docs(rich): 表格可读性、安卓编辑器、签名直链都验证过了
CI / backend (push) Canceled after 0s

- README:富文本行改成「Web Vditor 编辑 / 安卓 Markwon 预览编辑」,e2e 15 项
- ROADMAP 第 5 节:勾掉表格与超过一两行的旧描述(表格是只读渲染,
  HTML 片段仍按文本显示);附件改为短期签名 raw_url;安卓从"未做"改成已完成
- 记下仍欠的三件事:保存后回列表而不是回详情、图片压缩/缩略图、离线缓存

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过;安卓 assembleDebug 与模拟器逐屏走通。
This commit is contained in:
dadao
2026-10-29 03:19:33 +08:00
parent fbdf54cb4c
commit 8d61289f90
22 changed files with 911 additions and 90 deletions
+35 -20
View File
@@ -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 里的 `![](…/raw?token=…)` 用:浏览器 <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)
+3
View File
@@ -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,也不该为了装扩展去动它;
+45
View File
@@ -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)
+60 -2
View File
@@ -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