diff --git a/apps/admin/dependencies.py b/apps/admin/dependencies.py index 45dcebedd..bbdc5f994 100644 --- a/apps/admin/dependencies.py +++ b/apps/admin/dependencies.py @@ -14,7 +14,6 @@ ADMIN_SESSION_EXPIRE_MIN, settings, ) -from apps.admin.services import FileService, ConfigService, LocalFileService def _get_jwt_secret() -> bytes: @@ -173,12 +172,18 @@ async def share_required_login(authorization: str = Header(default=None)): async def get_file_service(): + # 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。 + from apps.admin.services import FileService return FileService() async def get_config_service(): + # 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。 + from apps.admin.services import ConfigService return ConfigService() async def get_local_file_service(): + # 工厂调用时加载业务服务,鉴权模块不依赖上传与文件管理实现。 + from apps.admin.services import LocalFileService return LocalFileService() diff --git a/apps/admin/services.py b/apps/admin/services.py index be9ae4201..f3dde0934 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -14,7 +14,7 @@ from apps.base.config import refresh_settings from apps.base.services import get_stored_download, response_from_download, stored_file_of from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret -from apps.base.models import FileCodes, KeyValue +from apps.base.models import DeliveryCode, FileCodes, KeyValue from apps.base.utils import get_expire_info from apps.base.local_share import ( LOCAL_REF_MARKER, @@ -26,6 +26,8 @@ resolve_under_local, should_skip_storage_delete, ) +from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags +from apps.base.share_storage import storage_for_share, storage_type_for_share from fastapi import HTTPException from core.utils import get_now, hash_password, is_password_hashed, validate_background_url @@ -102,8 +104,11 @@ def _file_metadata_key(self, file_id: int) -> str: return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}" async def _delete_file_code(self, file_code: FileCodes): + # 寄件文件与普通文件共用删除流程,失败时保留记录供重试。 + # NAS 引用只删除分享记录,不能删除原始文件。 if not should_skip_storage_delete(file_code): - await self.file_storage.delete_file(stored_file_of(file_code)) + storage = await storage_for_share(file_code, self._file_storage) + await storage.delete_file(stored_file_of(file_code)) await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete() await file_code.delete() @@ -220,7 +225,8 @@ async def update_file( raise HTTPException(status_code=404, detail="文件不存在") update_data: dict[str, Any] = {} - if code is not None and code != file_code.code: + # 历史私有文件没有公开口令,普通编辑不能改变其内部标识或公开权限。 + if code is not None and not file_code.is_private and code != file_code.code: if await FileCodes.filter(code=code).first(): raise HTTPException(status_code=400, detail="code已存在") update_data["code"] = code @@ -481,6 +487,7 @@ async def list_files( health: str = "", sort_by: str = "created_at", sort_order: str = "desc", + delivery_id: int | None = None, ): page = max(page, 1) size = min(max(size, 1), 100) @@ -491,7 +498,13 @@ async def list_files( sort_by = self._normalize_sort_by(sort_by) reverse = sort_order.strip().lower() != "asc" - all_files = await FileCodes.all() + query = FileCodes.all() + if delivery_id is not None: + # 收件列表复用文件管理的数据与操作,只限定当前管理员选中的寄件码。 + if not await DeliveryCode.filter(id=delivery_id).exists(): + raise HTTPException(404, "寄件码不存在") + query = query.filter(delivery_id=delivery_id) + all_files = await query now = await get_now() enriched_files = [] summary = { @@ -582,6 +595,8 @@ async def _build_admin_file_item( ) -> dict[str, Any]: if now is None: now = await get_now() + # 兼容未携带寄件字段的原文件列表对象,普通文件仍按原规则展示。 + is_private = getattr(file_code, "is_private", False) is_text = file_code.text is not None is_expired = await file_code.is_expired() name = f"{file_code.prefix}{file_code.suffix}" @@ -593,7 +608,8 @@ async def _build_admin_file_item( ) data = { "id": file_code.id, - "code": file_code.code, + "code": "" if is_private else file_code.code, + "is_private": is_private, "prefix": file_code.prefix, "suffix": file_code.suffix, "uuid_file_name": file_code.uuid_file_name, @@ -668,6 +684,8 @@ async def get_file_detail(self, file_id: int): is_text=is_text, ) + # 只有寄件文件展示授权后端,普通文件仍显示站点当前设置。 + display_storage_type = await storage_type_for_share(file_code) detail.update( { "filename": detail["name"], @@ -678,7 +696,7 @@ async def get_file_detail(self, file_id: int): "text_length": text_length, "can_preview_text": is_text, "can_download": can_download, - "storage_backend": settings.file_storage, + "storage_backend": display_storage_type, "file_path": file_code.file_path, "uuid_file_name": file_code.uuid_file_name, "upload_id": file_code.upload_id, @@ -690,7 +708,7 @@ async def get_file_detail(self, file_id: int): "is_permanent": is_permanent, }, "storage": { - "backend": settings.file_storage, + "backend": display_storage_type, "file_path": file_code.file_path, "uuid_file_name": file_code.uuid_file_name, "file_hash": file_code.file_hash, @@ -713,33 +731,12 @@ async def get_file_detail(self, file_id: int): return detail def _normalize_metadata_note(self, note: Optional[str]) -> str: - if note is None: - return "" - return str(note).strip()[: self.MAX_METADATA_NOTE_LENGTH] + # 与寄件管理共用规则,避免两个后台的备注长度和裁剪行为不一致。 + return normalize_metadata_note(note) def _normalize_metadata_tags(self, tags: Any) -> list[str]: - if not tags: - return [] - if isinstance(tags, str): - tags = [tags] - elif not isinstance(tags, list): - return [] - - normalized_tags = [] - seen_tags = set() - for raw_tag in tags: - tag = str(raw_tag).strip() - if not tag: - continue - tag = tag[: self.MAX_METADATA_TAG_LENGTH] - dedupe_key = tag.lower() - if dedupe_key in seen_tags: - continue - seen_tags.add(dedupe_key) - normalized_tags.append(tag) - if len(normalized_tags) >= self.MAX_METADATA_TAGS: - break - return normalized_tags + # 标签数量、长度和忽略大小写去重统一由公共模块维护。 + return normalize_metadata_tags(tags) def _normalize_file_metadata(self, metadata: Any) -> dict[str, Any]: if not isinstance(metadata, dict): @@ -1417,7 +1414,8 @@ async def download_file(self, file_id: int): if file_code.text: return APIResponse(detail=file_code.text) else: - return response_from_download(await get_stored_download(file_code, self.file_storage)) + # NAS 和普通下载沿用上游路径,寄件文件使用其授权后端。 + return response_from_download(await get_stored_download(file_code, self._file_storage)) async def preview_file(self, file_id: int, max_chars: int = 4000): max_chars = min(max(max_chars, 1), 20000) @@ -1431,7 +1429,8 @@ async def preview_file(self, file_id: int, max_chars: int = 4000): preview = content[:max_chars] return { "id": file_code.id, - "code": file_code.code, + "code": "" if file_code.is_private else file_code.code, + "is_private": file_code.is_private, "name": f"{file_code.prefix}{file_code.suffix}", "type": "text", "content": preview, @@ -1448,6 +1447,7 @@ async def share_local_file(self, item): if not await local_file.exists(): raise HTTPException(status_code=404, detail="文件不存在") + # NAS 分享引用已有文件,不复制到上传目录,也不消耗上传配额。 expired_at, expired_count, used_count, code = await get_expire_info( item.expire_value, item.expire_style ) diff --git a/apps/admin/views.py b/apps/admin/views.py index aa665ff74..2523bcca8 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -386,6 +386,7 @@ async def file_list( health: str = "", sort_by: str = "created_at", sort_order: str = "desc", + delivery_id: Optional[int] = None, file_service: FileService = Depends(get_file_service), ): page = max(page, 1) @@ -399,6 +400,7 @@ async def file_list( health=health, sort_by=sort_by, sort_order=sort_order, + delivery_id=delivery_id, ) return APIResponse( detail={ diff --git a/apps/base/metadata.py b/apps/base/metadata.py new file mode 100644 index 000000000..f0b3ec4c3 --- /dev/null +++ b/apps/base/metadata.py @@ -0,0 +1,28 @@ +"""文件与寄件管理共用的用户元数据归一化规则。""" + +from typing import Any + + +def normalize_metadata_note(value: Any) -> str: + """备注转为文本、去除首尾空白,并限制为 2000 个字符。""" + return "" if value is None else str(value).strip()[:2000] + + +def normalize_metadata_tags(value: Any) -> list[str]: + """标签最多 12 个、单项 24 字符,并按忽略大小写规则去重。""" + if not value: + return [] + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + result, seen = [], set() + for raw_tag in value: + tag = str(raw_tag).strip()[:24] + if not tag or tag.lower() in seen: + continue + seen.add(tag.lower()) + result.append(tag) + if len(result) == 12: + break + return result diff --git a/apps/base/migrations/migrations_008.py b/apps/base/migrations/migrations_008.py new file mode 100644 index 000000000..a3b0701ff --- /dev/null +++ b/apps/base/migrations/migrations_008.py @@ -0,0 +1,42 @@ +"""新增寄件口令与独立收件记录;保留既有分享表及其权限语义。""" + +from tortoise import connections + + +async def migrate(): + # 当前应用使用 SQLite;表定义与 Tortoise 模型保持一致,可重复执行。 + await connections.get("default").execute_script(""" + CREATE TABLE IF NOT EXISTS deliverycode ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + code_digest VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + owner_id VARCHAR(64) NOT NULL DEFAULT 'admin', + storage_type VARCHAR(20) NOT NULL, + target_path VARCHAR(200) NOT NULL, + expires_at TIMESTAMP NOT NULL, + max_uploads INT NOT NULL, + used_count INT NOT NULL DEFAULT 0, + reserved_count INT NOT NULL DEFAULT 0, + enabled INT NOT NULL DEFAULT 1, + deleted INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_deliverycode_owner ON deliverycode(owner_id); + CREATE TABLE IF NOT EXISTS deliveryfile ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + delivery_id INT NOT NULL, + owner_id VARCHAR(64) NOT NULL DEFAULT 'admin', + token VARCHAR(64) NOT NULL UNIQUE, + filename VARCHAR(255) NOT NULL DEFAULT '', + stored_name VARCHAR(255) NOT NULL DEFAULT '', + file_path VARCHAR(200) NOT NULL, + storage_type VARCHAR(20) NOT NULL, + size BIGINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_deliveryfile_code ON deliveryfile(delivery_id); + CREATE INDEX IF NOT EXISTS idx_deliveryfile_owner ON deliveryfile(owner_id); + CREATE INDEX IF NOT EXISTS idx_deliveryfile_status ON deliveryfile(status); + """) diff --git a/apps/base/migrations/migrations_009.py b/apps/base/migrations/migrations_009.py new file mode 100644 index 000000000..da296a2ca --- /dev/null +++ b/apps/base/migrations/migrations_009.py @@ -0,0 +1,11 @@ +"""保留管理员可查看的寄件码原文,既有摘要及口令有效性保持不变。""" + +from tortoise import connections + + +async def migrate(): + conn = connections.get("default") + # 可重复执行;旧数据保持 NULL,不能伪造或替换用户此前分发的口令。 + columns = await conn.execute_query_dict("PRAGMA table_info(deliverycode)") + if not any(column["name"] == "code_value" for column in columns): + await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN code_value VARCHAR(64) NULL;") diff --git a/apps/base/migrations/migrations_010.py b/apps/base/migrations/migrations_010.py new file mode 100644 index 000000000..650fef38a --- /dev/null +++ b/apps/base/migrations/migrations_010.py @@ -0,0 +1,11 @@ +"""将新的寄件授权上传关联到普通取件记录,保留历史私有收件。""" + +from tortoise import connections + + +async def migrate(): + conn = connections.get("default") + columns = await conn.execute_query_dict("PRAGMA table_info(deliveryfile)") + if not any(column["name"] == "share_id" for column in columns): + await conn.execute_script("ALTER TABLE deliveryfile ADD COLUMN share_id INT NULL;") + await conn.execute_script("CREATE INDEX IF NOT EXISTS idx_deliveryfile_share_id ON deliveryfile(share_id);") diff --git a/apps/base/migrations/migrations_011.py b/apps/base/migrations/migrations_011.py new file mode 100644 index 000000000..e8f757347 --- /dev/null +++ b/apps/base/migrations/migrations_011.py @@ -0,0 +1,34 @@ +"""仅为寄件分享保存授权指定的实际存储后端。""" + +from tortoise import connections + + +async def migrate(): + """幂等增加字段,并仅用寄件关联补全可验证的旧记录。""" + conn = connections.get("default") + tables = { + "filecodes": "storage_type", + } + for table, column in tables.items(): + columns = await conn.execute_query_dict(f"PRAGMA table_info({table})") + if not any(item["name"] == column for item in columns): + await conn.execute_script( + f"ALTER TABLE {table} ADD COLUMN {column} VARCHAR(20) NULL;" + ) + + # 仅 DeliveryFile 的关联记录能证明旧分享的实际后端;其余历史数据保持未知。 + await conn.execute_script( + """ + UPDATE filecodes + SET storage_type = ( + SELECT storage_type FROM deliveryfile + WHERE deliveryfile.share_id = filecodes.id + LIMIT 1 + ) + WHERE storage_type IS NULL + AND EXISTS ( + SELECT 1 FROM deliveryfile + WHERE deliveryfile.share_id = filecodes.id + ); + """ + ) diff --git a/apps/base/migrations/migrations_012.py b/apps/base/migrations/migrations_012.py new file mode 100644 index 000000000..2de6b809b --- /dev/null +++ b/apps/base/migrations/migrations_012.py @@ -0,0 +1,16 @@ +"""为寄件码补充管理备注、标签和授权版本,兼容已部署的 SQLite 数据库。""" + +from tortoise import connections + + +async def migrate(): + """按列存在性增量迁移,重复启动不会修改已有数据。""" + conn = connections.get("default") + columns = await conn.execute_query_dict("PRAGMA table_info(deliverycode)") + names = {column["name"] for column in columns} + if "note" not in names: + await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN note VARCHAR(2000) NOT NULL DEFAULT '';" ) + if "tags" not in names: + await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN tags JSON NOT NULL DEFAULT '[]';") + if "auth_version" not in names: + await conn.execute_script("ALTER TABLE deliverycode ADD COLUMN auth_version INT NOT NULL DEFAULT 1;") diff --git a/apps/base/migrations/migrations_013.py b/apps/base/migrations/migrations_013.py new file mode 100644 index 000000000..b8aec1d08 --- /dev/null +++ b/apps/base/migrations/migrations_013.py @@ -0,0 +1,72 @@ +"""收件归入普通文件表;旧私有文件保持私有,未完成上传迁入短期容量预留。""" + +import os +import uuid +from tortoise.transactions import in_transaction + + +async def migrate(): + # 整体事务保证迁移失败可以回滚,影子表仅在全部记录转移成功后移除。 + async with in_transaction() as conn: + additions = { + "filecodes": {"delivery_id": "INT NULL", "is_private": "INT NOT NULL DEFAULT 0"}, + "storagereservation": { + "delivery_id": "INT NULL", "auth_version": "INT NOT NULL DEFAULT 1", + "status": "VARCHAR(20) NOT NULL DEFAULT 'pending'", + "filename": "VARCHAR(255) NOT NULL DEFAULT ''", + "stored_name": "VARCHAR(255) NOT NULL DEFAULT ''", + "file_path": "VARCHAR(255) NOT NULL DEFAULT ''", + "storage_type": "VARCHAR(20) NULL", + }, + } + for table, fields in additions.items(): + names = {row["name"] for row in await conn.execute_query_dict(f"PRAGMA table_info({table})")} + for name, declaration in fields.items(): + if name not in names: + await conn.execute_query(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}") + await conn.execute_query("CREATE INDEX IF NOT EXISTS idx_filecodes_delivery_id ON filecodes(delivery_id)") + await conn.execute_query("CREATE INDEX IF NOT EXISTS idx_reservation_delivery_id ON storagereservation(delivery_id)") + exists = await conn.execute_query_dict("SELECT name FROM sqlite_master WHERE type='table' AND name='deliveryfile'") + if not exists: + return + # 分批读取避免升级时将全部文件记录载入内存。 + cursor = 0 + while True: + rows = await conn.execute_query_dict("SELECT * FROM deliveryfile WHERE id > ? ORDER BY id LIMIT 100", [cursor]) + if not rows: + break + for row in rows: + cursor = row["id"] + if row["status"] == "deleted": + continue + code_id = row["delivery_id"] + # 旧版可能已物理删除耗尽口令;补只读历史壳以保留按码查收件的入口。 + await conn.execute_query( + "INSERT OR IGNORE INTO deliverycode (id, code_digest, name, storage_type, target_path, expires_at, max_uploads, enabled, deleted) " + "VALUES (?, ?, ?, ?, '', CURRENT_TIMESTAMP, 1, 0, 0)", + [code_id, uuid.uuid4().hex, "历史寄件(授权已撤销)", row["storage_type"]], + ) + if row.get("share_id") is not None: + await conn.execute_query( + "UPDATE filecodes SET delivery_id = ?, storage_type = ?, upload_id = ? WHERE id = ?", + [code_id, row["storage_type"], row["token"], row["share_id"]], + ) + elif row["status"] == "stored": + prefix, suffix = os.path.splitext(row["filename"]) + await conn.execute_query( + "INSERT INTO filecodes (code, prefix, suffix, uuid_file_name, file_path, size, expired_count, used_count, is_chunked, created_at, storage_type, delivery_id, is_private, upload_id) " + "VALUES (?, ?, ?, ?, ?, ?, -1, 0, 0, ?, ?, ?, 1, ?)", + [uuid.uuid4().hex, prefix, suffix, row["stored_name"], row["file_path"], row["size"], row["created_at"], row["storage_type"], code_id, row["token"]], + ) + elif row["status"] in {"pending", "finalizing", "cleanup"}: + # 升级前未完成的上传统一取消并清理,不允许旧会话跨模型继续提交。 + await conn.execute_query("DELETE FROM storagereservation WHERE token IN (?, ?, ?)", + ["delivery:" + row["token"], "chunk:" + row["token"], "presign:" + row["token"]]) + await conn.execute_query( + "INSERT OR IGNORE INTO storagereservation (token, size, expires_at, delivery_id, status, filename, stored_name, file_path, storage_type) " + "VALUES (?, ?, CURRENT_TIMESTAMP, ?, 'cleanup', ?, ?, ?, ?)", + [row["token"], row["size"], code_id, row["filename"], row["stored_name"], row["file_path"], row["storage_type"]], + ) + await conn.execute_query("UPDATE deliverycode SET reserved_count = 0") + await conn.execute_query("UPDATE deliverycode SET enabled = 0 WHERE used_count >= max_uploads OR deleted = 1") + await conn.execute_query("DROP TABLE deliveryfile") diff --git a/apps/base/migrations/migrations_014.py b/apps/base/migrations/migrations_014.py new file mode 100644 index 000000000..8787edcf0 --- /dev/null +++ b/apps/base/migrations/migrations_014.py @@ -0,0 +1,54 @@ +"""移除重复口令摘要及预设账号归属;缺少原文的旧授权停用但保留收件关联。""" + +from tortoise.transactions import in_transaction + + +async def migrate(): + # SQLite 不能直接删除带唯一约束的旧列,使用事务内重建保留主键及全部业务字段。 + async with in_transaction() as conn: + columns = {row["name"] for row in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} + if "code_digest" not in columns: + return + # 异常旧数据先中止升级,不能猜测同一口令应该属于哪条授权;错误不输出口令。 + duplicates = await conn.execute_query_dict( + "SELECT MIN(id) AS first_id FROM deliverycode " + "WHERE code_value IS NOT NULL AND code_value != '' GROUP BY code_value HAVING COUNT(*) > 1" + ) + if duplicates: + ids = ", ".join(str(row["first_id"]) for row in duplicates) + raise RuntimeError("寄件码原文存在重复,请先为相关记录重新设置不同口令,首条记录 ID:" + ids) + await conn.execute_query(''' + CREATE TABLE deliverycode_without_digest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_value VARCHAR(64) NULL UNIQUE, + auth_version INT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + note VARCHAR(2000) NOT NULL DEFAULT '', + tags JSON NOT NULL DEFAULT '[]', + storage_type VARCHAR(20) NOT NULL, + target_path VARCHAR(200) NOT NULL, + expires_at TIMESTAMP NOT NULL, + max_uploads INT NOT NULL, + used_count INT NOT NULL DEFAULT 0, + reserved_count INT NOT NULL DEFAULT 0, + enabled INT NOT NULL DEFAULT 1, + deleted INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ''') + await conn.execute_query(''' + INSERT INTO deliverycode_without_digest ( + id, code_value, auth_version, name, note, tags, storage_type, target_path, + expires_at, max_uploads, used_count, reserved_count, enabled, deleted, created_at + ) + SELECT id, NULLIF(code_value, ''), + auth_version + CASE WHEN code_value IS NULL OR code_value = '' THEN 1 ELSE 0 END, + name, note, tags, storage_type, target_path, expires_at, max_uploads, + used_count, reserved_count, + CASE WHEN code_value IS NULL OR code_value = '' THEN 0 ELSE enabled END, + deleted, created_at + FROM deliverycode + ''') + # ID 保持不变;已存在的 FileCodes.delivery_id 以及历史计数不会丢失。 + await conn.execute_query("DROP TABLE deliverycode") + await conn.execute_query("ALTER TABLE deliverycode_without_digest RENAME TO deliverycode") diff --git a/apps/base/migrations/migrations_015.py b/apps/base/migrations/migrations_015.py new file mode 100644 index 000000000..c9e374ad8 --- /dev/null +++ b/apps/base/migrations/migrations_015.py @@ -0,0 +1,42 @@ +"""删除寄件码独立存储配置;既有文件和上传会话的位置不变。""" + +from tortoise.transactions import in_transaction + + +async def migrate(): + # 重建仅授权数据的表,保留主键、计数和自增序列,不搬动任何已保存的文件。 + async with in_transaction() as conn: + columns = {row["name"] for row in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} + if not {"storage_type", "target_path"} & columns: + return + sequences = await conn.execute_query_dict("SELECT seq FROM sqlite_sequence WHERE name = 'deliverycode'") + sequence = sequences[0]["seq"] if sequences else 0 + await conn.execute_query(''' + CREATE TABLE deliverycode_system_storage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code_value VARCHAR(64) NULL UNIQUE, + auth_version INT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + note VARCHAR(2000) NOT NULL DEFAULT '', + tags JSON NOT NULL DEFAULT '[]', + expires_at TIMESTAMP NOT NULL, + max_uploads INT NOT NULL, + used_count INT NOT NULL DEFAULT 0, + reserved_count INT NOT NULL DEFAULT 0, + enabled INT NOT NULL DEFAULT 1, + deleted INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ''') + await conn.execute_query(''' + INSERT INTO deliverycode_system_storage ( + id, code_value, auth_version, name, note, tags, expires_at, + max_uploads, used_count, reserved_count, enabled, deleted, created_at + ) SELECT id, code_value, auth_version, name, note, tags, expires_at, + max_uploads, used_count, reserved_count, enabled, deleted, created_at + FROM deliverycode + ''') + await conn.execute_query("DROP TABLE deliverycode") + await conn.execute_query("ALTER TABLE deliverycode_system_storage RENAME TO deliverycode") + # 不能因历史行曾被删除而复用旧 ID,避免错误接回历史收件关联。 + await conn.execute_query("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'deliverycode'", [sequence]) diff --git a/apps/base/models.py b/apps/base/models.py index 3820dee1b..f9510114d 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -30,6 +30,11 @@ class FileCodes(models.Model): file_hash = fields.CharField(max_length=64, null=True) is_chunked = fields.BooleanField(default=False) upload_id = fields.CharField(max_length=36, null=True) + # 寄件文件直接关联授权;历史私有收件仅供后台读取,不自动公开。 + delivery_id = fields.IntField(null=True, index=True) + is_private = fields.BooleanField(default=False) + # 仅寄件文件记录授权指定的真实后端;普通文件不写入也不读取该字段。 + storage_type = fields.CharField(max_length=20, null=True) async def is_expired(self): if self.expired_at is None: @@ -93,6 +98,34 @@ class StorageReservation(models.Model): token = fields.CharField(max_length=64, unique=True, index=True) size = fields.BigIntField() expires_at = fields.DatetimeField(index=True) + # 寄件预占复用容量预留;成功后整行删除,持久文件只保存到 FileCodes。 + delivery_id = fields.IntField(null=True, index=True) + auth_version = fields.IntField(default=1) + status = fields.CharField(max_length=20, default="pending") + filename = fields.CharField(max_length=255, default="") + stored_name = fields.CharField(max_length=255, default="") + file_path = fields.CharField(max_length=255, default="") + storage_type = fields.CharField(max_length=20, null=True) + + +class DeliveryCode(models.Model): + """只授予投递权限的口令;不进入公开取件码表,避免形成下载授权。""" + + id = fields.IntField(pk=True) + # 原文作为唯一上传口令;NULL 只用于已停用、等待管理员重新设码的历史记录。 + code_value = fields.CharField(max_length=64, null=True, unique=True) + # 改码时递增,令牌携带该版本后可立即撤销旧寄件授权。 + auth_version = fields.IntField(default=1) + name = fields.CharField(max_length=100) + note = fields.CharField(max_length=2000, default="") + tags = fields.JSONField(default=list) + expires_at = fields.DatetimeField() + max_uploads = fields.IntField() + used_count = fields.IntField(default=0) + reserved_count = fields.IntField(default=0) + enabled = fields.BooleanField(default=True) + deleted = fields.BooleanField(default=False) + created_at = fields.DatetimeField(auto_now_add=True) file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes") diff --git a/apps/base/pages.py b/apps/base/pages.py index 5bd174324..c93fa2a1f 100644 --- a/apps/base/pages.py +++ b/apps/base/pages.py @@ -3,7 +3,7 @@ import html from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from apps.base.config import initialize_system, is_runtime_initialized from apps.base.setup_wizard import ( @@ -90,12 +90,15 @@ async def theme_asset(asset_path: str): @router.get("/") async def index(request=None, exc=None): + # 新增寄件接口的 404 保持 JSON 状态码,不能被主题 SPA 回退改成 HTML 200。 + if request is not None and request.url.path.startswith(("/api/delivery", "/admin/delivery", "/delivery-assets/")): + return JSONResponse(status_code=404, content={"detail": getattr(exc, "detail", "资源不存在")}) # Site config is admin input (and during the setup window anyone can claim it); # always escape before injecting into the theme template to prevent stored XSS # (mirrors the setup page). + template = resolve_theme_file("index.html").read_text(encoding="utf-8") return HTMLResponse( - content=resolve_theme_file("index.html") - .read_text(encoding="utf-8") + content=template .replace("{{title}}", html.escape(str(settings.name))) .replace("{{description}}", html.escape(str(settings.description))) .replace("{{keywords}}", html.escape(str(settings.keywords))) @@ -135,3 +138,13 @@ async def health_check(): "theme": settings.themes_select, } ) + + +def theme_has_delivery_ui(template: str | None = None) -> bool: + """使用构建产物的功能标记兼容新旧前端,不依赖某个发行版本字符串。""" + if template is None: + path = resolve_theme_root() / "index.html" + if not path.is_file(): + return False + template = path.read_text(encoding="utf-8") + return 'name="filecodebox-features" content="delivery"' in template diff --git a/apps/base/quota.py b/apps/base/quota.py index e17d2fe0f..6df9c1c82 100644 --- a/apps/base/quota.py +++ b/apps/base/quota.py @@ -67,8 +67,9 @@ def get_storage_limit() -> int: async def get_storage_usage() -> dict[str, int | None]: now = await get_now() # SQL 聚合:此函数在每次上传配额检查时调用,禁止全表拉取(D3) + # 成功文件只计 FileCodes;寄件上传残留保留在容量预留中直到删除成功。 used_rows = await owned_storage_queryset().annotate(total=Sum("size")).values("total") - reserved_rows = await StorageReservation.filter(expires_at__gt=now).annotate( + reserved_rows = await StorageReservation.filter(Q(expires_at__gt=now) | Q(delivery_id__isnull=False)).annotate( total=Sum("size") ).values("total") limit = get_storage_limit() @@ -85,6 +86,25 @@ async def get_storage_usage() -> dict[str, int | None]: async def reserve_storage(token: str, size: int, ttl_seconds: int) -> None: requested_size = max(0, int(size)) limit = get_storage_limit() + # 普通上传前缀映射到同一寄件预留,避免次数和容量分别产生重复计费行。 + delivery_token = token.split(":", 1)[-1] + if delivery_token.startswith("d_"): + now = await get_now() + conn = connections.get("default") + p = _sql_placeholders(8) + changed, _ = await conn.execute_query( + f"UPDATE storagereservation SET size = {p[0]}, expires_at = {p[1]} " + f"WHERE token = {p[2]} AND delivery_id IS NOT NULL AND status IN ('pending', 'finalizing') " + f"AND ({p[3]} = 0 OR " + f"COALESCE((SELECT SUM(size) FROM filecodes WHERE file_path IS NULL OR file_path != '{LOCAL_REF_MARKER}'), 0) " + f"+ COALESCE((SELECT SUM(size) FROM storagereservation WHERE token != {p[4]} AND (expires_at > {p[5]} OR delivery_id IS NOT NULL)), 0) " + f"+ {p[6]} <= {p[7]})", + [requested_size, now + datetime.timedelta(seconds=ttl_seconds), delivery_token, + limit, delivery_token, now, requested_size, limit], + ) + if changed != 1: + raise HTTPException(507, "上传会话失效或存储容量不足") + return if not limit or requested_size == 0: return @@ -106,7 +126,7 @@ async def reserve_storage(token: str, size: int, ttl_seconds: int) -> None: SELECT {ph[0]}, {ph[1]}, {ph[2]} WHERE ( COALESCE((SELECT SUM(size) FROM filecodes WHERE file_path IS NULL OR file_path != '{LOCAL_REF_MARKER}'), 0) - + COALESCE((SELECT SUM(size) FROM storagereservation WHERE expires_at > {ph[3]}), 0) + + COALESCE((SELECT SUM(size) FROM storagereservation WHERE expires_at > {ph[3]} OR delivery_id IS NOT NULL), 0) + {ph[4]} ) <= {ph[5]} """, @@ -129,4 +149,7 @@ async def reserve_storage(token: str, size: int, ttl_seconds: int) -> None: async def release_storage(token: str) -> None: + # 寄件预留必须先完成文件提交或清理,不能由普通 finally 提前释放容量。 + if token.split(":", 1)[-1].startswith("d_"): + return await StorageReservation.filter(token=token).delete() diff --git a/apps/base/services.py b/apps/base/services.py index 39115e3dd..64ea7964f 100644 --- a/apps/base/services.py +++ b/apps/base/services.py @@ -14,6 +14,7 @@ from core.settings import settings from core.storage import FileStorageInterface, StoredDownload, StoredFile, storages +from apps.base.upload_access import prepare_upload, upload_storage, create_upload_share, abort_access from apps.base.file_validation import validate_upload_file from apps.base.local_share import build_local_ref_download, is_local_ref from apps.base.models import FileCodes, PresignUploadSession, UploadChunk @@ -41,7 +42,9 @@ def stored_file_of(code: FileCodes) -> StoredFile: async def get_stored_download(file_code, file_storage: FileStorageInterface | None = None) -> StoredDownload: if is_local_ref(file_code): return build_local_ref_download(file_code) - storage = file_storage or storages[settings.file_storage]() + # NAS 引用沿用原逻辑;只有寄件分享需要覆盖默认存储。 + from apps.base.share_storage import storage_for_share + storage = await storage_for_share(file_code, file_storage) return await storage.get_file_response(stored_file_of(file_code)) @@ -113,6 +116,7 @@ async def create_file_record( file_path: str, expire_value: int, expire_style: str, + access=None, **extra_fields, ) -> str: """统一创建FileCodes记录,返回code""" @@ -121,33 +125,31 @@ async def create_file_record( ) prefix, suffix = os.path.splitext(file_name) - await FileCodes.create( - code=code, - prefix=prefix, - suffix=suffix, - uuid_file_name=file_name, - file_path=file_path, - size=file_size, - expired_at=expired_at, - expired_count=expired_count, - used_count=used_count, - **extra_fields, + await create_upload_share(access, + code=code, prefix=prefix, suffix=suffix, uuid_file_name=file_name, + file_path=file_path, size=file_size, expired_at=expired_at, + expired_count=expired_count, used_count=used_count, **extra_fields, ) return code @staticmethod async def create_text_share( - text: str, expire_value: int, expire_style: str + text: str, expire_value: int, expire_style: str, access=None ) -> str: """文本分享:配额预留 → 建分享记录 → 释放配额。""" text_size = len(text.encode("utf-8")) - token = f"text:{uuid.uuid4().hex}" + # 文本与普通发送一致存入取件表,并占用一次寄件授权。 + await prepare_upload(access, "Text", text_size, uuid.uuid4().hex) + if access is not None and access.record is not None: + access.record.stored_name = "" + await access.record.save(update_fields=["stored_name"]) + token = access.record.token if access and access.record else f"text:{uuid.uuid4().hex}" await reserve_storage(token, text_size, ttl_seconds=300) try: expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) - await FileCodes.create( + await create_upload_share(access, code=code, text=text, expired_at=expired_at, @@ -158,25 +160,31 @@ async def create_text_share( ) finally: await release_storage(token) + await abort_access(access) return code @staticmethod async def create_file_share( - file: UploadFile, *, size: int, expire_value: int, expire_style: str + file: UploadFile, *, size: int, expire_value: int, expire_style: str, access=None ) -> dict[str, str]: """文件分享:路径生成 → 配额预留 → 存储写入 → 建分享记录,失败回滚已存文件。""" path, suffix, prefix, uuid_file_name, save_path = ( await FileUploadService.generate_file_path(file.filename or "") ) - token = f"file:{uuid.uuid4().hex}" + # 寄件授权只覆盖路径与归属,保留普通上传校验和存储流程。 + _, delivery_path = await prepare_upload(access, file.filename, size, uuid.uuid4().hex) + if delivery_path: + save_path = delivery_path + path, uuid_file_name = os.path.split(save_path) + token = access.record.token if access and access.record else f"file:{uuid.uuid4().hex}" await reserve_storage(token, size, ttl_seconds=3600) - storage = FileUploadService._storage() + storage = await upload_storage(access) if access and access.record else FileUploadService._storage() try: expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) await storage.save_file(file.file, save_path, file.content_type) - await FileCodes.create( + await create_upload_share(access, code=code, prefix=prefix, suffix=suffix, @@ -194,18 +202,19 @@ async def create_file_share( raise finally: await release_storage(token) + await abort_access(access) return {"code": code, "name": file.filename} @staticmethod async def complete_chunked_upload( - upload_id: str, chunk_info: UploadChunk, expire_value: int, expire_style: str + upload_id: str, chunk_info: UploadChunk, expire_value: int, expire_style: str, access=None ) -> dict[str, str]: """分片合并:配额 → 完整性/大小校验 → 合并 → 建分享记录 → 清理分片。 失败路径的配额释放与清理范围与原实现逐一对齐: 完整性校验失败仅抛 400(预留由 TTL 兜底);合并失败清理分片文件后抛 500。 """ - storage = FileUploadService._storage() + storage = await upload_storage(access) if access and access.record else FileUploadService._storage() await reserve_storage( f"chunk:{upload_id}", chunk_info.file_size, ttl_seconds=chunk_reservation_ttl() ) @@ -216,8 +225,8 @@ async def complete_chunked_upload( if len(completed_chunks) != chunk_info.total_chunks: raise HTTPException(400, "分片不完整") - # 用分片数 * chunk_size 校验最大可能大小 - max_total_size = len(completed_chunks) * chunk_info.chunk_size + # 寄件预占按声明字节数计费;普通上传保持上游的分片容量规则。 + max_total_size = chunk_info.file_size if access and access.record else len(completed_chunks) * chunk_info.chunk_size if max_total_size > settings.upload_size: save_path = chunk_info.save_path if save_path: @@ -253,7 +262,7 @@ async def complete_chunked_upload( expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style ) - await FileCodes.create( + await create_upload_share(access, code=code, file_hash=file_hash, # 使用合并后计算的哈希 is_chunked=True, @@ -267,10 +276,16 @@ async def complete_chunked_upload( prefix=prefix, suffix=suffix, ) - await storage.clean_chunks(upload_id, save_path) - await UploadChunk.filter(upload_id=upload_id).delete() + try: + await storage.clean_chunks(upload_id, save_path) + await UploadChunk.filter(upload_id=upload_id).delete() + except Exception: + if not (access and access.record): + raise + logger.warning("寄件分享已创建,分片清理稍后重试 upload_id=%s", upload_id, exc_info=True) await release_storage(f"chunk:{upload_id}") - return {"code": code, "name": safe_file_name} + # 寄件存储名带唯一前缀,但发送结果仍展示原文件名。 + return {"code": code, "name": access.record.filename if access is not None and access.record is not None else safe_file_name} except ValueError as e: raise HTTPException(400, str(e)) except Exception as e: @@ -287,7 +302,7 @@ async def complete_chunked_upload( @staticmethod async def commit_proxy_upload( - session: PresignUploadSession, file: UploadFile + session: PresignUploadSession, file: UploadFile, access=None ) -> str: """预签名代理上传:配额 → 大小/类型/一致性校验 → 转存 → 建记录 → 会话清理。 @@ -301,43 +316,32 @@ async def commit_proxy_upload( file_size = await validate_file_size(file, settings.upload_size) await validate_upload_file(file) - if abs(file_size - session.file_size) > 1024: + # 寄件必须与预占容量精确一致;普通代理保留上游的容差规则。 + mismatch = file_size != session.file_size if access and access.record else abs(file_size - session.file_size) > 1024 + if mismatch: raise HTTPException(400, "文件大小与声明不符") - storage = FileUploadService._storage() + storage = await upload_storage(access) if access and access.record else FileUploadService._storage() try: await storage.save_file(file.file, session.save_path, file.content_type) except Exception as e: raise HTTPException(500, f"文件保存失败: {str(e)}") - try: - code = await FileUploadService.create_file_record( - session.file_name, - file_size, - os.path.dirname(session.save_path), - session.expire_value, - session.expire_style, - ) - except Exception: - await rollback_saved_file( - storage, - os.path.dirname(session.save_path), - os.path.basename(session.save_path), - context="预签名代理上传:记录创建失败", - upload_id=session.upload_id, - ) - raise - - await session.delete() - await release_storage(f"presign:{session.upload_id}") - return code + return await FileUploadService._commit_presign_record( + session, file_size, storage, access=access, + context="预签名代理上传:记录创建失败" + ) @staticmethod - async def confirm_direct_upload(session: PresignUploadSession) -> str: + async def confirm_direct_upload(session: PresignUploadSession, access=None) -> str: """预签名直传确认:配额 → 文件存在性 → 建记录 → 会话清理。 预留失败说明配额已耗尽,此时清理远端临时文件与会话后原样抛出。 """ + if access and access.record: + # 释放旧会话的上传次数;已签发的 URL 不能提前撤销,容量和清理记录保留到其过期。 + await abort_access(access, cleanup_after=session.expires_at) + raise HTTPException(409, "旧寄件直传已停用,请重新上传;临时容量将在原会话过期后释放") try: await reserve_storage( f"presign:{session.upload_id}", @@ -345,7 +349,7 @@ async def confirm_direct_upload(session: PresignUploadSession) -> str: ttl_seconds=PRESIGN_SESSION_EXPIRES, ) except HTTPException: - storage = FileUploadService._storage() + storage = await upload_storage(access) if access and access.record else FileUploadService._storage() try: if await storage.file_exists(session.save_path): await storage.delete_file( @@ -359,33 +363,35 @@ async def confirm_direct_upload(session: PresignUploadSession) -> str: await release_storage(f"presign:{session.upload_id}") raise - storage = FileUploadService._storage() + storage = await upload_storage(access) if access and access.record else FileUploadService._storage() if not await storage.file_exists(session.save_path): raise HTTPException(404, "文件未上传或上传失败") + return await FileUploadService._commit_presign_record( + session, session.file_size, storage, access=access, + context="预签名确认:记录创建失败" + ) + + @staticmethod + async def _commit_presign_record(session, file_size, storage, *, access=None, context): + """代理上传与直传共用记录提交、失败回滚及会话释放,避免两条路径行为分叉。""" try: code = await FileUploadService.create_file_record( - session.file_name, - session.file_size, - os.path.dirname(session.save_path), - session.expire_value, - session.expire_style, + session.file_name, file_size, os.path.dirname(session.save_path), + session.expire_value, session.expire_style, access=access, ) except Exception: await rollback_saved_file( - storage, - os.path.dirname(session.save_path), - os.path.basename(session.save_path), - context="预签名确认:记录创建失败", - upload_id=session.upload_id, + storage, os.path.dirname(session.save_path), os.path.basename(session.save_path), + context=context, upload_id=session.upload_id, ) raise - await session.delete() await release_storage(f"presign:{session.upload_id}") return code + def response_from_download(download: StoredDownload): """Build the starlette Response for a StoredDownload (view-layer duty).""" if download.path is not None: diff --git a/apps/base/share_storage.py b/apps/base/share_storage.py new file mode 100644 index 000000000..3a72182f1 --- /dev/null +++ b/apps/base/share_storage.py @@ -0,0 +1,19 @@ +"""寄件文件按授权后端定位;普通文件与 NAS 引用保持上游行为。""" + +from apps.base.local_share import is_local_ref +from core.settings import settings +from core.storage import storages + + +async def storage_for_share(file_code, fallback=None): + if file_code.delivery_id is not None and file_code.storage_type: + # 只对寄件文件使用授权指定的后端,普通上传不采用旧快照列。 + return storages[file_code.storage_type]() + return fallback if fallback is not None else storages[settings.file_storage]() + + +async def storage_type_for_share(file_code): + """普通详情仍显示站点设置;NAS 引用及寄件按各自来源显示。""" + if is_local_ref(file_code): + return "local" + return file_code.storage_type if file_code.delivery_id is not None else settings.file_storage diff --git a/apps/base/tasks.py b/apps/base/tasks.py index c45505cd8..ee8866ec4 100644 --- a/apps/base/tasks.py +++ b/apps/base/tasks.py @@ -20,7 +20,8 @@ from core.settings import settings, data_root from core.storage import FileStorageInterface, StoredFile, storages from apps.base.services import stored_file_of -from apps.base.local_share import is_local_ref +from apps.base.share_storage import storage_for_share +from apps.base.local_share import is_local_ref, should_skip_storage_delete from core.utils import get_now async def delete_expire_files(): @@ -36,16 +37,23 @@ async def delete_expire_files(): await ip_limit["error"].remove_expired_ip() await ip_limit["metadata"].remove_expired_ip() await ip_limit["upload"].remove_expired_ip() - await StorageReservation.filter(expires_at__lte=await get_now()).delete() + await StorageReservation.filter(expires_at__lte=await get_now(), delivery_id__isnull=True).delete() expire_data = await FileCodes.filter( Q(expired_at__lt=await get_now()) | Q(expired_count=0) ).all() for exp in expire_data: try: - if not is_local_ref(exp): + if exp.delivery_id is not None: + # 寄件沿用统一过期任务,仅覆盖授权后端与文本无需删文件的情形。 + if not should_skip_storage_delete(exp): + await (await storage_for_share(exp)).delete_file(stored_file_of(exp)) + elif not is_local_ref(exp): await file_storage.delete_file(stored_file_of(exp)) except Exception as e: logger.error(f"删除过期文件失败 code={exp.code}: {e}") + # 寄件保留失败记录供重试;普通文件清理不改变上游语义。 + if exp.delivery_id is not None: + continue try: await exp.delete() except Exception as e: @@ -66,7 +74,7 @@ async def clean_incomplete_uploads(): expire_time = now - datetime.timedelta(hours=expire_hours) expired_sessions = await UploadChunk.filter( chunk_index=-1, created_at__lt=expire_time - ).all() + ).exclude(upload_id__startswith="d_").all() for session in expired_sessions: try: @@ -106,7 +114,7 @@ async def clean_expired_presign_sessions(): now = await get_now() expired_sessions = await PresignUploadSession.filter( expires_at__lt=now - ).all() + ).exclude(upload_id__startswith="d_").all() for session in expired_sessions: if session.mode == "direct": try: diff --git a/apps/base/upload_access.py b/apps/base/upload_access.py new file mode 100644 index 000000000..f5d872b4a --- /dev/null +++ b/apps/base/upload_access.py @@ -0,0 +1,115 @@ +"""上传鉴权依赖:只解析授权与会话归属,存储、扣次和清理由公共上传服务完成。""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta + +from fastapi import Header, HTTPException, Request + +from apps.admin.dependencies import share_required_login, verify_token +from apps.base.models import FileCodes, StorageReservation +from apps.base.upload_sessions import reserve_slot, heartbeat, abort_upload, commit_delivery, STALE_SECONDS +from apps.delivery.services import upload_identity, active_code +from core.settings import settings +from core.storage import storages +from core.utils import get_now + + +@dataclass +class UploadAccess: + code_id: int | None = None + auth_version: int = 1 + record: StorageReservation | None = None + completed: FileCodes | None = None + + +async def authorize_upload(request: Request, authorization: str | None = Header(default=None)): + """游客、管理员与寄件身份隔离,跨码或无凭证访问会话统一返回不存在。""" + access = UploadAccess() + if authorization and authorization.startswith("Bearer "): + try: + payload = verify_token(authorization[7:]) + except ValueError: + # 非寄件身份交回上游游客/管理员规则,不能改变游客开放时的普通上传行为。 + payload = {} + if payload.get("purpose") == "delivery" and not payload.get("is_admin"): + access.code_id = await upload_identity(authorization) + access.auth_version = int(payload.get("delivery_version", 1)) + if access.code_id is None: + await share_required_login(authorization) + upload_id = request.path_params.get("upload_id") + completion = any(part in request.url.path for part in ("/complete/", "/confirm/", "/proxy/")) + if upload_id and upload_id.startswith("d_"): + if access.code_id is None: + raise HTTPException(404, "上传会话不存在") + access.record = await StorageReservation.filter(token=upload_id, delivery_id=access.code_id).first() + if access.record is None and completion: + access.completed = await FileCodes.filter(upload_id=upload_id, delivery_id=access.code_id).first() + if access.record is None and access.completed is None: + raise HTTPException(404, "上传会话不存在") + if access.record and (access.record.status != "pending" or access.record.auth_version != access.auth_version): + raise HTTPException(409, "上传正在完成、清理或授权已修改") + elif upload_id and access.code_id is not None: + raise HTTPException(404, "上传会话不属于该寄件码") + if access.code_id is not None and access.completed is None: + await active_code(access.code_id) + finalizing = bool(access.record and completion) + if access.record: + changed = await StorageReservation.filter(id=access.record.id, status="pending").update( + status="finalizing" if finalizing else "pending", + expires_at=await get_now() + timedelta(seconds=STALE_SECONDS), + ) + if not changed: + raise HTTPException(409, "上传状态已变化,请重试") + if finalizing: + access.record.status = "finalizing" + task = asyncio.create_task(heartbeat(access)) if access.code_id is not None else None + try: + yield access + except BaseException: + if not upload_id and access.record: + await asyncio.shield(abort_access(access)) + raise + finally: + if finalizing: + await StorageReservation.filter(id=access.record.id, status="finalizing").update(status="pending") + if task: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +async def prepare_upload(access, file_name, file_size, upload_id): + """普通上传保持原路径;寄件只补充授权选定的目录与短期预留。""" + if access is None or access.code_id is None: + return upload_id, None + access.record = await reserve_slot(access.code_id, access.auth_version, file_name, upload_id) + return access.record.token, f"{access.record.file_path}/{access.record.stored_name}" + + +async def upload_storage(access=None): + if access is not None and access.record is not None: + return storages[access.record.storage_type]() + return storages[settings.file_storage]() + + +async def create_upload_share(access=None, **fields): + if access is None or access.record is None: + # 普通文件保持上游行为,不记录公共存储快照。 + fields.pop("storage_type", None) + return await FileCodes.create(**fields) + return await commit_delivery(access.record, fields) + + +async def abort_access(access, *, cleanup_after=None): + if access is not None and access.record is not None: + await abort_upload(access.record.id, cleanup_after=cleanup_after) + + +async def completed_upload(access): + """响应丢失后按成功文件的会话标识重试,不重复写文件或扣次。""" + if access is None or access.completed is None: + return None + share = access.completed + if await share.is_expired(): + raise HTTPException(410, "该上传的文件已过期") + return {"code": share.code, "name": share.prefix + share.suffix} diff --git a/apps/base/upload_sessions.py b/apps/base/upload_sessions.py new file mode 100644 index 000000000..c2c18904d --- /dev/null +++ b/apps/base/upload_sessions.py @@ -0,0 +1,139 @@ +"""寄件上传的短期预留:复用容量记录,成功文件只进入 FileCodes。""" + +import asyncio +import os +from datetime import timedelta + +from fastapi import HTTPException +from tortoise.expressions import F +from tortoise.transactions import in_transaction + +from apps.base.models import DeliveryCode, FileCodes, StorageReservation, UploadChunk, PresignUploadSession +from apps.base.quota import _sql_placeholders +from apps.base.utils import build_file_path +from core.logger import logger +from core.settings import settings +from core.storage import StoredFile, storages +from core.utils import get_now + +STALE_SECONDS = 7200 + + +async def reserve_slot(code_id, version, file_name, upload_id): + """次数原子预占与会话创建同事务;实际字节随后由共用配额入口预留。""" + async with in_transaction() as conn: + now = await get_now() + p = _sql_placeholders(3) + changed, _ = await conn.execute_query( + f"UPDATE deliverycode SET reserved_count = reserved_count + 1 " + f"WHERE id = {p[0]} AND expires_at > {p[1]} AND auth_version = {p[2]} " + "AND enabled = 1 AND deleted = 0 AND used_count + reserved_count < max_uploads", + [code_id, now, version], + ) + if changed != 1: + raise HTTPException(409, "寄件码已失效或没有剩余上传次数") + # 每次新寄件都沿用原系统的存储设置和路径生成器,不读取寄件码独立配置。 + storage_type = settings.file_storage + path, _, _, stored_name, _ = await build_file_path(file_name or "file", upload_id) + token = "d_" + upload_id + return await StorageReservation.create( + token=token, size=0, delivery_id=code_id, auth_version=version, + filename=stored_name, stored_name=stored_name, + file_path=path, storage_type=storage_type, + expires_at=now + timedelta(seconds=STALE_SECONDS), using_db=conn, + ) + + +async def heartbeat(access): + """整个 HTTP 上传期间续租;后续分片请求重新启动租约,断线残留由定时清理回收。""" + while True: + await asyncio.sleep(30) + if access.record is not None: + await StorageReservation.filter(id=access.record.id, status__in=["pending", "finalizing"]).update( + expires_at=await get_now() + timedelta(seconds=STALE_SECONDS) + ) + + +async def abort_upload(record_id, *, stale_before=None, cleanup_after=None): + """只释放一次次数;清理失败的容量预留继续计费,防止失败残留绕过配额。""" + async with in_transaction() as conn: + query = StorageReservation.filter(id=record_id, delivery_id__isnull=False, status__in=["pending", "finalizing"]) + if stale_before is not None: + query = query.filter(expires_at__lte=stale_before) + changed = await query.using_db(conn).update( + status="cleanup", expires_at=cleanup_after or await get_now() + ) + if changed: + record = await StorageReservation.get(id=record_id).using_db(conn) + await DeliveryCode.filter(id=record.delivery_id, reserved_count__gt=0).using_db(conn).update( + reserved_count=F("reserved_count") - 1 + ) + await clean_reservation(record_id) + + +async def clean_reservation(record_id): + """物理对象清理成功后才删除预留;此表不保留任何已成功收件。""" + record = await StorageReservation.filter( + id=record_id, status="cleanup", delivery_id__isnull=False, expires_at__lte=await get_now() + ).first() + if record is None: + return + try: + storage = storages[record.storage_type]() + if record.stored_name: + path = f"{record.file_path}/{record.stored_name}" + await storage.clean_chunks(record.token, path) + await storage.delete_file(StoredFile(file_path=record.file_path, uuid_file_name=record.stored_name)) + await UploadChunk.filter(upload_id=record.token).delete() + await PresignUploadSession.filter(upload_id=record.token).delete() + await record.delete() + except Exception: + logger.warning("上传残留清理失败,将重试 id=%s", record.id, exc_info=True) + + +async def commit_delivery(record, fields): + """同事务扣次、建普通文件与释放容量;文件关联不再经过影子收件表。""" + async with in_transaction() as conn: + reserved = await StorageReservation.filter(id=record.id, status__in=["pending", "finalizing"]).using_db(conn).delete() + if reserved != 1: + raise HTTPException(409, "该上传已完成或正在清理") + changed = await DeliveryCode.filter( + id=record.delivery_id, auth_version=record.auth_version, enabled=True, + deleted=False, expires_at__gt=await get_now(), reserved_count__gt=0, + ).using_db(conn).update(reserved_count=F("reserved_count") - 1, used_count=F("used_count") + 1) + if changed != 1: + raise HTTPException(409, "寄件码在上传期间失效") + fields.update(delivery_id=record.delivery_id, upload_id=record.token, storage_type=record.storage_type) + if "text" not in fields: + fields["prefix"], fields["suffix"] = os.path.splitext(record.filename) + fields.update(file_path=record.file_path, uuid_file_name=record.stored_name) + share = await FileCodes.create(using_db=conn, **fields) + # 耗尽后保留授权历史和文件关联;管理员增加次数后可显式重新启用。 + await DeliveryCode.filter(id=record.delivery_id, reserved_count=0, used_count__gte=F("max_uploads")).using_db(conn).update(enabled=False) + return share + + +async def cleanup_once(): + now = await get_now() + for record in await StorageReservation.filter(delivery_id__isnull=False, status__in=["pending", "finalizing"], expires_at__lte=now).limit(100): + await abort_upload(record.id, stale_before=now) + for record in await StorageReservation.filter(delivery_id__isnull=False, status="cleanup", expires_at__lte=now).limit(100): + await clean_reservation(record.id) + # 成功后临时分片清理异常仍可按普通文件的 upload_id 重试,不保留影子文件行。 + tokens = await UploadChunk.filter(chunk_index=-1, upload_id__startswith="d_").limit(100).values_list("upload_id", flat=True) + for share in await FileCodes.filter(upload_id__in=tokens): + try: + storage = storages[share.storage_type]() + await storage.clean_chunks(share.upload_id, await share.get_file_path()) + await UploadChunk.filter(upload_id=share.upload_id).delete() + except Exception: + logger.warning("已完成上传的临时分片清理失败 id=%s", share.id, exc_info=True) + + +async def cleanup_loop(): + while True: + try: + await cleanup_once() + except Exception: + logger.warning("上传预留清理异常,下轮重试", exc_info=True) + await asyncio.sleep(60) diff --git a/apps/base/views.py b/apps/base/views.py index 85746bf4a..1b587b56b 100644 --- a/apps/base/views.py +++ b/apps/base/views.py @@ -4,7 +4,7 @@ from datetime import timedelta from urllib.parse import quote, unquote -from typing import Optional, Tuple, Union +from typing import Annotated, Optional, Tuple, Union from fastapi import APIRouter, Form, Request, UploadFile, File, Depends, HTTPException from pydantic import BaseModel, ValidationError @@ -12,9 +12,11 @@ from starlette.responses import Response from tortoise.expressions import Case, F, Q, When -from apps.admin.dependencies import share_required_login +from apps.base.upload_access import UploadAccess, authorize_upload, prepare_upload, upload_storage, abort_access, completed_upload +from apps.base.models import StorageReservation from apps.base.models import FileCodes, UploadChunk, PresignUploadSession from apps.base.quota import release_storage, reserve_storage +from apps.base.share_storage import storage_for_share from apps.base.services import ( PRESIGN_SESSION_EXPIRES, FileUploadService, @@ -40,7 +42,7 @@ from apps.base.local_share import is_local_ref from core.response import APIResponse from core.settings import settings -from core.storage import storages, FileStorageInterface +from core.storage import FileStorageInterface, storages as storages from core.utils import ( get_file_url as get_proxy_file_url, get_select_token, @@ -58,8 +60,9 @@ def normalize_share_code(code: str) -> str: return str(code or "").strip() -@share_api.post("/text/", dependencies=[Depends(share_required_login)]) +@share_api.post("/text/", dependencies=[Depends(authorize_upload)]) async def share_text( + access: Annotated[UploadAccess, Depends(authorize_upload)] = None, text: str = Form(...), expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default="day"), @@ -71,13 +74,14 @@ async def share_text( if text_size > max_txt_size: raise HTTPException(status_code=403, detail="内容过多,建议采用文件形式") - code = await FileUploadService.create_text_share(text, expire_value, expire_style) + code = await FileUploadService.create_text_share(text, expire_value, expire_style, access=access) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code}) -@share_api.post("/file/", dependencies=[Depends(share_required_login)]) +@share_api.post("/file/", dependencies=[Depends(authorize_upload)]) async def share_file( + access: Annotated[UploadAccess, Depends(authorize_upload)] = None, expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default="day"), file: UploadFile = File(...), @@ -87,7 +91,7 @@ async def share_file( await validate_upload_file(file) validate_expire_style(expire_style) detail = await FileUploadService.create_file_share( - file, size=file_size, expire_value=expire_value, expire_style=expire_style + file, size=file_size, expire_value=expire_value, expire_style=expire_style, access=access ) ip_limit["upload"].add_ip(ip) return APIResponse(detail=detail) @@ -99,7 +103,7 @@ async def get_code_file_by_code( normalized_code = normalize_share_code(code) if not normalized_code: return False, "文件不存在" - file_code = await FileCodes.filter(code=normalized_code).first() + file_code = await FileCodes.filter(code=normalized_code, is_private=False).first() if not file_code: return False, "文件不存在" if await file_code.is_expired() and check: @@ -153,8 +157,8 @@ async def build_select_detail( metadata = build_file_metadata(file_code) if file_code.text is not None: download_url = None - elif file_code.expired_count >= 0 or is_local_ref(file_code): - # 次数限制与 NAS 引用都必须走下载接口,避免直链绕过次数或打到错误存储。 + elif file_code.expired_count >= 0 or is_local_ref(file_code) or file_code.delivery_id is not None: + # 次数限制、NAS 引用及寄件文件均经过下载接口,统一执行计数和存储定位。 download_url = await get_proxy_file_url(file_code.code) else: download_url = await file_storage.get_file_url(stored_file_of(file_code)) @@ -195,7 +199,6 @@ async def post_file_metadata( @share_api.get("/select/") async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): - file_storage: FileStorageInterface = storages[settings.file_storage]() has, file_code = await get_code_file_by_code(code) if not has: ip_limit["error"].add_ip(ip) @@ -215,18 +218,19 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): ) }, ) - return response_from_download(await get_stored_download(file_code, file_storage)) + # 下载入口兼容 NAS 引用及寄件文件的存储快照。 + return response_from_download(await get_stored_download(file_code)) @share_api.post("/select/") async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"])): - file_storage: FileStorageInterface = storages[settings.file_storage]() has, file_code = await get_code_file_by_code(data.code) if not has: ip_limit["error"].add_ip(ip) return APIResponse(code=404, detail=file_code) assert isinstance(file_code, FileCodes) + file_storage = await storage_for_share(file_code) detail = await build_select_detail(file_code, file_storage) download_url = detail.get("download_url") consumes_on_download = isinstance(download_url, str) and download_url.startswith( @@ -241,7 +245,6 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"] @share_api.get("/download") async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"])): - file_storage: FileStorageInterface = storages[settings.file_storage]() normalized_code = normalize_share_code(code) # 同时接受当前窗口与上一窗口 token,避免时间窗边界竞态导致偶发 403 valid_keys = { @@ -257,6 +260,7 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"] assert isinstance(file_code, FileCodes) if not await consume_file_usage(file_code): return APIResponse(code=404, detail="文件已过期") + file_storage = await storage_for_share(file_code) return ( APIResponse(detail=file_code.text) if file_code.text @@ -289,21 +293,30 @@ async def parse_complete_upload(request: Request) -> CompleteUploadModel: return await parse_body_model(request, CompleteUploadModel) -@chunk_api.post("/upload/init/", dependencies=[Depends(share_required_login)]) -async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chunk_upload)): +@chunk_api.post("/upload/init/", dependencies=[Depends(authorize_upload)]) +async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chunk_upload), access: Annotated[UploadAccess, Depends(authorize_upload)] = None): + # 保持服务函数可被内部调用;HTTP 请求始终由依赖提供校验后的授权。 + access = access or UploadAccess() safe_file_name = await sanitize_filename(unquote(data.file_name or "")) validate_file_type(safe_file_name) - # 服务端校验:根据 total_chunks * chunk_size 计算理论最大上传量 + # 新增校验仅约束寄件授权,避免无效大小影响次数预占或容量计算。 + if access.code_id is not None and (data.file_size <= 0 or not 1 <= data.chunk_size <= 5 * 1024 * 1024): + raise HTTPException(422, "寄件文件大小必须为正数,分片大小须在 1 至 5MB 之间") total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size - max_possible_size = total_chunks * data.chunk_size + max_possible_size = data.file_size if access.code_id is not None else total_chunks * data.chunk_size if max_possible_size > settings.upload_size: max_size_mb = settings.upload_size / (1024 * 1024) raise HTTPException( status_code=403, detail=f"文件大小超过限制,最大为 {max_size_mb:.2f} MB" ) - # 断点续传:检查是否存在相同文件的未完成上传会话 - existing_session = await UploadChunk.filter( + # 断点续传按寄件码隔离;普通上传不能恢复凭码创建的会话。 + if access.code_id is not None: + tokens = await StorageReservation.filter(delivery_id=access.code_id, auth_version=access.auth_version, status="pending").values_list("token", flat=True) + session_scope = UploadChunk.filter(upload_id__in=tokens) + else: + session_scope = UploadChunk.exclude(upload_id__startswith="d_") + existing_session = await session_scope.filter( chunk_hash=data.file_hash, chunk_index=-1, file_size=data.file_size, @@ -311,7 +324,10 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun ).first() if existing_session: + if access.code_id is not None: + access.record = await StorageReservation.get(token=existing_session.upload_id) if not existing_session.save_path: + await abort_access(access) await UploadChunk.filter(upload_id=existing_session.upload_id).delete() await release_storage(f"chunk:{existing_session.upload_id}") else: @@ -338,6 +354,7 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun # 创建新的上传会话 upload_id = uuid.uuid4().hex + upload_id, delivery_path = await prepare_upload(access, safe_file_name, data.file_size, upload_id) reservation_token = f"chunk:{upload_id}" chunk_expire_seconds = max(1, int(getattr(settings, "chunk_expire_hours", 24))) * 3600 await reserve_storage( @@ -355,9 +372,10 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun chunk_size=data.chunk_size, chunk_hash=data.file_hash, file_name=safe_file_name, - save_path=save_path, + save_path=delivery_path or save_path, ) except Exception: + await abort_access(access) await release_storage(reservation_token) raise return APIResponse( @@ -373,13 +391,17 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun @chunk_api.post( "/upload/chunk/{upload_id}/{chunk_index}", - dependencies=[Depends(share_required_login)], + dependencies=[Depends(authorize_upload)], ) async def upload_chunk( upload_id: str, chunk_index: int, + access: Annotated[UploadAccess, Depends(authorize_upload)] = None, chunk: UploadFile = File(...), ): + # 已完成的寄件会话不能继续写分片,避免覆盖正在供下载的文件。 + if access is not None and access.record is not None and access.record.status != "pending": + raise HTTPException(409, "上传已经完成") # 获取上传会话信息 chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: @@ -408,6 +430,11 @@ async def upload_chunk( if chunk_index == 0: validate_header_bytes(chunk_info.file_name, None, chunk_data[:64]) chunk_size = len(chunk_data) + if access and access.record: + # 寄件不得少报容量;普通分片继续沿用下方的原大小校验。 + expected_size = min(chunk_info.chunk_size, chunk_info.file_size - chunk_index * chunk_info.chunk_size) + if chunk_size != expected_size: + raise HTTPException(400, "分片大小与声明的文件范围不一致") # 校验分片大小不超过声明的 chunk_size if chunk_size > chunk_info.chunk_size: @@ -433,7 +460,7 @@ async def upload_chunk( save_path = chunk_info.save_path # 保存分片到存储 - storage = storages[settings.file_storage]() + storage = await upload_storage(access) try: await storage.save_chunk( upload_id, chunk_index, chunk_data, chunk_hash, save_path @@ -460,9 +487,12 @@ async def upload_chunk( return APIResponse(detail={"chunk_hash": chunk_hash}) -@chunk_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)]) -async def cancel_upload(upload_id: str): +@chunk_api.delete("/upload/{upload_id}", dependencies=[Depends(authorize_upload)]) +async def cancel_upload(upload_id: str, access: Annotated[UploadAccess, Depends(authorize_upload)] = None): """取消上传并清理临时文件""" + if access is not None and access.record is not None: + await abort_access(access) + return APIResponse(detail={"message": "上传已取消"}) chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") @@ -470,7 +500,7 @@ async def cancel_upload(upload_id: str): save_path = chunk_info.save_path # 清理存储中的临时文件 - storage = storages[settings.file_storage]() + storage = await upload_storage(access) if save_path: try: await storage.clean_chunks(upload_id, save_path) @@ -485,9 +515,9 @@ async def cancel_upload(upload_id: str): @chunk_api.get( - "/upload/status/{upload_id}", dependencies=[Depends(share_required_login)] + "/upload/status/{upload_id}", dependencies=[Depends(authorize_upload)] ) -async def get_upload_status(upload_id: str): +async def get_upload_status(upload_id: str, access: Annotated[UploadAccess, Depends(authorize_upload)] = None): """获取上传状态""" chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: @@ -512,20 +542,26 @@ async def get_upload_status(upload_id: str): @chunk_api.post( - "/upload/complete/{upload_id}", dependencies=[Depends(share_required_login)] + "/upload/complete/{upload_id}", dependencies=[Depends(authorize_upload)] ) async def complete_upload( upload_id: str, + access: Annotated[UploadAccess, Depends(authorize_upload)] = None, data: CompleteUploadModel = Depends(parse_complete_upload), ip: str = Depends(ip_limit["upload"]), ): + result = await completed_upload(access) + if result: + return APIResponse(detail=result) # 获取上传基本信息 chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") validate_expire_style(data.expire_style) + if access and access.code_id is not None and data.expire_value <= 0: + raise HTTPException(422, "寄件保存期限必须为正数") detail = await FileUploadService.complete_chunked_upload( - upload_id, chunk_info, data.expire_value, data.expire_style + upload_id, chunk_info, data.expire_value, data.expire_style, access=access ) ip_limit["upload"].add_ip(ip) return APIResponse(detail=detail) @@ -544,13 +580,16 @@ def build_proxy_upload_urls(upload_id: str) -> dict: async def _get_valid_session( - upload_id: str, expected_mode: Optional[str] = None + upload_id: str, expected_mode: Optional[str] = None, access: UploadAccess | None = None ) -> PresignUploadSession: """获取并验证会话""" session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: raise HTTPException(404, "上传会话不存在") if await session.is_expired(): + if access and access.record: + # 寄件过期仍须释放次数并跟踪残留;普通会话沿用原清理方式。 + await abort_access(access) await session.delete() await release_storage(f"presign:{upload_id}") raise HTTPException(404, "上传会话已过期") @@ -559,9 +598,9 @@ async def _get_valid_session( return session -@presign_api.post("/upload/init", dependencies=[Depends(share_required_login)]) +@presign_api.post("/upload/init", dependencies=[Depends(authorize_upload)]) async def presign_upload_init( - data: PresignUploadInitRequest, ip: str = Depends(ip_limit["upload"]) + data: PresignUploadInitRequest, access: Annotated[UploadAccess, Depends(authorize_upload)] = None, ip: str = Depends(ip_limit["upload"]) ): """初始化预签名上传,S3返回直传URL,其他存储返回代理URL""" validate_file_type(data.file_name) @@ -572,7 +611,10 @@ async def presign_upload_init( ) validate_expire_style(data.expire_style) + if access and access.code_id is not None and (data.file_size <= 0 or data.expire_value <= 0): + raise HTTPException(422, "寄件文件大小和保存期限必须为正数") upload_id = uuid.uuid4().hex + upload_id, delivery_path = await prepare_upload(access, data.file_name, data.file_size, upload_id) reservation_token = f"presign:{upload_id}" await reserve_storage( reservation_token, data.file_size, ttl_seconds=PRESIGN_SESSION_EXPIRES @@ -581,8 +623,12 @@ async def presign_upload_init( path, _, _, filename, save_path = await FileUploadService.generate_file_path( data.file_name, upload_id ) - storage: FileStorageInterface = storages[settings.file_storage]() - presigned_url = await storage.generate_presigned_upload_url( + if delivery_path: + save_path = delivery_path + storage: FileStorageInterface = await upload_storage(access) + # 寄件通过原代理路径核验真实字节数,不能把未约束大小的 S3 直传 URL 当作受限授权。 + # 普通上传仍按上游规则选择 S3 直传或代理。 + presigned_url = None if access and access.code_id is not None else await storage.generate_presigned_upload_url( save_path, PRESIGN_SESSION_EXPIRES ) mode = "direct" if presigned_url else "proxy" @@ -599,6 +645,7 @@ async def presign_upload_init( expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES), ) except Exception: + await abort_access(access) await release_storage(reservation_token) raise @@ -618,33 +665,39 @@ async def presign_upload_init( @presign_api.put( - "/upload/proxy/{upload_id}", dependencies=[Depends(share_required_login)] + "/upload/proxy/{upload_id}", dependencies=[Depends(authorize_upload)] ) async def presign_upload_proxy( - upload_id: str, file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"]) + upload_id: str, file: UploadFile = File(...), access: Annotated[UploadAccess, Depends(authorize_upload)] = None, ip: str = Depends(ip_limit["upload"]) ): """代理模式上传,服务器转存到存储后端""" - session = await _get_valid_session(upload_id, expected_mode="proxy") - code = await FileUploadService.commit_proxy_upload(session, file) + result = await completed_upload(access) + if result: + return APIResponse(detail=result) + session = await _get_valid_session(upload_id, expected_mode="proxy", access=access) + code = await FileUploadService.commit_proxy_upload(session, file, access=access) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @presign_api.post( - "/upload/confirm/{upload_id}", dependencies=[Depends(share_required_login)] + "/upload/confirm/{upload_id}", dependencies=[Depends(authorize_upload)] ) -async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upload"])): +async def presign_upload_confirm(upload_id: str, access: Annotated[UploadAccess, Depends(authorize_upload)] = None, ip: str = Depends(ip_limit["upload"])): """直传确认,客户端完成S3直传后调用获取分享码""" - session = await _get_valid_session(upload_id, expected_mode="direct") - code = await FileUploadService.confirm_direct_upload(session) + result = await completed_upload(access) + if result: + return APIResponse(detail=result) + session = await _get_valid_session(upload_id, expected_mode="direct", access=access) + code = await FileUploadService.confirm_direct_upload(session, access=access) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @presign_api.get( - "/upload/status/{upload_id}", dependencies=[Depends(share_required_login)] + "/upload/status/{upload_id}", dependencies=[Depends(authorize_upload)] ) -async def presign_upload_status(upload_id: str): +async def presign_upload_status(upload_id: str, access: Annotated[UploadAccess, Depends(authorize_upload)] = None): """查询上传会话状态""" session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: @@ -663,15 +716,18 @@ async def presign_upload_status(upload_id: str): ) -@presign_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)]) -async def presign_upload_cancel(upload_id: str): +@presign_api.delete("/upload/{upload_id}", dependencies=[Depends(authorize_upload)]) +async def presign_upload_cancel(upload_id: str, access: Annotated[UploadAccess, Depends(authorize_upload)] = None): """取消上传会话""" + if access is not None and access.record is not None: + await abort_access(access) + return APIResponse(detail={"message": "上传会话已取消"}) session = await PresignUploadSession.filter(upload_id=upload_id).first() if not session: raise HTTPException(404, "上传会话不存在") if session.mode == "direct": - storage: FileStorageInterface = storages[settings.file_storage]() + storage: FileStorageInterface = await upload_storage(access) try: if await storage.file_exists(session.save_path): temp_file_code = StoredFile( diff --git a/apps/delivery/__init__.py b/apps/delivery/__init__.py new file mode 100644 index 000000000..36e9f5d55 --- /dev/null +++ b/apps/delivery/__init__.py @@ -0,0 +1 @@ +"""文件驿站寄件模块:独立上传权限、收件管理及内置页面。""" diff --git a/apps/delivery/schemas.py b/apps/delivery/schemas.py new file mode 100644 index 000000000..74d661116 --- /dev/null +++ b/apps/delivery/schemas.py @@ -0,0 +1,135 @@ +"""只校验寄件授权参数,存储和文件规则由系统设置统一控制。""" + +import re +from datetime import datetime, timezone, timedelta +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags + + +class DeliveryCodeConfig(BaseModel): + """寄件授权的公共字段,文件和存储配置不在此模型中。""" + # 禁止静默接受 owner_id 等越权字段,未来多用户必须由服务端身份决定归属。 + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + name: str = Field(min_length=1, max_length=100) + expires_at: datetime + max_uploads: int = Field(default=1, ge=1, le=100000) + + @field_validator("expires_at") + @classmethod + def validate_expiry(cls, value): + # 无时区的后台输入明确解释为北京时间,接口返回保留时区。 + if value.tzinfo is None: + value = value.replace(tzinfo=timezone(timedelta(hours=8))) + if value <= datetime.now(timezone.utc): + raise ValueError("有效期必须晚于当前时间") + return value + + +class CreateDeliveryCode(DeliveryCodeConfig): + # 新建自定义寄件码最多 32 位;自动生成仍固定为 16 位。 + code: str = Field(default="", max_length=32) + note: str = Field(default="", max_length=2000) + tags: list[str] = Field(default_factory=list) + + @field_validator("code") + @classmethod + def validate_code(cls, value): + if value and not re.fullmatch(r"[A-Za-z0-9_-]{8,32}", value): + raise ValueError("寄件码须为 8 至 32 位字母、数字、下划线或短横线") + return value + + @field_validator("note", mode="before") + @classmethod + def normalize_note(cls, value: Any) -> str: + # 与文件管理的备注规则相同:非文本值转文本并截断。 + return normalize_metadata_note(value) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tags(cls, value: Any) -> list[str]: + return normalize_metadata_tags(value) + + +class UpdateDeliveryCode(BaseModel): + """编辑寄件码时只更新提交的字段,历史过期记录可单独维护备注。""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + name: str | None = Field(default=None, min_length=1, max_length=100) + expires_at: datetime | None = None + max_uploads: int | None = Field(default=None, ge=1, le=100000) + code: str = Field(default="", max_length=32) + note: str | None = Field(default=None, max_length=2000) + tags: list[str] | None = None + + @field_validator("code") + @classmethod + def validate_code(cls, value): + if value and not re.fullmatch(r"[A-Za-z0-9_-]{8,32}", value): + raise ValueError("寄件码须为 8 至 32 位字母、数字、下划线或短横线") + return value + + @field_validator("expires_at") + @classmethod + def normalize_expiry(cls, value): + # 编辑时可省略旧的过期时间;新提交的无时区时间按北京时间处理。 + return value.replace(tzinfo=timezone(timedelta(hours=8))) if value and value.tzinfo is None else value + + @field_validator("note", mode="before") + @classmethod + def normalize_note(cls, value): + return None if value is None else normalize_metadata_note(value) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tags(cls, value): + return None if value is None else normalize_metadata_tags(value) + + @model_validator(mode="after") + def reject_explicit_null(self): + # 可选字段表示可以省略,不能用 null 意外清空已有配置。 + forbidden = {"name", "expires_at", "max_uploads", "note", "tags"} + if any(field in self.model_fields_set and getattr(self, field) is None for field in forbidden): + raise ValueError("编辑字段不能为 null;请省略不修改的字段") + return self + + +class BatchDeliveryCodes(BaseModel): + """批量管理只允许指定动作涉及的字段,防止整表配置被意外覆盖。""" + + model_config = ConfigDict(extra="forbid") + ids: list[int] = Field(min_length=1, max_length=1000) + action: str + expires_at: datetime | None = None + max_uploads: int | None = Field(default=None, ge=1, le=100000) + + @field_validator("expires_at") + @classmethod + def normalize_expiry(cls, value): + # 批量期限与单条编辑使用相同的后台时间解释规则。 + return value.replace(tzinfo=timezone(timedelta(hours=8))) if value and value.tzinfo is None else value + + @model_validator(mode="after") + def validate_batch(self): + self.ids = list(dict.fromkeys(self.ids)) + if not self.ids: + raise ValueError("请至少选择一个寄件码") + if self.action not in {"enable", "disable", "delete", "update"}: + raise ValueError("不支持的批量操作") + if self.action == "update" and self.expires_at is None and self.max_uploads is None: + raise ValueError("批量更新至少需要有效期或上传次数") + if self.action != "update" and (self.expires_at is not None or self.max_uploads is not None): + raise ValueError("该批量操作不接受有效期或上传次数") + return self + + +class VerifyDeliveryCode(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + code: str = Field(min_length=8, max_length=64) + + +class SetDeliveryEnabled(BaseModel): + model_config = ConfigDict(extra="forbid") + enabled: bool diff --git a/apps/delivery/services.py b/apps/delivery/services.py new file mode 100644 index 000000000..557eca866 --- /dev/null +++ b/apps/delivery/services.py @@ -0,0 +1,244 @@ +"""寄件授权与管理:口令校验、令牌续期、数据库分页和配置更新。""" + +import json +import secrets + +from fastapi import HTTPException +from tortoise.exceptions import IntegrityError +from tortoise.expressions import F, Q +from tortoise.transactions import in_transaction + +from apps.admin.dependencies import create_token, verify_token +from apps.base.models import DeliveryCode, StorageReservation +from apps.base.quota import _sql_placeholders +from apps.base.setup_wizard import build_public_config +from core.utils import get_now + +TOKEN_TTL = 900 + + +async def upload_identity(authorization: str | None) -> int: + """只接受用途为 delivery 的凭证;管理员 token 也不能被误当作寄件授权。""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "请先验证寄件码") + try: + payload = verify_token(authorization[7:]) + if payload.get("purpose") != "delivery" or payload.get("is_admin"): + raise ValueError("凭证用途错误") + code_id = int(payload["delivery_id"]) + # 历史令牌按初始版本 1 解释;改码后同样会立即失效。 + token_version = payload.get("delivery_version", 1) + record = await DeliveryCode.filter(id=code_id, deleted=False).first() + if record is None or int(token_version) != record.auth_version: + raise ValueError("寄件码已修改") + return code_id + except (ValueError, TypeError, KeyError): + raise HTTPException(401, "寄件凭证无效或已过期,请重新验证寄件码") from None + + +async def active_code(code_id: int) -> DeliveryCode: + record = await DeliveryCode.filter(id=code_id, enabled=True, deleted=False).first() + if not record or record.expires_at <= await get_now(): + raise HTTPException(403, "寄件码无效、已过期或已停用") + return record + + +async def create_code(data): + """创建时保存原文,便于管理员后续查看;访客响应仍不提供任何口令列表。""" + code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16)) + try: + record = await DeliveryCode.create( + code_value=code, name=data.name, + expires_at=data.expires_at, max_uploads=data.max_uploads, + note=data.note, tags=data.tags, + ) + except IntegrityError: + raise HTTPException(409, "该寄件码已被使用,请设置其他口令") from None + return {"item": await code_summary(record), "code": code} + + +async def code_summary(record, *, include_code=False): + """仅供已鉴权的后台读取状态和口令原文,不返回摘要或存储密钥。""" + now = await get_now() + state = "active" + if record.deleted: + state = "deleted" + elif record.used_count >= record.max_uploads: + state = "exhausted" + elif not record.enabled: + state = "disabled" + elif record.expires_at <= now: + state = "expired" + return { + "id": record.id, "name": record.name, + **({"code": record.code_value} if include_code else {}), + "note": record.note, "tags": record.tags if isinstance(record.tags, list) else [], + "expires_at": record.expires_at, + "max_uploads": record.max_uploads, "used_count": record.used_count, + "reserved_count": record.reserved_count, "enabled": record.enabled, + "deleted": record.deleted, "status": state, "created_at": record.created_at, + "remaining": max(0, record.max_uploads - record.used_count - record.reserved_count), + } + + +async def verify_code(code: str): + record = await DeliveryCode.filter(code_value=code).first() + if not record: + raise HTTPException(403, "寄件码无效、已过期或已停用") + record = await active_code(record.id) + remaining = record.max_uploads - record.used_count - record.reserved_count + # 已预占的分片会话允许重新验证后续传,新文件仍由 reserve_slot 拒绝超额。 + if remaining <= 0 and not await StorageReservation.filter(delivery_id=record.id, status__in=["pending", "finalizing"]).exists(): + raise HTTPException(409, "可上传次数已耗尽或正在使用,请联系管理员") + return await session_summary(record) + + +async def session_summary(record): + """验证与续期使用相同白名单响应,不泄露路径、存储密钥或管理员授权。""" + # 老版寄件页面仍读取这些字段;值统一取自原公共配置构建函数,不维护独立规则。 + config = build_public_config() + upload_keys = ("upload_size", "allowed_file_types", "expire_style", "max_save_seconds", "enable_chunk") + return { + "token": create_token({"purpose": "delivery", "delivery_id": record.id, + "delivery_version": record.auth_version}, expires_in=TOKEN_TTL), + "expires_in": TOKEN_TTL, "name": record.name, + "remaining": max(0, record.max_uploads - record.used_count - record.reserved_count), + "expires_at": record.expires_at, + **{key: config[key] for key in upload_keys}, + } + + +async def refresh_session(authorization): + """有效令牌可续期;改码、删除、手动停用或到期后不能延长权限。""" + code_id = await upload_identity(authorization) + record = await DeliveryCode.get(id=code_id) + # 耗尽自动停用仅允许获取既有完成结果,创建上传仍由原子预占拒绝。 + if record.expires_at <= await get_now() or (not record.enabled and record.used_count < record.max_uploads): + raise HTTPException(403, "寄件码已过期或停用") + return await session_summary(record) + + +async def update_code(code_id, data): + """原子更新可编辑配置,改码时同步撤销当前版本的临时寄件凭证。""" + async with in_transaction() as conn: + record = await DeliveryCode.filter(id=code_id, deleted=False).using_db(conn).first() + if not record: + raise HTTPException(404, "寄件码不存在或已删除") + changes = data.model_dump(exclude_unset=True) + new_code = changes.pop("code", "") + # 空值和省略均保持历史口令,因此此前 32 位以上的旧码仍可继续使用。 + if "expires_at" in changes and changes["expires_at"] <= await get_now(): + # 兼容旧管理页完整回传过期时间:未改变期限时允许只编辑备注等字段。 + if changes["expires_at"] != record.expires_at: + raise HTTPException(400, "新的有效期必须晚于当前时间") + max_uploads = changes.get("max_uploads", record.max_uploads) + if record.used_count + record.reserved_count > max_uploads: + raise HTTPException(409, "上传总次数不能小于已使用次数与上传中占用次数之和,请刷新后重试") + if new_code and new_code != record.code_value: + # 版本由数据库递增,两个改码请求并发时任一旧令牌都不会被错误复用。 + changes.update(code_value=new_code, auth_version=True) + if changes: + try: + # 条件写入把已用和预占次数与配置修改放入同一语句,避免并发上传越过新额度。 + bound_fields = [field for field in changes if field != "auth_version"] + placeholders = _sql_placeholders(len(bound_fields) + 2) + assignments = ", ".join(f"{field} = {placeholders[index]}" for index, field in enumerate(bound_fields)) + if "auth_version" in changes: + assignments += ", auth_version = auth_version + 1" + changed, _ = await conn.execute_query( + f"UPDATE deliverycode SET {assignments} WHERE id = {placeholders[-2]} " + f"AND deleted = 0 " + f"AND used_count + reserved_count <= {placeholders[-1]}", + [json.dumps(changes[field], ensure_ascii=False) if field == "tags" else changes[field] + for field in bound_fields] + [record.id, max_uploads], + ) + if changed != 1: + raise HTTPException(409, "上传次数已变化,请刷新后重试") + except IntegrityError: + raise HTTPException(409, "该寄件码已被使用,请设置其他口令") from None + record = await DeliveryCode.get(id=record.id).using_db(conn) + return await code_summary(record) + + +async def list_codes(*, page=1, page_size=20, keyword="", status="all", tag="", sort_by="created_at", sort_order="desc"): + """筛选、计数、排序和分页全部在数据库执行,列表不返回口令原文。""" + if status not in {"all", "active", "disabled", "expired", "exhausted"}: + raise HTTPException(400, "不支持的寄件码状态筛选") + if sort_by not in {"created_at", "expires_at", "name", "used_count", "max_uploads"} or sort_order not in {"asc", "desc"}: + raise HTTPException(400, "不支持的排序方式") + query = DeliveryCode.filter(deleted=False) + now = await get_now() + if status == "exhausted": + query = query.filter(used_count__gte=F("max_uploads")) + elif status == "disabled": + query = query.filter(enabled=False, used_count__lt=F("max_uploads")) + elif status == "expired": + query = query.filter(enabled=True, expires_at__lte=now, used_count__lt=F("max_uploads")) + elif status == "active": + query = query.filter(enabled=True, expires_at__gt=now, used_count__lt=F("max_uploads")) + if keyword.strip(): + query = query.filter(Q(name__icontains=keyword.strip()) | Q(note__icontains=keyword.strip())) + if tag.strip(): + # 当前应用使用 SQLite;JSON 数组逐项精确匹配,不把标签误当成子串。 + from tortoise.expressions import RawSQL + escaped = tag.strip().replace("'", "''") + query = query.annotate(tag_match=RawSQL( + "EXISTS (SELECT 1 FROM json_each(deliverycode.tags) WHERE lower(value) = lower('" + escaped + "'))" + )).filter(tag_match=1) + total = await query.count() + order = ("-" if sort_order == "desc" else "") + sort_by + # 只选取管理展示字段,口令原文仅由单独管理接口按需返回。 + records = await query.order_by(order, "-id").offset((page - 1) * page_size).limit(page_size).only( + "id", "name", "note", "tags", "expires_at", + "max_uploads", "used_count", "reserved_count", "enabled", "deleted", "created_at", + ) + return {"items": [await code_summary(record) for record in records], "total": total} + + +async def batch_codes(data): + """批量操作在同一事务中先完整校验,任一记录不合法时整批不变更。""" + async with in_transaction() as conn: + records = await DeliveryCode.filter(id__in=data.ids, deleted=False).using_db(conn) + by_id = {record.id: record for record in records} + missing = [str(code_id) for code_id in data.ids if code_id not in by_id] + if missing: + raise HTTPException(404, "寄件码不存在、已删除或不可用:" + "、".join(missing)) + if data.action == "enable" and any(record.code_value is None for record in records): + raise HTTPException(409, "所选记录包含没有原文的旧码,请先重新设置寄件码") + if data.action == "update": + now = await get_now() + if data.expires_at is not None and data.expires_at <= now: + raise HTTPException(400, "新的有效期必须晚于当前时间") + if data.max_uploads is not None: + invalid = [str(record.id) for record in records if record.used_count + record.reserved_count > data.max_uploads] + if invalid: + raise HTTPException(409, "上传次数不能小于已使用和上传中占用次数,受影响寄件码:" + "、".join(invalid)) + if data.action == "delete": + await DeliveryCode.filter(id__in=data.ids).using_db(conn).update(deleted=True, enabled=False, auth_version=F("auth_version") + 1) + return {"message": "已删除寄件码,已收文件和取件码不受影响", "count": len(data.ids)} + changes = {"enabled": data.action == "enable"} if data.action in {"enable", "disable"} else {} + if data.action == "update": + if data.expires_at is not None: + changes["expires_at"] = data.expires_at + if data.max_uploads is not None: + changes["max_uploads"] = data.max_uploads + # 批量条件写入必须覆盖全部记录,防止并发上传使其中一个新额度失效。 + placeholders = _sql_placeholders(len(changes) + len(data.ids) + 1) + assignments = ", ".join(f"{field} = {placeholders[index]}" for index, field in enumerate(changes)) + if data.action in {"enable", "disable"}: + assignments += ", auth_version = auth_version + 1" + id_placeholders = ", ".join(placeholders[len(changes):-1]) + condition = "" + values = list(changes.values()) + data.ids + if data.action == "update" and data.max_uploads is not None: + # max_uploads 是 update 时第一个或第二个字段,改用其实际占位符。 + condition = f" AND used_count + reserved_count <= {placeholders[-1]}" + values.append(data.max_uploads) + changed, _ = await conn.execute_query( + f"UPDATE deliverycode SET {assignments} WHERE id IN ({id_placeholders}) " + f"AND deleted = 0{condition}", values, + ) + if changed != len(data.ids): + raise HTTPException(409, "寄件码状态已变化,请刷新后重试") + result = await DeliveryCode.filter(id__in=data.ids, deleted=False).using_db(conn) + return {"items": [await code_summary(record) for record in result], "count": len(result)} diff --git a/apps/delivery/views.py b/apps/delivery/views.py new file mode 100644 index 000000000..6e8c45b24 --- /dev/null +++ b/apps/delivery/views.py @@ -0,0 +1,123 @@ +"""寄件只提供授权和后台配置,文件上传及管理复用普通文件接口。""" + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response +from fastapi.responses import PlainTextResponse, RedirectResponse +from tortoise.expressions import F + +from apps.admin.dependencies import get_admin_session +from apps.base.dependencies import get_client_ip +from apps.base.models import DeliveryCode +from apps.base.pages import theme_has_delivery_ui +from apps.base.utils import ip_limit +from apps.delivery import services +from apps.delivery.schemas import BatchDeliveryCodes, CreateDeliveryCode, SetDeliveryEnabled, UpdateDeliveryCode, VerifyDeliveryCode +from core.response import APIResponse + + +def private_response(response: Response): + # 授权响应和后台凭证都不得进入浏览器或中间代理缓存。 + response.headers["Cache-Control"] = "no-store" + + +public_api = APIRouter(prefix="/api/delivery", tags=["寄件授权"], dependencies=[Depends(private_response)]) +admin_api = APIRouter(prefix="/admin/delivery", tags=["寄件管理"], dependencies=[Depends(get_admin_session), Depends(private_response)]) +pages = APIRouter() + + +@public_api.post("/verify") +async def verify(data: VerifyDeliveryCode, request: Request): + ip = get_client_ip(request) + limiter = ip_limit["error"] + if not limiter.check_ip(ip): + raise HTTPException(429, "尝试次数过多,请稍后重试") + try: + result = await services.verify_code(data.code) + except HTTPException: + limiter.add_ip(ip) + raise + return APIResponse(detail=result) + + +@public_api.post("/refresh") +async def refresh(authorization: str | None = Header(default=None)): + """续期不使用管理员会话,也不会重新开放已撤销的寄件授权。""" + return APIResponse(detail=await services.refresh_session(authorization)) + + +@admin_api.get("/codes") +async def list_codes( + page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str = "", + status: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc", +): + return APIResponse(detail=await services.list_codes( + page=page, page_size=page_size, keyword=keyword, status=status, + tag=tag, sort_by=sort_by, sort_order=sort_order, + )) + + +@admin_api.post("/codes", status_code=201) +async def create(data: CreateDeliveryCode): + return APIResponse(detail=await services.create_code(data)) + + +@admin_api.patch("/codes/{code_id}") +async def toggle(code_id: int, data: SetDeliveryEnabled): + # 缺少原文的历史码必须先重新设码,不能仅通过启用按钮恢复旧凭证。 + if data.enabled and await DeliveryCode.filter(id=code_id, deleted=False, code_value__isnull=True).exists(): + raise HTTPException(409, "请先编辑并重新设置寄件码,再启用授权") + # 手动启停递增版本,停用后重新启用也不能复活旧令牌。 + changed = await DeliveryCode.filter(id=code_id, deleted=False).update(enabled=data.enabled, auth_version=F("auth_version") + 1) + if not changed: + raise HTTPException(404, "寄件码不存在或已删除") + return APIResponse(detail=await services.code_summary(await DeliveryCode.get(id=code_id))) + + +@admin_api.delete("/codes/{code_id}") +async def delete_code(code_id: int): + # 软删除保留收件关联;普通取件码生命周期独立,不撤销已完成文件。 + changed = await DeliveryCode.filter(id=code_id, deleted=False).update(deleted=True, enabled=False, auth_version=F("auth_version") + 1) + if not changed: + raise HTTPException(404, "寄件码不存在或已删除") + return APIResponse(detail={"message": "寄件授权已撤销,已收文件仍保留"}) + + +@admin_api.get("/codes/{code_id}/secret") +async def reveal_code(code_id: int): + """只有管理员主动查看或复制时才读取口令,列表始终不携带凭证。""" + record = await DeliveryCode.filter(id=code_id, deleted=False).first() + if record is None: + raise HTTPException(404, "寄件码不存在或已删除") + return APIResponse(detail={"code": record.code_value}) + + +@admin_api.put("/codes/{code_id}") +async def update_config(code_id: int, data: UpdateDeliveryCode): + return APIResponse(detail=await services.update_code(code_id, data)) + + +@admin_api.post("/codes/batch") +async def batch_codes(data: BatchDeliveryCodes): + return APIResponse(detail=await services.batch_codes(data)) + + +@pages.get("/delivery", include_in_schema=False) +@pages.get("/delivery/", include_in_schema=False) +async def delivery_page(): + if theme_has_delivery_ui(): + return RedirectResponse('/#/delivery', status_code=307) + return upgrade_notice() + + +@pages.get("/delivery/admin", include_in_schema=False) +async def management_page(): + if theme_has_delivery_ui(): + return RedirectResponse('/#/admin/delivery', status_code=307) + return upgrade_notice() + + +def upgrade_notice(): + """旧主题仅提示升级,不再注入脚本或维护第二套上传和管理界面。""" + return PlainTextResponse("寄件功能需要支持寄件的 2024 主题,请联系管理员更新并切换主题。", headers={ + "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + }) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index efd8ddb1e..4c80aea9d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -30,6 +30,8 @@ export default defineConfig({ { text: '快速开始', link: '/guide/getting-started' }, { text: '上传与分享', link: '/guide/upload' }, { text: '管理面板', link: '/guide/management' }, + // 寄件码的上传授权与普通取件码用途不同,单独说明。 + { text: '寄件码', link: '/guide/delivery' }, { text: '存储配置', link: '/guide/storage' }, ], }, @@ -64,6 +66,8 @@ export default defineConfig({ { text: '文件上传', link: '/guide/upload' }, { text: '文件分享', link: '/guide/share' }, { text: '文件管理', link: '/guide/management' }, + // 新增寄件功能入口,文档随本仓库维护。 + { text: '寄件码', link: '/guide/delivery' }, ], }, { diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md new file mode 100644 index 000000000..e0c1ed514 --- /dev/null +++ b/docs/guide/delivery.md @@ -0,0 +1,51 @@ +# 寄件码 + +寄件码是管理员发放的受限上传授权。即使关闭游客上传,访客仍可凭有效寄件码投递到系统设置指定的存储目录。成功上传返回普通取件码,接收者无需登录即可按取件期限下载。寄件码本身不能下载文件,也不能访问管理接口。 + +## 使用与主题 + +1. 安装配套 2024 主题,在原后台“寄件管理”创建口令、有效期和最大上传次数。 +2. 存储方式、目录、文件大小和类型、分片开关及文件过期策略全部使用系统设置,寄件码不提供独立覆盖。 +3. 创建结果提供口令、链接和二维码。列表不返回口令原文,点击“查看 / 复制”才通过管理员专用接口读取,响应禁止缓存。 +4. 访客进入寄件链接,验证后复用普通发送页面。文件、文本以及一个多文件 ZIP 都按一次成功分享计次。 +5. 管理员点击“查看收件”,使用原文件管理的详情、下载、编辑和删除功能。 + +原生寄件界面仅支持 2024 主题。2023 或尚未更新的主题访问 `/delivery`、`/delivery/admin` 时提示切换主题,不注入脚本,也不提供独立 HTML 上传或管理页面。旧 `/api/delivery/upload` 接口已移除。 + +新口令自动生成 16 位随机字符,也可自定义 8~32 位字母、数字、下划线或短横线。历史长口令仍兼容验证。编辑时口令留空表示保持原值;改码、手动启停及删除均撤销旧令牌。 + +## 次数、文件与清理 + +- 上传使用 `/share/*`、`/chunk/*` 和 `/presign/*` 原有接口。寄件的非分片上传使用已有代理模式,按实际大小核验授权配额;不向寄件者发放未约束大小的 S3 直传 URL。普通上传的直传行为不变。 +- 每次上传先在数据库中原子预占次数。提交成功时,同事务增加已用次数、创建 `FileCodes` 并删除预留。失败不增加成功次数。 +- 仅保留 `DeliveryCode` 授权表,成功文件通过 `FileCodes.delivery_id` 关联。上传期间的归属、路径及容量复用 `StorageReservation`,不再维护第二份成功收件记录。 +- 额度耗尽后自动停用并保留寄件码,仍可按码查看收件。管理员增加额度后可重新启用。删除采用软删除,保留历史关联,已生成取件码继续独立有效。 +- 删除文件不退还已经使用的寄件次数。普通文件清理负责成功收件;未完成上传清理失败时保留容量预留计费,后台重试成功后才释放。 +- 寄件文件使用原系统路径生成器和存储驱动,与普通发送采用相同目录规则。已开始上传的会话和成功文件保留实际后端与位置,以免系统设置切换后找错文件。 +- 寄件授权与文件领取期限独立。到期、改码或手动停用后,未提交的上传不能完成。 + +## 授权与续期 + +上传凭证有效期为 15 分钟,只包含寄件身份和授权版本。前端在到期前通过 `/api/delivery/refresh` 续期;每个上传请求前也检查有效期,并发分片共用一次刷新,避免后台标签页计时器暂停导致中断。令牌已经过期时,仅使用当前页面内存中的原寄件码重新验证;口令和令牌不保存到本地存储。 + +每个上传会话校验归属。其他寄件码、游客和管理员令牌不能冒用寄件上传会话。完成响应丢失时,分片/预签名完成接口可根据普通文件表的 `upload_id` 返回既有结果,避免重复扣次。 + +后台列表在数据库筛选、计数、排序和分页;关键字查询名称与备注,标签独立精确筛选。凭证不进入列表响应。口令仅保存一份具有唯一约束的原文,不再同时保存 HMAC 摘要;原文必须与管理员配置数据同等保护。 + +## 存储 + +寄件不再保存或接收存储类型和目标目录配置。每次新上传读取系统当前 `file_storage` 和 `storage_path`,调用原 `build_file_path` 与原存储驱动。账号、密钥、桶名、服务地址等只在系统设置维护。 + +上传限制直接使用原发送页的同一个配置 Store;后端仍由原上传服务实时校验站点限制。寄件独有的数据仅限口令、用途、授权期限、次数、启停及管理备注标签。授权期限与文件领取期限是不同概念,前者控制能否投递,后者仍由原文件管理处理。 + +## 升级 + +启动依次执行迁移 008~015。升级保留既有数据:013 将成功分享关联迁入 `FileCodes`,旧私有文件转入同表但标记 `is_private`,公共取件接口拒绝访问;后台仍能管理和下载。旧未完成上传取消并转为待清理预留,不跨版本继续提交。 + +旧版本已物理删除的寄件码,会为其尚存收件补充停用的历史记录。所有转移完成后删除旧 `deliveryfile` 表。迁移使用事务并支持重试,不修改实际存储文件。 + +普通上传的参数模型、分片规则、默认存储选择以及驱动实现与主干保持一致。寄件额外校验仅作用于持有寄件授权的请求,保障其次数与容量预占不被绕过。旧版本曾添加到普通上传会话中的快照列可留在数据库中,但运行时代码不再读取或写入。 + +迁移 014 移除旧摘要和未使用的账号归属列。有原文的寄件码保持原口令和授权状态;缺少原文的历史码保留 ID、次数和收件关系,停用并递增授权版本。管理员必须先编辑设置新口令,再显式启用;不能依靠旧口令或旧令牌恢复授权。 + +迁移 015 移除寄件码的独立存储类型和目录字段,保留授权 ID、口令、计数与历史收件关系。已上传文件及正在上传的会话不搬动;新上传全部跟随系统当前设置。 diff --git a/main.py b/main.py index 0127b270b..e9debb7f8 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,8 @@ delete_expire_files, ) from apps.base.views import share_api, chunk_api, presign_api +from apps.base.upload_sessions import cleanup_loop as delivery_cleanup_loop +from apps.delivery.views import admin_api as delivery_admin_api, public_api as delivery_api, pages as delivery_pages from core.database import db_startup_lock, get_db_config, init_db from core.errors import StorageError from core.logger import get_log_level_name, is_access_log_enabled, logger @@ -48,6 +50,8 @@ async def lifespan(app: FastAPI): task = asyncio.create_task(delete_expire_files()) chunk_cleanup_task = asyncio.create_task(clean_incomplete_uploads()) presign_cleanup_task = asyncio.create_task(clean_expired_presign_sessions()) + # 独立回收崩溃遗留的寄件次数与文件,不改变普通分享的过期清理规则。 + delivery_cleanup_task = asyncio.create_task(delivery_cleanup_loop()) logger.info("应用初始化完成") try: @@ -56,10 +60,12 @@ async def lifespan(app: FastAPI): task.cancel() chunk_cleanup_task.cancel() presign_cleanup_task.cancel() + delivery_cleanup_task.cancel() await asyncio.gather( task, chunk_cleanup_task, presign_cleanup_task, + delivery_cleanup_task, return_exceptions=True, ) await Tortoise.close_connections() @@ -138,6 +144,9 @@ async def refresh_settings_middleware(request, call_next): app.include_router(presign_api) app.include_router(presign_api, prefix="/api") app.include_router(admin_api) +app.include_router(delivery_admin_api) +app.include_router(delivery_api) +app.include_router(delivery_pages) app.include_router(pages_router) # 404 时返回主题首页(index 兼任 exception handler 与 GET / 路由) diff --git a/readme.md b/readme.md index 920636b1e..48c4efbde 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,14 @@ +## 寄件码 + +使用 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码按系统设置投递;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 + +- 访客投递:`/delivery` +- 寄件管理:`/delivery/admin`(使用本站管理员密码) +- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–015。2023 前端源码未修改,旧主题访问寄件入口时提示更新并切换到 2024 主题。 + ## 一条命令开始 ```bash