三件欠账一次清:
1. 图片直链签名化
- 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
(`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
- `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
- 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
- 保留旧 `?token=` 通道,给未升级的客户端过渡
2. 安卓富文本编辑器
- `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
- 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
- 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)
3. 表格
- 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
宽表横向滚动、窄表铺满,都不撑破抽屉
验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。
仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
This commit is contained in:
@@ -28,7 +28,7 @@ from app.models_attachment import Attachment
|
||||
from app.models_task import Item
|
||||
from app.schemas import ok
|
||||
from app.security import decode_token
|
||||
from app.services import storage
|
||||
from app.services import signing, storage
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
|
||||
|
||||
@@ -36,6 +36,7 @@ ALLOWED_KINDS = ("task", "ticket", "knowledge")
|
||||
|
||||
|
||||
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"item_id": str(row.item_id),
|
||||
@@ -45,6 +46,11 @@ def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
||||
"sha256": row.sha256,
|
||||
"uploader_id": str(row.uploader_id),
|
||||
"created_at": row.created_at.isoformat(),
|
||||
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 URL
|
||||
"raw_url": (
|
||||
f"{settings.api_prefix}/workspaces/{row.workspace_id}"
|
||||
f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}"
|
||||
),
|
||||
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
|
||||
}
|
||||
|
||||
@@ -176,31 +182,40 @@ async def raw_attachment(
|
||||
ws_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
attachment_id: uuid.UUID,
|
||||
token: Annotated[str, Query(description="access token(<img src> 带不了请求头)")],
|
||||
exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None,
|
||||
sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None,
|
||||
token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None,
|
||||
):
|
||||
"""给 Markdown 里的 `` 用:浏览器 <img> 无法附加 Authorization 头。
|
||||
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
|
||||
|
||||
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
|
||||
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
|
||||
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
|
||||
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
|
||||
"""
|
||||
try:
|
||||
user_id = decode_token(token, "access")
|
||||
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
|
||||
if exp is not None or sig is not None:
|
||||
if not signing.verify(ws_id, attachment_id, exp, sig):
|
||||
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面")
|
||||
# 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次
|
||||
await bind_tenant(session, ws_id=ws_id, user_id=None)
|
||||
else:
|
||||
if not token:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据")
|
||||
try:
|
||||
user_id = decode_token(token, "access")
|
||||
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
|
||||
|
||||
# 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次
|
||||
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
|
||||
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
|
||||
|
||||
member = await session.scalar(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
member = await session.scalar(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
)
|
||||
if member is None:
|
||||
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
if member is None:
|
||||
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
|
||||
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
|
||||
|
||||
row = await session.scalar(
|
||||
select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id)
|
||||
|
||||
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
|
||||
upload_dir: str = "var/uploads"
|
||||
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
|
||||
max_upload_mb: int = 20
|
||||
# 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上;
|
||||
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
|
||||
attachment_url_ttl_seconds: int = 1800
|
||||
|
||||
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||||
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""附件直链的短期签名。
|
||||
|
||||
为什么需要它:Markdown 里的图片是 `<img src="…">`,浏览器不会给它加 Authorization 头,
|
||||
所以图片地址必须自带凭据。把**登录令牌**塞进 query 是最省事的做法,但代价明确:
|
||||
令牌会进 Nginx access log、浏览器历史、以及任何复制粘贴出去的链接里,有效期还长达 30 分钟。
|
||||
|
||||
这里换成一个只为"这一张图"服务、只活 30 分钟的签名:
|
||||
`?exp=<unix 秒>&sig=<hmac 前 32 位>`。它不能用来调任何别的接口,泄漏了也只影响一张图的一小段时间。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# 域分离:即使有人拿 JWT 密钥来试,也拿不到同样的签名(反之亦然)
|
||||
_DOMAIN = b"attachment-raw-v1"
|
||||
|
||||
|
||||
def _key() -> bytes:
|
||||
base = get_settings().jwt_secret.encode("utf-8")
|
||||
return hmac.new(base, _DOMAIN, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _digest(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int) -> str:
|
||||
# 工作区也签进去,避免拿 A 工作区的签名去读 B 工作区的文件
|
||||
payload = f"{workspace_id}:{attachment_id}:{exp}".encode()
|
||||
return hmac.new(_key(), payload, hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | None = None) -> str:
|
||||
"""返回可直接拼在 `/raw` 后面的查询串。"""
|
||||
ttl = ttl_seconds if ttl_seconds is not None else get_settings().attachment_url_ttl_seconds
|
||||
exp = int(time.time()) + ttl
|
||||
return f"exp={exp}&sig={_digest(workspace_id, attachment_id, exp)}"
|
||||
|
||||
|
||||
def verify(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None) -> bool:
|
||||
if not exp or not sig:
|
||||
return False
|
||||
if exp < int(time.time()): # 过期就是过期,不给宽限
|
||||
return False
|
||||
return hmac.compare_digest(_digest(workspace_id, attachment_id, exp), sig)
|
||||
@@ -13,7 +13,10 @@ from typing import Any
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
import uuid
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import signing
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
@@ -160,11 +163,66 @@ async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None:
|
||||
assert resp.headers["content-disposition"] == "inline"
|
||||
assert resp.headers["cache-control"].startswith("private")
|
||||
|
||||
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
|
||||
# 既没签名也没令牌:401(而不是把"缺凭据"说成"文件不存在")
|
||||
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
|
||||
assert missing.status_code == 422
|
||||
assert missing.status_code == 401
|
||||
assert missing.json()["code"] == 2001
|
||||
outsider = await login(client)
|
||||
leaked = await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
|
||||
)
|
||||
assert leaked.json()["code"] == 8001
|
||||
|
||||
|
||||
async def test_raw_url_uses_short_lived_signature(client: AsyncClient) -> None:
|
||||
"""图片直链自带短期签名:不需要把登录令牌贴进 URL,也不需要 Authorization 头。"""
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
body = b"\x89PNG\r\n\x1a\n signed png"
|
||||
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"]
|
||||
)
|
||||
)
|
||||
raw_url = listed["items"][0]["raw_url"]
|
||||
assert "exp=" in raw_url and "sig=" in raw_url
|
||||
assert "token=" not in raw_url # 不该再把登录令牌塞进 URL
|
||||
|
||||
# 裸请求(无 Authorization、无 cookie)也要能取到图——这才是 <img> 的真实处境
|
||||
resp = await client.get(raw_url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == body
|
||||
|
||||
|
||||
async def test_raw_url_rejects_tampered_or_expired_signature(client: AsyncClient) -> None:
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
created = unwrap(await _upload(client, user, ws["id"], item_id, b"data", "图.png"))
|
||||
base = f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw"
|
||||
|
||||
# 改一个字符就该失效:签名不是"看起来对就行"
|
||||
good = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]))
|
||||
tampered = good[:-1] + ("0" if good[-1] != "0" else "1")
|
||||
bad = await client.get(f"{base}?{tampered}")
|
||||
assert bad.status_code == 403
|
||||
assert bad.json()["code"] == 8003
|
||||
|
||||
# 过期签名:就算 HMAC 算得对,时间过了也不给
|
||||
stale = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]), ttl_seconds=-10)
|
||||
expired = await client.get(f"{base}?{stale}")
|
||||
assert expired.status_code == 403
|
||||
assert expired.json()["code"] == 8003
|
||||
|
||||
|
||||
async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> None:
|
||||
"""拿 A 工作区签出来的链接去请求 B 工作区,必须失败(签名里带 workspace_id)。"""
|
||||
user, ws_a, item_a = await _ws_with_item(client, "甲工作区")
|
||||
ws_b = await create_workspace(client, user, "乙工作区")
|
||||
created = unwrap(await _upload(client, user, ws_a["id"], item_a, b"data", "图.png"))
|
||||
|
||||
query = signing.sign(uuid.UUID(ws_a["id"]), uuid.UUID(created["id"]))
|
||||
crossed = await client.get(
|
||||
f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}"
|
||||
)
|
||||
assert crossed.status_code == 403
|
||||
assert crossed.json()["code"] == 8003
|
||||
Reference in New Issue
Block a user