- 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:
@@ -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)
|
||||
Reference in New Issue
Block a user