前言
在 Markdown 知識庫系統設計 中,我們建立了企業內部的知識庫。但有時需要將知識分享給外部使用者(客戶、合作夥伴),這篇來實作公開分享連結功能:
- 產生短網址分享知識、專案、檔案
- Token 驗證確保安全
- 可設定有效期限
- 存取次數追蹤
系統架構
用戶(內部) 用戶(外部)
│ │
│ 建立分享連結 │ 存取連結
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ POST /api/share │ │ GET /api/public/ │
│ (需要登入) │ │ {token} │
└────────┬────────┘ │ (無需登入) │
│ └────────┬────────┘
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ public_share_links │
│ token | resource_type | resource_id | expires_at | ... │
└─────────────────────────────────────────────────────────┘
資料表設計
CREATE TABLE public_share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token VARCHAR(16) NOT NULL UNIQUE, -- 短 token
resource_type VARCHAR(32) NOT NULL, -- knowledge, project, nas_file, project_attachment
resource_id TEXT NOT NULL, -- 資源識別碼
created_by VARCHAR(64) NOT NULL, -- 建立者
expires_at TIMESTAMP WITH TIME ZONE, -- 過期時間(NULL 表示永久)
access_count INTEGER DEFAULT 0, -- 存取次數
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_share_links_token ON public_share_links(token);
CREATE INDEX idx_share_links_created_by ON public_share_links(created_by);
支援的資源類型
| 類型 | 說明 | resource_id 格式 |
|---|---|---|
knowledge |
知識庫文件 | kb-001 |
project |
專案資訊 | UUID |
nas_file |
NAS 檔案 | 檔案路徑 |
project_attachment |
專案附件 | 附件 UUID |
Pydantic 模型
建立連結請求
class ShareLinkCreate(BaseModel):
"""建立分享連結請求"""
resource_type: Literal["knowledge", "project", "nas_file", "project_attachment"]
resource_id: str
expires_in: str | None = "24h" # 1h, 24h, 7d, null(永久)
連結回應
class ShareLinkResponse(BaseModel):
"""分享連結回應"""
token: str
url: str # /s/{token}
full_url: str # https://xxx.com/s/{token}
resource_type: str
resource_id: str
resource_title: str
expires_at: datetime | None
access_count: int = 0
created_at: datetime
created_by: str | None = None
is_expired: bool = False
公開資源回應
class PublicResourceResponse(BaseModel):
"""公開資源回應"""
type: Literal["knowledge", "project", "nas_file", "project_attachment"]
data: dict[str, Any] # 資源內容
shared_by: str
shared_at: datetime
expires_at: datetime | None
Token 產生
使用加密安全的隨機產生器產生 6 字元 Token:
import secrets
import string
def generate_token(length: int = 6) -> str:
"""產生隨機 token
使用加密安全的隨機產生器
"""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
為什麼選 6 字元?
| 長度 | 組合數 | 碰撞機率(10萬筆) |
|---|---|---|
| 4 | 1,400 萬 | 0.7% |
| 6 | 568 億 | < 0.0001% |
| 8 | 218 兆 | 極低 |
6 字元在安全性和易用性之間取得平衡。
有效期設定
def parse_expires_in(expires_in: str | None) -> datetime | None:
"""解析有效期設定
Args:
expires_in: 1h, 24h, 7d, null(永久)
Returns:
過期時間(UTC),None 表示永久
"""
if expires_in is None or expires_in == "null":
return None
now = datetime.now(timezone.utc)
if expires_in == "1h":
return now + timedelta(hours=1)
elif expires_in == "24h":
return now + timedelta(hours=24)
elif expires_in == "7d":
return now + timedelta(days=7)
else:
# 預設 24 小時
return now + timedelta(hours=24)
有效期選項
| 選項 | 適用場景 |
|---|---|
1h |
一次性分享、敏感資料 |
24h |
日常分享(預設) |
7d |
長期協作 |
null |
永久連結(謹慎使用) |
建立分享連結
服務層
async def create_share_link(
data: ShareLinkCreate,
created_by: str,
) -> ShareLinkResponse:
"""建立分享連結"""
# 驗證資源存在
resource_title = await get_resource_title(data.resource_type, data.resource_id)
async with get_connection() as conn:
# 嘗試產生唯一 token(最多 10 次)
for _ in range(10):
token = generate_token()
# 檢查是否已存在
existing = await conn.fetchval(
"SELECT 1 FROM public_share_links WHERE token = $1",
token,
)
if not existing:
break
else:
raise ShareError("無法產生唯一的 token")
# 計算過期時間
expires_at = parse_expires_in(data.expires_in)
# 儲存到資料庫
now = datetime.now(timezone.utc)
row = await conn.fetchrow(
"""
INSERT INTO public_share_links
(token, resource_type, resource_id, created_by, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
""",
token, data.resource_type, data.resource_id,
created_by, expires_at, now,
)
return ShareLinkResponse(
token=row["token"],
url=f"/s/{row['token']}",
full_url=get_full_url(row["token"]),
resource_type=row["resource_type"],
resource_id=row["resource_id"],
resource_title=resource_title,
expires_at=row["expires_at"],
access_count=row["access_count"],
created_at=row["created_at"],
)
API 端點
@router.post(
"",
response_model=ShareLinkResponse,
status_code=status.HTTP_201_CREATED,
summary="建立分享連結",
)
async def create_link(
data: ShareLinkCreate,
session: SessionData = Depends(get_current_session),
) -> ShareLinkResponse:
"""建立公開分享連結
只有資源擁有者或有編輯權限的人可以建立連結。
"""
# 權限檢查
if data.resource_type == "knowledge":
knowledge = get_knowledge(data.resource_id)
preferences = await get_user_preferences(session.user_id)
if not check_knowledge_permission(
session.username, preferences, knowledge.owner, knowledge.scope, "write"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="您沒有分享此知識的權限",
)
elif data.resource_type == "nas_file":
# 驗證 NAS 檔案存在且在允許範圍內
validate_nas_file_path(data.resource_id)
return await create_share_link(data, session.username)
存取公開資源
公開 API(無需登入)
# 無需登入的公開 API
public_router = APIRouter(prefix="/api/public", tags=["public"])
@public_router.get(
"/{token}",
response_model=PublicResourceResponse,
summary="取得公開資源",
)
async def get_resource(token: str) -> PublicResourceResponse:
"""取得公開分享的資源內容
無需登入即可存取。
"""
try:
return await get_public_resource(token)
except ShareLinkNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="連結不存在或已被撤銷",
)
except ShareLinkExpiredError:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="此連結已過期",
)
取得公開資源服務
async def get_public_resource(token: str) -> PublicResourceResponse:
"""取得公開資源"""
async with get_connection() as conn:
# 查詢連結
row = await conn.fetchrow(
"""
SELECT token, resource_type, resource_id, created_by, expires_at, created_at
FROM public_share_links
WHERE token = $1
""",
token,
)
if not row:
raise ShareLinkNotFoundError("連結不存在或已被撤銷")
# 檢查是否過期
now = datetime.now(timezone.utc)
if row["expires_at"] and row["expires_at"] < now:
raise ShareLinkExpiredError("此連結已過期")
# 更新存取次數
await conn.execute(
"UPDATE public_share_links SET access_count = access_count + 1 WHERE token = $1",
token,
)
# 取得資源內容
resource_type = row["resource_type"]
resource_id = row["resource_id"]
if resource_type == "knowledge":
knowledge = get_knowledge(resource_id)
data = {
"id": knowledge.id,
"title": knowledge.title,
"content": knowledge.content,
"attachments": [...],
"created_at": knowledge.created_at.isoformat(),
"updated_at": knowledge.updated_at.isoformat(),
}
elif resource_type == "project":
project = await get_project(UUID(resource_id))
# 只顯示安全的資訊
data = {
"id": str(project.id),
"name": project.name,
"description": project.description,
"status": project.status,
"milestones": [...],
"members": [{"name": m.name, "role": m.role} for m in project.members],
}
elif resource_type == "nas_file":
full_path = validate_nas_file_path(resource_id)
data = {
"file_name": full_path.name,
"file_size_str": format_size(full_path.stat().st_size),
"download_url": f"/api/public/{token}/download",
}
return PublicResourceResponse(
type=resource_type,
data=data,
shared_by=row["created_by"],
shared_at=row["created_at"],
expires_at=row["expires_at"],
)
附件與檔案下載
知識庫附件
@public_router.get(
"/{token}/attachments/{path:path}",
summary="取得公開資源的附件",
)
async def get_public_attachment(token: str, path: str) -> Response:
"""取得公開資源的附件
僅限知識庫附件,無需登入。
"""
# 驗證 token 有效
link_info = await get_link_info(token)
# 只支援知識庫附件
if link_info["resource_type"] != "knowledge":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="此資源類型不支援附件",
)
kb_id = link_info["resource_id"]
filename = path.split("/")[-1]
# 驗證附件路徑是否屬於該知識庫
if not filename.startswith(f"{kb_id}-"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權存取此附件",
)
# 讀取並回傳檔案...
檔案下載
@public_router.get(
"/{token}/download",
summary="下載檔案",
)
async def download_shared_file(token: str) -> Response:
"""透過分享連結下載檔案
支援 nas_file 和 project_attachment 類型。
"""
link_info = await get_link_info(token)
resource_type = link_info["resource_type"]
if resource_type == "nas_file":
file_path = link_info["resource_id"]
full_path = validate_nas_file_path(file_path)
content = full_path.read_bytes()
filename = full_path.name
elif resource_type == "project_attachment":
attachment_id = link_info["resource_id"]
content, filename = await get_attachment_content(attachment_id)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="此連結不是檔案下載連結",
)
# 處理檔名編碼(支援中文)
encoded_filename = quote(filename)
# 圖片用 inline,其他用 attachment
is_image = mime_type and mime_type.startswith("image/")
disposition = "inline" if is_image else "attachment"
return Response(
content=content,
media_type=mime_type or "application/octet-stream",
headers={
"Content-Disposition": f"{disposition}; filename*=UTF-8''{encoded_filename}",
},
)
連結管理
列出我的連結
@router.get(
"",
response_model=ShareLinkListResponse,
summary="列出分享連結",
)
async def list_links(
view: str = "mine",
session: SessionData = Depends(get_current_session),
) -> ShareLinkListResponse:
"""列出分享連結
Args:
view: "mine" 只顯示自己的,"all" 顯示全部(僅管理員)
"""
user_is_admin = is_admin(session.username)
if view == "all" and user_is_admin:
result = await list_all_links()
else:
result = await list_my_links(session.username)
result.is_admin = user_is_admin
return result
撤銷連結
@router.delete(
"/{token}",
status_code=status.HTTP_204_NO_CONTENT,
summary="撤銷分享連結",
)
async def delete_link(
token: str,
session: SessionData = Depends(get_current_session),
) -> None:
"""撤銷分享連結
連結建立者或管理員可以撤銷。
"""
await revoke_link(token, session.username, is_admin(session.username))
自動清理過期連結
async def cleanup_expired_links() -> int:
"""清理過期的分享連結
刪除所有 expires_at < 當前時間 的連結。
永久連結(expires_at 為 NULL)不會被刪除。
Returns:
刪除的連結數量
"""
async with get_connection() as conn:
now = datetime.now(timezone.utc)
result = await conn.execute(
"""
DELETE FROM public_share_links
WHERE expires_at IS NOT NULL AND expires_at < $1
""",
now,
)
# result 格式為 "DELETE N"
deleted_count = int(result.split()[-1]) if result else 0
return deleted_count
安全性考量
路徑穿越防護
def validate_nas_file_path(file_path: str) -> Path:
"""驗證 NAS 檔案路徑"""
projects_path = Path(settings.projects_mount_path)
# 正規化路徑
full_path = (projects_path / file_path).resolve()
# 安全檢查:確保路徑在允許範圍內
if not str(full_path).startswith(str(projects_path.resolve())):
raise NasFileAccessDenied(f"不允許存取此路徑:{file_path}")
if not full_path.exists():
raise NasFileNotFoundError(f"檔案不存在:{file_path}")
return full_path
附件存取驗證
# 驗證附件路徑是否屬於該知識庫
if not path.startswith(f"attachments/{kb_id}/"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="無權存取此附件",
)
權限控制
- 建立連結需要登入且有編輯權限
- 存取連結無需登入(方便外部使用者)
- 撤銷連結只有建立者或管理員可以
使用流程
內部使用者 外部使用者
│ │
│ 1. 找到要分享的知識/專案 │
│ 2. 點擊「分享」按鈕 │
│ 3. 選擇有效期 │
│ │
▼ │
┌─────────────────────────┐ │
│ 產生分享連結 │ │
│ https://xxx.com/s/Ab3Xyz │ │
└─────────────────────────┘ │
│ │
│ 4. 複製連結發送給外部使用者 │
├──────────────────────────────────────►
│ │
│ │ 5. 點擊連結
│ │
│ ┌───────▼───────┐
│ │ 檢查 Token │
│ │ 檢查有效期 │
│ │ 記錄存取次數 │
│ └───────┬───────┘
│ │
│ │ 6. 顯示內容
│ ▼
│ ┌───────────────┐
│ │ 公開檢視頁面 │
│ └───────────────┘
小結
公開分享連結功能的關鍵設計:
| 特性 | 實作方式 |
|---|---|
| 短網址 | 6 字元隨機 Token |
| 有效期 | 1h / 24h / 7d / 永久 |
| 存取追蹤 | access_count 計數 |
| 安全性 | 路徑驗證、權限檢查 |
| 管理 | 列出、撤銷、自動清理 |
API 端點:
| 端點 | 功能 | 登入需求 |
|---|---|---|
POST /api/share |
建立連結 | 需要 |
GET /api/share |
列出我的連結 | 需要 |
DELETE /api/share/{token} |
撤銷連結 | 需要 |
GET /api/public/{token} |
存取公開資源 | 不需要 |
GET /api/public/{token}/download |
下載檔案 | 不需要 |
下一篇 手機版 App 佈局優化實戰 會介紹如何優化 PWA 在手機上的使用體驗。