"""附件接口:上传 / 列表 / 下载 / 删除。 三条约束(都会有用例守): 1. 只认「本工作区的条目」。拿着别的租户的 item_id 上传,返回 5001 而不是 403—— 对外不该暴露那条记录是否存在。 2. 大小上限在**读流时**卡住,不能先落盘再判断(否则等于给了别人一个写满磁盘的入口)。 3. 磁盘上的名字由服务端生成,用户文件名只进库和 Content-Disposition。 """ from __future__ import annotations import urllib.parse import uuid from typing import Annotated from fastapi import APIRouter, File, Query, UploadFile from fastapi.responses import FileResponse from sqlalchemy import select from starlette.concurrency import run_in_threadpool from app.audit import record as audit_record from app.config import get_settings from app.db import bind_tenant from app.deps import MemberDep, SessionDep from app.errors import AppError, ErrorCode from app.models import Member 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 router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"]) ALLOWED_KINDS = ("task", "ticket", "knowledge") def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict: return { "id": str(row.id), "item_id": str(row.item_id), "filename": row.filename, "content_type": row.content_type, "size": row.size_bytes, "sha256": row.sha256, "uploader_id": str(row.uploader_id), "created_at": row.created_at.isoformat(), "can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"), } async def _load_item(session, ws_id, item_id) -> Item: item = await session.scalar( select(Item).where( Item.id == item_id, Item.workspace_id == ws_id, Item.deleted_at.is_(None), Item.kind.in_(ALLOWED_KINDS), ) ) if item is None: raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在") return item def _safe_filename(name: str | None, fallback: str = "未命名文件") -> str: """只用于展示与下载响应头:去掉路径分隔符与控制字符,长度截断。""" raw = (name or "").strip().replace("\\", "/").split("/")[-1] cleaned = "".join(ch for ch in raw if ch.isprintable() and ch not in '\r\n"') cleaned = cleaned.strip() or fallback if len(cleaned) > 200: stem, dot, ext = cleaned.rpartition(".") cleaned = (stem[:180] + dot + ext[-16:]) if dot and ext else cleaned[:200] return cleaned @router.post("/items/{item_id}/attachments", status_code=201) async def upload_attachment( ctx: MemberDep, session: SessionDep, item_id: str, file: Annotated[UploadFile, File()], ): item = await _load_item(session, ctx.ws_id, item_id) limit = get_settings().max_upload_mb * 1024 * 1024 # 先看声明的大小(可选,客户端不一定给),真正的限额在读流时兜底 if file.size is not None and file.size > limit: raise AppError( ErrorCode.ATTACHMENT_TOO_LARGE, f"文件超过 {get_settings().max_upload_mb}MB 上限", ) try: stored = await run_in_threadpool( storage.save, file.file, workspace_id=ctx.ws_id, item_id=item.id ) if stored.size > limit: await run_in_threadpool(storage.remove, stored.key) raise AppError( ErrorCode.ATTACHMENT_TOO_LARGE, f"文件超过 {get_settings().max_upload_mb}MB 上限", ) row = Attachment( workspace_id=ctx.ws_id, item_id=item.id, uploader_id=ctx.user.id, filename=_safe_filename(file.filename), content_type=(file.content_type or "application/octet-stream")[:120], size_bytes=stored.size, sha256=stored.sha256, storage_key=stored.key, ) session.add(row) await session.flush() await audit_record( session, workspace_id=ctx.ws_id, actor_user_id=ctx.user.id, action="attachment_uploaded", target_type="item", target_id=str(item.id), payload={"attachment_id": str(row.id), "filename": row.filename}, ) await session.commit() finally: await file.close() return ok(_attachment_out(row, ctx.user.id, ctx.role)) @router.get("/items/{item_id}/attachments") async def list_attachments(ctx: MemberDep, session: SessionDep, item_id: str): item = await _load_item(session, ctx.ws_id, item_id) rows = ( await session.scalars( select(Attachment) .where(Attachment.workspace_id == ctx.ws_id, Attachment.item_id == item.id) .order_by(Attachment.created_at.asc()) ) ).all() return ok({"items": [_attachment_out(row, ctx.user.id, ctx.role) for row in rows]}) @router.get("/attachments/{attachment_id}/download") async def download_attachment(ctx: MemberDep, session: SessionDep, attachment_id: str): row = await session.scalar( select(Attachment).where( Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id ) ) if row is None: raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") path = storage.path_of(row.storage_key) if not path.exists(): # 库里有记录、盘上没文件:说明备份漏了 blob,明确报错而不是返回空文件 raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员") return FileResponse( path, media_type=row.content_type, headers={ # 中文文件名必须按 RFC 5987 编码,否则浏览器拿到的是乱码 "Content-Disposition": "attachment; filename*=UTF-8''" + urllib.parse.quote(row.filename), # 附件是不可信内容:禁止浏览器嗅探类型,否则 .txt 里的 HTML 会被执行 "X-Content-Type-Options": "nosniff", }, ) @router.get("/attachments/{attachment_id}/raw") async def raw_attachment( ws_id: uuid.UUID, session: SessionDep, attachment_id: uuid.UUID, token: Annotated[str, Query(description="access token( 带不了请求头)")], ): """给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 无法附加 Authorization 头。 token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。 所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。 """ 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) 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, "附件不存在") row = await session.scalar( select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id) ) if row is None: raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") path = storage.path_of(row.storage_key) if not path.exists(): raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员") return FileResponse( path, media_type=row.content_type, headers={ "Content-Disposition": "inline", "X-Content-Type-Options": "nosniff", # 附件内容不变(key 由内容哈希决定),可以放心长缓存;换了文件就是换了 id "Cache-Control": "private, max-age=86400", }, ) @router.delete("/attachments/{attachment_id}") async def delete_attachment( ctx: MemberDep, session: SessionDep, attachment_id: str, confirm: bool = Query(False) ): row = await session.scalar( select(Attachment).where( Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id ) ) if row is None: raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在") is_uploader = row.uploader_id == ctx.user.id if not is_uploader and ctx.role not in ("owner", "admin"): raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "只有上传者或管理员能删除附件") if not confirm: # 删除不可逆,要求显式确认,避免误点 raise AppError( ErrorCode.CONFIRM_REQUIRED, "删除附件需要二次确认", details={"confirm": True} ) await run_in_threadpool(storage.remove, row.storage_key) await session.delete(row) await audit_record( session, workspace_id=ctx.ws_id, actor_user_id=ctx.user.id, action="attachment_deleted", target_type="item", target_id=str(row.item_id), payload={"attachment_id": str(row.id), "filename": row.filename}, ) await session.commit() return ok({"deleted": True}) async def list_for_item(session, ws_id, item_id) -> list[dict]: """给其他接口内联用(列表/详情里带上附件摘要)。""" rows = ( await session.scalars( select(Attachment) .where(Attachment.workspace_id == ws_id, Attachment.item_id == item_id) .order_by(Attachment.created_at.asc()) ) ).all() return [_attachment_out(row) for row in rows] async def count_for_items(session, ws_id, item_ids: list) -> dict[str, int]: """列表页只显示"几个附件",避免每条都拉全量。""" if not item_ids: return {} rows = ( await session.execute( select(Attachment.item_id).where( Attachment.workspace_id == ws_id, Attachment.item_id.in_(item_ids) ) ) ).all() counts: dict[str, int] = {} for (item_id,) in rows: counts[str(item_id)] = counts.get(str(item_id), 0) + 1 return counts __all__ = ["router", "list_for_item", "count_for_items"]