三件欠账一次清:
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)
|
||||
|
||||
Reference in New Issue
Block a user