"""本地文件存储。 M0/M1 用本地磁盘就够:单机、量小、备份简单。要做 SaaS 多副本时换成对象存储, 只需要换掉本模块的三个函数(写入 / 读取路径 / 删除),API 层不用动。 安全约定: - 磁盘路径完全由服务端生成(租户 + 条目 + 随机文件名),**不使用用户提供的文件名**, 否则 `../../` 之类可以直接逃出上传目录。 - 原始文件名只存库、只出现在下载响应头里。 """ from __future__ import annotations import contextlib import hashlib import uuid from dataclasses import dataclass from pathlib import Path from app.config import get_settings @dataclass(frozen=True) class Stored: key: str size: int sha256: str def root() -> Path: path = Path(get_settings().upload_dir) path.mkdir(parents=True, exist_ok=True) return path def path_of(key: str) -> Path: """key 由服务端生成,这里仍做一次越界校验——防御的是库被写脏。""" base = root().resolve() target = (base / key).resolve() if base not in target.parents: raise ValueError("storage key escapes upload root") return target def save(stream, *, workspace_id: uuid.UUID, item_id: uuid.UUID) -> Stored: """把文件流写进磁盘,返回 key / 大小 / 摘要。 `stream` 是同步的二进制可读对象;调用方用 `run_in_threadpool` 包一层, 避免大文件读写卡住事件循环。 """ key = f"{workspace_id}/{item_id}/{uuid.uuid4().hex}" target = path_of(key) target.parent.mkdir(parents=True, exist_ok=True) digest = hashlib.sha256() size = 0 with target.open("wb") as out: while True: chunk = stream.read(1024 * 256) if not chunk: break digest.update(chunk) size += len(chunk) out.write(chunk) return Stored(key=key, size=size, sha256=digest.hexdigest()) def remove(key: str) -> None: # 删文件时"文件已经不在了"和"key 格式不对"都不算错误: # 前者是重复删除,后者是库里有脏数据,都只能靠日志发现问题,不该让请求失败。 with contextlib.suppress(FileNotFoundError, ValueError): path_of(key).unlink()