From 03b17419435da9a8c6e93bd01b7cbb32399e2e3e Mon Sep 17 00:00:00 2001 From: dawnStamp <310845922@qq.com> Date: Tue, 15 Sep 2026 20:01:16 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(=E5=AF=84=E4=BB=B6):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=AF=84=E4=BB=B6=E6=8E=88=E6=9D=83=E5=B9=B6=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E6=99=AE=E9=80=9A=E4=B8=8A=E4=BC=A0=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/services.py | 21 +- apps/admin/views.py | 2 + apps/base/migrations/migrations_008.py | 42 ++++ apps/base/migrations/migrations_009.py | 11 + apps/base/migrations/migrations_010.py | 11 + apps/base/models.py | 39 +++ apps/base/pages.py | 23 +- apps/base/quota.py | 9 +- apps/base/schemas.py | 14 +- apps/base/services.py | 109 ++++---- apps/base/share_storage.py | 28 +++ apps/base/tasks.py | 16 +- apps/base/upload_access.py | 131 ++++++++++ apps/base/views.py | 126 ++++++---- apps/delivery/__init__.py | 1 + apps/delivery/schemas.py | 71 ++++++ apps/delivery/services.py | 333 +++++++++++++++++++++++++ apps/delivery/static/admin.html | 48 ++++ apps/delivery/static/admin.js | 141 +++++++++++ apps/delivery/static/common.js | 21 ++ apps/delivery/static/delivery.css | 5 + apps/delivery/static/delivery.html | 38 +++ apps/delivery/static/delivery.js | 63 +++++ apps/delivery/static/entry.css | 2 + apps/delivery/static/entry.js | 9 + apps/delivery/static/logo.svg | 2 + apps/delivery/storage.py | 117 +++++++++ apps/delivery/views.py | 218 ++++++++++++++++ core/storage.py | 5 +- docs/.vitepress/config.mts | 4 + docs/guide/delivery.md | 89 +++++++ main.py | 9 + readme.md | 10 +- tests/test_issue_476_theme_assets.py | 5 +- tests/test_security_gaps.py | 5 +- 35 files changed, 1655 insertions(+), 123 deletions(-) create mode 100644 apps/base/migrations/migrations_008.py create mode 100644 apps/base/migrations/migrations_009.py create mode 100644 apps/base/migrations/migrations_010.py create mode 100644 apps/base/share_storage.py create mode 100644 apps/base/upload_access.py create mode 100644 apps/delivery/__init__.py create mode 100644 apps/delivery/schemas.py create mode 100644 apps/delivery/services.py create mode 100644 apps/delivery/static/admin.html create mode 100644 apps/delivery/static/admin.js create mode 100644 apps/delivery/static/common.js create mode 100644 apps/delivery/static/delivery.css create mode 100644 apps/delivery/static/delivery.html create mode 100644 apps/delivery/static/delivery.js create mode 100644 apps/delivery/static/entry.css create mode 100644 apps/delivery/static/entry.js create mode 100644 apps/delivery/static/logo.svg create mode 100644 apps/delivery/storage.py create mode 100644 apps/delivery/views.py create mode 100644 docs/guide/delivery.md diff --git a/apps/admin/services.py b/apps/admin/services.py index ab7a97e11..1c73585ea 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -18,7 +18,8 @@ from apps.base.config import refresh_settings from apps.base.services import 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, DeliveryFile, FileCodes, KeyValue +from apps.base.share_storage import remove_delivery_share, storage_for_share from apps.base.utils import get_expire_info, get_file_path_name from apps.base.quota import release_storage, reserve_storage from fastapi import HTTPException @@ -98,6 +99,9 @@ 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): + # 寄件分享在两个管理入口使用相同撤销与清理逻辑,避免重复计费或遗留可用取件码。 + if await remove_delivery_share(file_code): + return if file_code.text is None: await self.file_storage.delete_file(stored_file_of(file_code)) await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete() @@ -477,6 +481,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) @@ -487,7 +492,16 @@ 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, owner_id="admin").exists(): + raise HTTPException(404, "寄件码不存在") + share_ids = await DeliveryFile.filter( + delivery_id=delivery_id, owner_id="admin", status="shared" + ).values_list("share_id", flat=True) + query = query.filter(id__in=share_ids) + all_files = await query now = await get_now() enriched_files = [] summary = { @@ -1412,7 +1426,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 self.file_storage.get_file_response(stored_file_of(file_code))) + storage = await storage_for_share(file_code, self._file_storage) + return response_from_download(await storage.get_file_response(stored_file_of(file_code))) async def preview_file(self, file_id: int, max_chars: int = 4000): max_chars = min(max(max_chars, 1), 20000) diff --git a/apps/admin/views.py b/apps/admin/views.py index dcbd61ab2..45c9c358b 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -381,6 +381,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) @@ -394,6 +395,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/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/models.py b/apps/base/models.py index 3820dee1b..4b2e7f147 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -95,6 +95,45 @@ class StorageReservation(models.Model): expires_at = fields.DatetimeField(index=True) +class DeliveryCode(models.Model): + """只授予投递权限的口令;不进入公开取件码表,避免形成下载授权。""" + + id = fields.IntField(pk=True) + code_digest = fields.CharField(max_length=64, unique=True) + # 与普通取件码一样保留原文供管理员管理;旧记录为 NULL,不能从摘要反推。 + code_value = fields.CharField(max_length=64, null=True) + name = fields.CharField(max_length=100) + owner_id = fields.CharField(max_length=64, default="admin", index=True) + storage_type = fields.CharField(max_length=20) + target_path = fields.CharField(max_length=200) + 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) + + +class DeliveryFile(models.Model): + """寄件收件记录;pending 占用次数,stored 计入永久容量,独立于公开分享。""" + + id = fields.IntField(pk=True) + delivery_id = fields.IntField(index=True) + # 新的授权分享关联普通取件记录;NULL 表示旧版私有收件,绝不自动公开。 + share_id = fields.IntField(null=True, index=True) + owner_id = fields.CharField(max_length=64, default="admin", index=True) + token = fields.CharField(max_length=64, unique=True) + filename = fields.CharField(max_length=255, default="") + stored_name = fields.CharField(max_length=255, default="") + file_path = fields.CharField(max_length=200) + storage_type = fields.CharField(max_length=20) + size = fields.BigIntField(default=0) + status = fields.CharField(max_length=20, default="pending", index=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes") upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk") key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue") diff --git a/apps/base/pages.py b/apps/base/pages.py index 5bd174324..9c2e67b90 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,19 @@ 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") + # 原生 2024 寄件页面自行提供导航,旧主题仍保留独立入口以兼容滚动更新。 + if not theme_has_delivery_ui(template): + template = template.replace("", '' + '') 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 +142,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 988ffc796..9ce5d9aea 100644 --- a/apps/base/quota.py +++ b/apps/base/quota.py @@ -4,7 +4,7 @@ from tortoise import connections from tortoise.functions import Sum -from apps.base.models import FileCodes, StorageReservation +from apps.base.models import DeliveryFile, FileCodes, StorageReservation from core.settings import settings from core.utils import get_now @@ -57,11 +57,15 @@ async def get_storage_usage() -> dict[str, int | None]: now = await get_now() # SQL 聚合:此函数在每次上传配额检查时调用,禁止全表拉取(D3) used_rows = await FileCodes.all().annotate(total=Sum("size")).values("total") + # 私有收件与失败残留计入;shared 已由 FileCodes 计算,不能重复计费。 + delivery_rows = await DeliveryFile.filter(status__in=["stored", "cleanup"]).annotate( + total=Sum("size") + ).values("total") reserved_rows = await StorageReservation.filter(expires_at__gt=now).annotate( total=Sum("size") ).values("total") limit = get_storage_limit() - used_bytes = used_rows[0]["total"] or 0 + used_bytes = (used_rows[0]["total"] or 0) + (delivery_rows[0]["total"] or 0) reserved_bytes = reserved_rows[0]["total"] or 0 return { "limit": limit, @@ -95,6 +99,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), 0) + + COALESCE((SELECT SUM(size) FROM deliveryfile WHERE status IN ('stored', 'cleanup')), 0) + COALESCE((SELECT SUM(size) FROM storagereservation WHERE expires_at > {ph[3]}), 0) + {ph[4]} ) <= {ph[5]} diff --git a/apps/base/schemas.py b/apps/base/schemas.py index f4cf4a405..ecf5b8112 100644 --- a/apps/base/schemas.py +++ b/apps/base/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class SelectFileModel(BaseModel): @@ -7,13 +7,15 @@ class SelectFileModel(BaseModel): class InitChunkUploadModel(BaseModel): file_name: str - chunk_size: int = 5 * 1024 * 1024 - file_size: int + # 在计算分片数之前限制非法或过大的分片,避免除零和单片内存失控。 + chunk_size: int = Field(default=5 * 1024 * 1024, ge=1, le=5 * 1024 * 1024) + file_size: int = Field(ge=1) file_hash: str class CompleteUploadModel(BaseModel): - expire_value: int + # 分享完成沿用正数期限,不能通过分片接口绕开表单约束。 + expire_value: int = Field(ge=1) expire_style: str @@ -21,8 +23,8 @@ class CompleteUploadModel(BaseModel): class PresignUploadInitRequest(BaseModel): """预签名上传初始化请求""" file_name: str - file_size: int - expire_value: int = 1 + file_size: int = Field(ge=1) + expire_value: int = Field(default=1, ge=1) expire_style: str = "day" diff --git a/apps/base/services.py b/apps/base/services.py index 095c46935..e384fa70a 100644 --- a/apps/base/services.py +++ b/apps/base/services.py @@ -12,8 +12,9 @@ from core.logger import logger from core.settings import settings -from core.storage import FileStorageInterface, StoredDownload, StoredFile, storages +from core.storage import FileStorageInterface, StoredDownload, StoredFile +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.models import FileCodes, PresignUploadSession, UploadChunk from apps.base.quota import release_storage, reserve_storage @@ -87,10 +88,6 @@ def chunk_reservation_ttl() -> int: class FileUploadService: """统一的文件上传服务""" - @staticmethod - def _storage() -> FileStorageInterface: - return storages[settings.file_storage]() - @staticmethod async def generate_file_path( file_name: str, upload_id: str | None = None @@ -105,6 +102,7 @@ async def create_file_record( file_path: str, expire_value: int, expire_style: str, + access=None, **extra_fields, ) -> str: """统一创建FileCodes记录,返回code""" @@ -113,7 +111,7 @@ async def create_file_record( ) prefix, suffix = os.path.splitext(file_name) - await FileCodes.create( + await create_upload_share(access, code=code, prefix=prefix, suffix=suffix, @@ -129,17 +127,22 @@ async def create_file_record( @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")) + # 文本与普通发送一致存入取件表,并占用一次寄件授权。 + 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 = 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, @@ -150,25 +153,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 "") ) + # 寄件授权只覆盖路径与归属,保留普通上传校验和存储流程。 + _, 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 = f"file:{uuid.uuid4().hex}" await reserve_storage(token, size, ttl_seconds=3600) - storage = FileUploadService._storage() + storage = await upload_storage(access) 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, @@ -186,18 +195,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) await reserve_storage( f"chunk:{upload_id}", chunk_info.file_size, ttl_seconds=chunk_reservation_ttl() ) @@ -208,8 +218,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 max_total_size > settings.upload_size: save_path = chunk_info.save_path if save_path: @@ -245,7 +255,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, @@ -259,10 +269,14 @@ 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: + 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: @@ -279,7 +293,7 @@ async def complete_chunked_upload( @staticmethod async def commit_proxy_upload( - session: PresignUploadSession, file: UploadFile + session: PresignUploadSession, file: UploadFile, access=None ) -> str: """预签名代理上传:配额 → 大小/类型/一致性校验 → 转存 → 建记录 → 会话清理。 @@ -296,36 +310,18 @@ async def commit_proxy_upload( if abs(file_size - session.file_size) > 1024: raise HTTPException(400, "文件大小与声明不符") - storage = FileUploadService._storage() + storage = await upload_storage(access) 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: """预签名直传确认:配额 → 文件存在性 → 建记录 → 会话清理。 预留失败说明配额已耗尽,此时清理远端临时文件与会话后原样抛出。 @@ -337,7 +333,7 @@ async def confirm_direct_upload(session: PresignUploadSession) -> str: ttl_seconds=PRESIGN_SESSION_EXPIRES, ) except HTTPException: - storage = FileUploadService._storage() + storage = await upload_storage(access) try: if await storage.file_exists(session.save_path): await storage.delete_file( @@ -351,33 +347,34 @@ 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 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..fb30c713b --- /dev/null +++ b/apps/base/share_storage.py @@ -0,0 +1,28 @@ +"""寄件生成的普通分享使用原投递后端,避免全站存储切换后找错文件。""" + +from apps.base.models import DeliveryFile +from core.settings import settings +from core.storage import storages + + +async def delivery_record(file_code): + return await DeliveryFile.filter(share_id=file_code.id).first() + + +async def storage_for_share(file_code, fallback=None): + record = await delivery_record(file_code) + if record is not None: + # 延迟导入以保持应用模块边界,沿用 OneDrive 的精确对象键适配。 + from apps.delivery.storage import get_storage + return await get_storage(record.storage_type) + return fallback if fallback is not None else storages[settings.file_storage]() + + +async def remove_delivery_share(file_code): + """先撤销取件记录再清理文件;失败残留继续占用容量并交由后台重试。""" + record = await delivery_record(file_code) + if record is None: + return False + from apps.delivery.services import request_file_removal + await request_file_removal(record.id) + return True diff --git a/apps/base/tasks.py b/apps/base/tasks.py index 4a682877e..ad2fcf374 100644 --- a/apps/base/tasks.py +++ b/apps/base/tasks.py @@ -20,6 +20,7 @@ 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.share_storage import remove_delivery_share from core.utils import get_now @@ -27,7 +28,7 @@ async def delete_expire_files(): while True: try: await refresh_settings() - file_storage: FileStorageInterface = storages[settings.file_storage]() + file_storage = None # 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除 if settings.file_storage == "local": for root, dirs, files in os.walk(f"{data_root}/share/data"): @@ -42,6 +43,15 @@ async def delete_expire_files(): ).all() for exp in expire_data: try: + if await remove_delivery_share(exp): + continue + except Exception: + # 关联清理事务失败时保留分享,下一轮重试,不能落入普通删除分支丢失关联。 + logger.warning("寄件分享过期清理失败 id=%s", exp.id, exc_info=True) + continue + try: + if file_storage is None: + file_storage = storages[settings.file_storage]() await file_storage.delete_file(stored_file_of(exp)) except Exception as e: logger.error(f"删除过期文件失败 code={exp.code}: {e}") @@ -65,7 +75,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: @@ -105,7 +115,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..4030b2d92 --- /dev/null +++ b/apps/base/upload_access.py @@ -0,0 +1,131 @@ +"""普通上传的可选寄件授权层;通过显式参数传递,不修改全站配置或管理员会话。""" + +import asyncio +from dataclasses import dataclass + +from fastapi import Header, HTTPException, Request + +from apps.base.models import DeliveryFile, FileCodes +from core.settings import settings +from core.storage import storages +from core.utils import get_now + + +@dataclass +class UploadAccess: + """每个请求独立的上传身份;寄件记录的 token 同时绑定普通上传会话。""" + + code_id: int | None = None + record: DeliveryFile | None = None + + +async def authorize_upload(request: Request, authorization: str | None = Header(default=None)): + """校验每一步的授权和会话归属,游客模式也不能访问寄件上传会话。""" + from apps.admin.dependencies import share_required_login, verify_token + from apps.delivery.services import active_code, heartbeat + + access = UploadAccess() + if authorization and authorization.startswith("Bearer "): + try: + payload = verify_token(authorization[7:]) + except ValueError: + raise HTTPException(401, "上传凭证无效或已过期") from None + if payload.get("purpose") == "delivery" and not payload.get("is_admin"): + access.code_id = int(payload["delivery_id"]) + await active_code(access.code_id) + if access.code_id is None: + await share_required_login(authorization) + + upload_id = request.path_params.get("upload_id") + if upload_id: + record = await DeliveryFile.filter(token=upload_id).first() + if record is not None or upload_id.startswith("d_"): + if record is None or record.delivery_id != access.code_id: + raise HTTPException(404, "上传会话不存在") + if record.status not in {"pending", "shared"}: + raise HTTPException(409, "上传会话正在完成或清理,请稍后重试") + access.record = record + elif access.code_id is not None: + raise HTTPException(404, "上传会话不属于该寄件码") + + # 完成阶段原子抢占,避免并发合并或代理上传覆盖同一个已发布文件。 + finalizing = False + if access.record is not None and access.record.status == "pending" and ( + "/complete/" in request.url.path or "/confirm/" in request.url.path or "/proxy/" in request.url.path + ): + changed = await DeliveryFile.filter(id=access.record.id, status="pending").update(status="finalizing", updated_at=await get_now()) + if changed != 1: + raise HTTPException(409, "上传正在完成,请稍后重试") + access.record.status = "finalizing" + finalizing = True + # 长时间传输续租;新会话在初始化后由后续分片请求续租,空闲会话由后台回收。 + task = None + if access.record is not None and access.record.status in {"pending", "finalizing"}: + await DeliveryFile.filter(id=access.record.id).update(updated_at=await get_now()) + task = asyncio.create_task(heartbeat(access.record)) + try: + yield access + except BaseException: + # 初始化、文本或单文件上传失败即释放本次占用;分片请求失败则保留已传内容供续传。 + if not upload_id and access.record is not None: + await asyncio.shield(abort_access(access)) + raise + finally: + if finalizing: + # 失败仍可重新提交完成请求;成功记录已变为 shared,不会被回退。 + await DeliveryFile.filter(id=access.record.id, status="finalizing").update(status="pending", updated_at=await get_now()) + 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 + from apps.delivery.services import reserve_slot, normalize_delivery_filename + + record = await reserve_slot(access.code_id) + # 先绑定记录,后续文件名处理或会话保存失败时依赖退出逻辑仍能释放占用。 + access.record = record + name = await normalize_delivery_filename(file_name) + record.token = "d_" + upload_id + record.filename = name + record.stored_name = record.token + "_" + name + record.size = file_size + await record.save() + return record.token, f"{record.file_path}/{record.stored_name}" + + +async def upload_storage(access=None): + """上传和合并均使用寄件码选定的存储,普通上传仍遵循全站设置。""" + if access is not None and access.record is not None: + from apps.delivery.storage import get_storage + return await get_storage(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: + return await FileCodes.create(**fields) + # 授权适配层只传递身份,扣次及私有/公开收件事务集中在寄件业务层。 + from apps.delivery.services import commit_delivery + return await commit_delivery(access.record, fields) + + +async def abort_access(access): + """取消或初始化失败释放寄件占用,实际残留交由原清理流程重试。""" + if access is not None and access.record is not None: + from apps.delivery.services import abort_upload + await abort_upload(access.record.id) + + +async def completed_upload(access): + """完成响应丢失后允许按原会话取回结果,不再合并、覆盖文件或重复扣次。""" + if access is None or access.record is None or access.record.status != "shared": + return None + share = await FileCodes.filter(id=access.record.share_id).first() + if share is None or await share.is_expired(): + raise HTTPException(410, "该上传的文件已过期") + return {"code": share.code, "name": access.record.filename} diff --git a/apps/base/views.py b/apps/base/views.py index ef54a84b4..e176a372c 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 DeliveryFile from apps.base.models import FileCodes, UploadChunk, PresignUploadSession from apps.base.quota import release_storage, reserve_storage +from apps.base.share_storage import delivery_record, storage_for_share from apps.base.services import ( PRESIGN_SESSION_EXPIRES, FileUploadService, @@ -38,7 +40,7 @@ ) 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, @@ -56,8 +58,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"), @@ -69,13 +72,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(...), @@ -85,7 +89,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) @@ -151,7 +155,7 @@ 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: + elif file_code.expired_count >= 0 or await delivery_record(file_code) is not None: # 有次数限制的文件必须经过下载接口,第三方直链无法阻止重复使用。 download_url = await get_proxy_file_url(file_code.code) else: @@ -193,7 +197,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) @@ -213,18 +216,19 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): ) }, ) + file_storage = await storage_for_share(file_code) return response_from_download(await file_storage.get_file_response(stored_file_of(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( @@ -239,7 +243,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 = { @@ -255,6 +258,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 @@ -287,21 +291,27 @@ 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 计算理论最大上传量 + # 使用文件真实声明大小校验上限,最后一个分片通常小于整片大小。 total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size - max_possible_size = total_chunks * data.chunk_size - if max_possible_size > settings.upload_size: + if data.file_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 DeliveryFile.filter(delivery_id=access.code_id, 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, @@ -309,7 +319,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 DeliveryFile.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: @@ -336,6 +349,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( @@ -353,9 +367,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( @@ -371,13 +386,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: @@ -406,6 +425,9 @@ async def upload_chunk( if chunk_index == 0: validate_header_bytes(chunk_info.file_name, None, chunk_data[:64]) chunk_size = len(chunk_data) + 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: @@ -431,7 +453,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 @@ -458,9 +480,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="上传会话不存在") @@ -468,7 +493,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) @@ -483,9 +508,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: @@ -510,20 +535,24 @@ 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) 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) @@ -557,9 +586,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) @@ -571,6 +600,7 @@ async def presign_upload_init( validate_expire_style(data.expire_style) 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 @@ -579,7 +609,9 @@ 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]() + if delivery_path: + save_path = delivery_path + storage: FileStorageInterface = await upload_storage(access) presigned_url = await storage.generate_presigned_upload_url( save_path, PRESIGN_SESSION_EXPIRES ) @@ -597,6 +629,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 @@ -616,33 +649,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"]) ): """代理模式上传,服务器转存到存储后端""" + result = await completed_upload(access) + if result: + return APIResponse(detail=result) session = await _get_valid_session(upload_id, expected_mode="proxy") - code = await FileUploadService.commit_proxy_upload(session, file) + 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直传后调用获取分享码""" + result = await completed_upload(access) + if result: + return APIResponse(detail=result) session = await _get_valid_session(upload_id, expected_mode="direct") - code = await FileUploadService.confirm_direct_upload(session) + 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: @@ -661,15 +700,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..0c628a1b1 --- /dev/null +++ b/apps/delivery/schemas.py @@ -0,0 +1,71 @@ +"""寄件参数校验,所有路径都相对于已配置的存储根目录。""" + +import re +from datetime import datetime, timezone, timedelta +from pathlib import PurePosixPath + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CreateDeliveryCode(BaseModel): + # 禁止静默接受 owner_id 等越权字段,未来多用户必须由服务端身份决定归属。 + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + name: str = Field(min_length=1, max_length=100) + code: str = Field(default="", max_length=64) + storage_type: str = "local" + target_path: str = Field(min_length=1, max_length=200) + expires_at: datetime + max_uploads: int = Field(default=1, ge=1, le=100000) + + @field_validator("code") + @classmethod + def validate_code(cls, value): + if value and not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", value): + raise ValueError("寄件码须为 8 至 64 位字母、数字、下划线或短横线") + return value + + @field_validator("storage_type") + @classmethod + def validate_storage(cls, value): + if value not in {"local", "s3", "webdav", "onedrive", "opendal"}: + raise ValueError("不支持的存储类型") + return value + + @field_validator("target_path") + @classmethod + def validate_path(cls, value): + # 同时约束 POSIX、Windows 与 URL 语义,避免 WebDAV 二次解码或盘符逃逸。 + parts = value.split("/") + if (PurePosixPath(value).is_absolute() + or any(part in {"", ".", ".."} for part in parts) + or not re.fullmatch(r"[\w ./-]+", value, re.UNICODE) + or any(part.endswith((".", " ")) for part in parts) + or any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?", part) for part in parts)): + raise ValueError("目标目录必须是根目录内的相对路径,如 inbox/project-a") + return value + + @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 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 + + +class DeliveryShareOptions(BaseModel): + """上传选择的过期策略仍受全站白名单和最长保存时间约束。""" + expire_style: str = Field(min_length=1, max_length=20) + expire_value: int = Field(default=1, ge=1, le=1000000) diff --git a/apps/delivery/services.py b/apps/delivery/services.py new file mode 100644 index 000000000..a5af649f9 --- /dev/null +++ b/apps/delivery/services.py @@ -0,0 +1,333 @@ +"""寄件业务:独立权限、数据库次数预占、存储落盘和失败回收。""" + +import asyncio +import hashlib +import hmac +import os +import secrets +import uuid +from datetime import timedelta + +from fastapi import HTTPException +from tortoise.exceptions import IntegrityError +from tortoise.expressions import F +from tortoise.transactions import in_transaction + +from apps.admin.dependencies import create_token, verify_token +from apps.base.file_validation import validate_upload_file +from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue, UploadChunk, PresignUploadSession, StorageReservation +from apps.base.utils import get_expire_info, validate_expire_style +from apps.base.quota import _sql_placeholders, reserve_storage +from apps.delivery.storage import get_storage, validate_storage_config +from core.logger import logger +from core.settings import settings +from core.storage import StoredFile +from core.utils import get_now, sanitize_filename + +TOKEN_TTL = 900 +STALE_SECONDS = 7200 + + +def code_digest(code: str) -> str: + """校验使用带服务端密钥的摘要,与管理员读取原文的用途分开。""" + secret = str(settings.jwt_secret) + if not secret: + raise HTTPException(503, "系统签名密钥尚未初始化") + return hmac.new(secret.encode(), ("delivery-code:" + code).encode(), hashlib.sha256).hexdigest() + + +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("凭证用途错误") + return int(payload["delivery_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, owner_id="admin", 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): + """创建时保存原文,便于管理员后续查看;访客响应仍不提供任何口令列表。""" + validate_storage_config(data.storage_type) + code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16)) + try: + record = await DeliveryCode.create( + code_digest=code_digest(code), code_value=code, name=data.name, owner_id="admin", + storage_type=data.storage_type, target_path=data.target_path, + expires_at=data.expires_at, max_uploads=data.max_uploads, + ) + except IntegrityError: + raise HTTPException(409, "该寄件码已被使用,请设置其他口令") from None + return {"item": await code_summary(record), "code": code} + + +async def code_summary(record): + """仅供已鉴权的后台读取状态和口令原文,不返回摘要或存储密钥。""" + now = await get_now() + state = "active" + if record.deleted: + state = "deleted" + elif not record.enabled: + state = "disabled" + elif record.expires_at <= now: + state = "expired" + elif record.used_count >= record.max_uploads: + state = "exhausted" + return { + "id": record.id, "name": record.name, "storage_type": record.storage_type, + "code": record.code_value, + "target_path": record.target_path, "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_digest=code_digest(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 DeliveryFile.filter(delivery_id=record.id, status="pending", token__startswith="d_").exists(): + raise HTTPException(409, "可上传次数已耗尽或正在使用,请联系管理员") + # 旧码无法离线还原;持有者成功验证时补存其原码,不修改口令或重新生成。 + if record.code_value is None: + await DeliveryCode.filter(id=record.id, code_value__isnull=True).update(code_value=code) + # 凭证仅含寄件 ID,不携带 is_admin、目标路径或下载口令。 + token = create_token({"purpose": "delivery", "delivery_id": record.id}, expires_in=TOKEN_TTL) + return { + "token": token, "expires_in": TOKEN_TTL, "name": record.name, + "remaining": remaining, "expires_at": record.expires_at, + "upload_size": settings.upload_size, "allowed_file_types": settings.allowed_file_types, + "expire_style": settings.expire_style, "max_save_seconds": settings.max_save_seconds, + "enable_chunk": settings.enable_chunk, + } + + +async def reserve_slot(code_id: int): + """数据库原子预占最后一次上传;不以进程内锁代替跨 worker 并发控制。""" + async with in_transaction() as conn: + now = await get_now() + p = _sql_placeholders(2) + count, _ = await conn.execute_query( + f"UPDATE deliverycode SET reserved_count = reserved_count + 1 " + f"WHERE id = {p[0]} AND expires_at > {p[1]} AND owner_id = 'admin' " + "AND enabled = 1 AND deleted = 0 AND used_count + reserved_count < max_uploads", + [code_id, now], + ) + if count != 1: + raise HTTPException(409, "寄件码已失效或没有剩余上传次数") + code = await DeliveryCode.get(id=code_id).using_db(conn) + return await DeliveryFile.create( + delivery_id=code.id, owner_id="admin", token=uuid.uuid4().hex, + file_path=code.target_path, storage_type=code.storage_type, using_db=conn, + ) + + +def stored_file(record): + """显示名保留,磁盘/对象键使用唯一名称,禁止同名覆盖。""" + prefix, suffix = os.path.splitext(record.filename) + return StoredFile(file_path=record.file_path, uuid_file_name=record.stored_name, prefix=prefix, suffix=suffix) + + +async def store_upload(record, file, *, expire_style=None, expire_value=1): + """继承类型与容量限制;只在落盘和记录事务都成功后扣减成功次数。""" + # 传入过期策略即明确请求生成取件码;旧客户端未传策略时仍保持私有收件语义。 + if expire_style is not None: + validate_expire_style(expire_style) + await get_expire_info(expire_value, expire_style) + await validate_upload_file(file) + size = file.size + if size is None: + file.file.seek(0, 2) + size = file.file.tell() + await file.seek(0) + if size > int(settings.upload_size): + raise HTTPException(413, "文件大小超过站点限制") + filename = await normalize_delivery_filename(file.filename) + record.filename = filename + record.stored_name = f"{record.token}_{filename}" + record.size = size + await reserve_storage("delivery:" + record.token, size, STALE_SECONDS) + await record.save(update_fields=["filename", "stored_name", "size", "updated_at"]) + storage = await get_storage(record.storage_type) + # shield 防止本地后台写线程被取消后仍写入已经关闭的临时文件。 + writing = asyncio.create_task(storage.save_file(file.file, f"{record.file_path}/{record.stored_name}", file.content_type)) + try: + await asyncio.shield(writing) + except asyncio.CancelledError: + try: + await writing + except Exception: + logger.warning("被取消的寄件写入失败 id=%s", record.id, exc_info=True) + raise + # 兼容接口仅负责构造分享字段,扣次和关联提交与普通上传共用同一事务。 + share_fields = None + if expire_style is not None: + expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style) + share_fields = dict(code=code, size=size, expired_at=expired_at, + expired_count=expired_count, used_count=used_count) + share = await commit_delivery(record, share_fields) + result = {"name": filename, "size": size, "message": "投递成功"} + if share is not None: + result.update(code=share.code, expired_at=share.expired_at, expired_count=share.expired_count) + return result + + +async def heartbeat(record): + """有效传输定期续租,进程异常退出后遗留记录才会被回收。""" + while True: + await asyncio.sleep(30) + changed = await DeliveryFile.filter(id=record.id, status__in=["pending", "finalizing"]).update(updated_at=await get_now()) + if not changed: + return + # 大文件慢速传输期间维持容量预留,避免 TTL 到期使其他上传超配额。 + await StorageReservation.filter(token__in=reservation_tokens(record.token)).update( + expires_at=await get_now() + timedelta(seconds=STALE_SECONDS) + ) + + +async def abort_upload(record_id: int, *, stale_before=None): + """先释放次数,再保留 cleanup 记录跟踪残留文件,清理成功后释放占用容量。""" + async with in_transaction() as conn: + query = DeliveryFile.filter(id=record_id, status__in=["pending", "finalizing"]) + if stale_before is not None: + query = query.filter(updated_at__lt=stale_before) + changed = await query.using_db(conn).update(status="cleanup", updated_at=await get_now()) + if changed: + record = await DeliveryFile.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 + ) + # 转为 cleanup 后由收件记录计费,统一释放所有上传预留。 + await StorageReservation.filter(token__in=reservation_tokens(record.token)).using_db(conn).delete() + await clean_file(record_id) + + +async def clean_file(record_id): + """实际文件删除成功后物理删除收件记录;失败时保留记录计费并重试。""" + record = await DeliveryFile.filter(id=record_id, status="cleanup").first() + if not record: + return + try: + if record.stored_name: + storage = await get_storage(record.storage_type) + # 复用普通上传的会话时,临时分片也必须按寄件存储回收,失败保留记录重试。 + if record.token.startswith("d_"): + await storage.clean_chunks(record.token, f"{record.file_path}/{record.stored_name}") + await storage.delete_file(stored_file(record)) + if record.token.startswith("d_"): + await UploadChunk.filter(upload_id=record.token).delete() + await PresignUploadSession.filter(upload_id=record.token).delete() + await StorageReservation.filter(token__in=reservation_tokens(record.token)).delete() + # 与普通文件管理一致,清理成功后不保留已删除文件的历史空壳。 + await DeliveryFile.filter(id=record.id, status="cleanup").delete() + except Exception: + logger.warning("寄件文件清理失败,将自动重试 id=%s", record.id, exc_info=True) + + +async def cleanup_once(): + """有限批次回收崩溃残留,避免任务持有大量 ORM 对象。""" + before = await get_now() - timedelta(seconds=STALE_SECONDS) + for record in await DeliveryFile.filter(status__in=["pending", "finalizing"], updated_at__lt=before).limit(100): + await abort_upload(record.id, stale_before=before) + for record in await DeliveryFile.filter(status="cleanup").limit(100): + await clean_file(record.id) + # 分享创建成功后若分片回收失败,继续清理临时分片,不撤销已经生成的取件码。 + tokens = await UploadChunk.filter(chunk_index=-1, upload_id__startswith="d_").limit(100).values_list("upload_id", flat=True) + for record in await DeliveryFile.filter(token__in=tokens, status="shared"): + try: + storage = await get_storage(record.storage_type) + await storage.clean_chunks(record.token, f"{record.file_path}/{record.stored_name}") + await UploadChunk.filter(upload_id=record.token).delete() + except Exception: + logger.warning("寄件分享临时分片清理失败 id=%s", record.id, exc_info=True) + # 旧版本仅在物理清理成功后标记 deleted 并将大小归零,分批移除这些历史空壳。 + deleted_ids = await DeliveryFile.filter(status="deleted", size=0).limit(100).values_list("id", flat=True) + if deleted_ids: + await DeliveryFile.filter(id__in=deleted_ids, status="deleted", size=0).delete() + + +async def cleanup_loop(): + while True: + try: + await cleanup_once() + except Exception: + logger.warning("寄件清理任务异常,下轮继续", exc_info=True) + await asyncio.sleep(60) + + +async def request_file_removal(record_id): + """普通文件管理、寄件管理和过期清理共用撤销流程,容量始终只计算一次。""" + async with in_transaction() as conn: + record = await DeliveryFile.filter(id=record_id).using_db(conn).first() + # 管理员删除与自动过期可能同时触发;已被另一流程清除时视为完成。 + if record is None: + return + if record.status in {"pending", "finalizing"}: + raise HTTPException(409, "文件正在上传,请稍后再试") + if record.share_id is not None: + await FileCodes.filter(id=record.share_id).using_db(conn).delete() + # 元数据清理由原文件服务管理;这里仅清除对应记录,不能触碰其他分享。 + await KeyValue.filter(key=f"admin_file_metadata:{record.share_id}").using_db(conn).delete() + await DeliveryFile.filter(id=record_id, status__in=["stored", "shared"]).using_db(conn).update( + status="cleanup", updated_at=await get_now() + ) + await clean_file(record_id) + + + +def reservation_tokens(token): + """集中定义寄件会话的容量键,续租、提交和取消不会漏掉某一种上传方式。""" + tokens = ["delivery:" + token] + if token.startswith("d_"): + tokens.extend(["chunk:" + token, "presign:" + token]) + return tokens + + +async def commit_delivery(record, share_fields=None): + """统一提交寄件扣次;无分享字段时保留旧客户端的私有收件语义。""" + async with in_transaction() as conn: + changed = await DeliveryFile.filter(id=record.id, status__in=["pending", "finalizing"]).using_db(conn).update( + status="shared" if share_fields is not None else "stored", updated_at=await get_now() + ) + if changed != 1: + raise HTTPException(409, "该上传已完成或已被清理") + changed = await DeliveryCode.filter( + id=record.delivery_id, 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, "寄件码在上传期间失效") + share = None + if share_fields is not None: + fields = dict(share_fields) + # 存储名保证唯一,显示名、文本分享和原取件规则保持不变。 + if "text" not in fields: + fields["prefix"], fields["suffix"] = os.path.splitext(record.filename) + fields["file_path"] = record.file_path + fields["uuid_file_name"] = record.stored_name + share = await FileCodes.create(using_db=conn, **fields) + await DeliveryFile.filter(id=record.id).using_db(conn).update(share_id=share.id, size=share.size) + await StorageReservation.filter(token__in=reservation_tokens(record.token)).using_db(conn).delete() + return share + + + +async def normalize_delivery_filename(file_name): + """新旧上传统一清理显示名,并预留唯一前缀所需的文件系统字节空间。""" + filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1]) + return filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file" diff --git a/apps/delivery/static/admin.html b/apps/delivery/static/admin.html new file mode 100644 index 000000000..625b3d718 --- /dev/null +++ b/apps/delivery/static/admin.html @@ -0,0 +1,48 @@ + + + + + 寄件管理 · 文件驿站 + + + + + +
文件驿站 FileRelay
+
+

管理员工作台

寄件管理

用一枚口令,收齐一份资料。控制有效期、投递次数与收件目录。

+
+

管理员登录

使用现有站点管理员密码。寄件码不能登录此页面。

+
+
+

+ +
+ + diff --git a/apps/delivery/static/admin.js b/apps/delivery/static/admin.js new file mode 100644 index 000000000..b279c4fe9 --- /dev/null +++ b/apps/delivery/static/admin.js @@ -0,0 +1,141 @@ +import {$, api, bytes, date, cell, emptyRow, errorText, message} from './common.js'; + +// 管理凭证只存在当前标签页会话中;不与访客寄件凭证混用。 +const tokenKey = 'filerelay_delivery_admin_token'; +let token = sessionStorage.getItem(tokenKey) || ''; +let page = 1, filePage = 1, selected = null, listSequence = 0, fileSequence = 0; +const stateNames = {active:'可投递', disabled:'已禁用', expired:'已过期', exhausted:'次数耗尽', deleted:'已删除', pending:'上传中', stored:'已收到', cleanup:'等待清理'}; +function authScreen(loggedIn) { + $('workspace').hidden = !loggedIn; $('login-panel').hidden = loggedIn; $('logout').hidden = !loggedIn; + if (!loggedIn) { $('codes').replaceChildren(); $('received-files').replaceChildren(); $('files-panel').hidden = true; $('created').hidden = true; $('created-code').textContent = ''; } +} +function report(error) { + if (error.status === 401) { token = ''; sessionStorage.removeItem(tokenKey); authScreen(false); } + message($('message'), error.message, true); +} +const request = (path, options = {}) => api('/admin/delivery' + path, {...options, token}); +function action(container, label, run, danger = false) { + const button = document.createElement('button'); button.textContent = label; button.className = `quiet${danger ? ' danger' : ''}`; + button.type = 'button'; container.append(button); + button.addEventListener('click', async () => { button.disabled = true; try { await run(); } catch (error) { report(error); } finally { button.disabled = false; } }); +} +function pagination(total, current, previous, next, info) { + const pages = Math.max(1, Math.ceil(total / 20)); $(previous).disabled = current <= 1; $(next).disabled = current >= pages; + $(info).textContent = `${current} / ${pages} 页 · 共 ${total} 条`; +} +async function loadCodes() { + const sequence = ++listSequence; + const result = await request(`/codes?page=${page}&include_deleted=${$('include-deleted').checked}`); + if (sequence !== listSequence) return; + if (!result.items.length && page > 1) { page--; return loadCodes(); } + $('codes').replaceChildren(); + if (!result.items.length) emptyRow($('codes'), 6, '还没有寄件码。创建后即可邀请对方投递。'); + for (const item of result.items) { + const row = $('codes').insertRow(); + cell(row, `${item.name} #${item.id}`); cell(row, `${item.storage_type} · ${item.target_path}`); cell(row, date(item.expires_at)); + const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = stateNames[item.status] || item.status; row.insertCell().append(badge); + cell(row, `${item.used_count} / ${item.reserved_count} / ${item.max_uploads}`); + const actions = row.insertCell(); actions.className = 'actions'; + action(actions, '查看收件', async () => { selected = item; filePage = 1; await loadFiles(); $('files-panel').scrollIntoView({behavior:'smooth', block:'start'}); }); + if (!item.deleted) { + action(actions, item.enabled ? '禁用' : '启用', async () => { await request(`/codes/${item.id}`, {method:'PATCH', json:{enabled:!item.enabled}}); await loadCodes(); }); + action(actions, '删除', async () => { + if (!window.confirm(`删除“${item.name}”的寄件码?会撤销投递权限,但保留已收到的文件。`)) return; + await request(`/codes/${item.id}`, {method:'DELETE'}); message($('message'), '寄件码已删除。勾选“显示已删除”可继续管理收件。'); await loadCodes(); + }, true); + } + } + pagination(result.total, page, 'previous', 'next', 'page-info'); +} +async function download(item) { + // 支持文件系统接口的浏览器直接流式写盘,其他浏览器回退到 Blob 下载。 + let handle; + if ('showSaveFilePicker' in window) { + try { handle = await window.showSaveFilePicker({suggestedName:item.filename}); } + catch (error) { if (error.name === 'AbortError') return; throw error; } + } + const response = await fetch(`/admin/delivery/files/${item.id}/download`, {headers:{Authorization:`Bearer ${token}`}, cache:'no-store'}); + if (!response.ok) { const error = new Error(errorText(await response.json().catch(() => ({})))); error.status=response.status; throw error; } + if (handle) { const writable = await handle.createWritable(); await response.body.pipeTo(writable); return; } + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement('a'); link.href=url; link.download=item.filename; document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +} +async function loadFiles() { + if (!selected) return; + const selection = selected; const sequence = ++fileSequence; + const result = await request(`/codes/${selection.id}/files?page=${filePage}`); + if (sequence !== fileSequence || selected.id !== selection.id) return; + if (!result.items.length && filePage > 1) { filePage--; return loadFiles(); } + $('files-panel').hidden=false; $('files-title').textContent=`${selection.name} · 收到的文件`; + $('received-files').replaceChildren(); + if (!result.items.length) emptyRow($('received-files'), 5, '尚未收到文件。'); + for (const item of result.items) { + const row=$('received-files').insertRow(); cell(row, item.filename || '正在接收'); cell(row, bytes(item.size)); cell(row, date(item.created_at)); cell(row, stateNames[item.status] || item.status); + const actions=row.insertCell(); actions.className='actions'; + if (item.status === 'stored') action(actions, '下载', () => download(item)); + if (item.status !== 'pending') action(actions, '删除文件', async () => { + if (!window.confirm(`确定永久删除文件“${item.filename}”?此操作不能撤销。`)) return; + await request(`/files/${item.id}`, {method:'DELETE'}); await loadFiles(); + }, true); + } + pagination(result.total, filePage, 'files-previous', 'files-next', 'files-page-info'); +} +function eventAction(id, event, handler) { $(id).addEventListener(event, () => Promise.resolve().then(handler).catch(report)); } +$('login-form').addEventListener('submit', async event => { + event.preventDefault(); const button=event.submitter; button.disabled=true; + try { const session=await api('/admin/login', {method:'POST', json:{password:$('password').value}}); token=session.token; sessionStorage.setItem(tokenKey,token); authScreen(true); message($('message'),'登录成功'); await loadCodes(); } + catch(error) { report(error); } + finally { button.disabled=false; $('password').value=''; } +}); +$('create-form').addEventListener('submit', async event => { + event.preventDefault(); const button=event.submitter; button.disabled=true; $('created').hidden=true; + try { + const result=await request('/codes', {method:'POST', json:{name:$('name').value, code:$('new-code').value.trim(), storage_type:$('storage').value, target_path:$('target').value, expires_at:new Date($('expires').value).toISOString(), max_uploads:Number($('maximum').value)}}); + $('created-code').textContent=result.code; $('created').hidden=false; $('copy-message').textContent=''; $('new-code').value=''; + page=1; message($('message'),'寄件码已创建,请保存下方口令并发给投递人。'); await loadCodes(); + } catch(error) { report(error); } finally { button.disabled=false; } +}); +// HTTP 下的兼容复制必须直接在点击回调中触发,避免额外异步调度丢失用户手势。 +$('copy-code').addEventListener('click', copyCreatedCode); +eventAction('logout','click',() => { token=''; sessionStorage.removeItem(tokenKey); authScreen(false); message($('message'),'已退出当前寄件管理会话。'); }); +eventAction('refresh','click',loadCodes); eventAction('include-deleted','change',() => {page=1; return loadCodes();}); +eventAction('previous','click',() => {page=Math.max(1,page-1); return loadCodes();}); eventAction('next','click',() => {page++; return loadCodes();}); +eventAction('refresh-files','click',loadFiles); eventAction('files-previous','click',() => {filePage=Math.max(1,filePage-1); return loadFiles();}); eventAction('files-next','click',() => {filePage++; return loadFiles();}); +// datetime-local 使用本机时区显示,提交时转成带时区的 ISO 日期。 +const nextWeek=new Date(Date.now()+7*86400000); nextWeek.setMinutes(nextWeek.getMinutes()-nextWeek.getTimezoneOffset()); $('expires').value=nextWeek.toISOString().slice(0,16); +authScreen(false); +if (token) { api('/admin/verify',{token}).then(async () => {authScreen(true); await loadCodes();}).catch(report); } + +async function copyCreatedCode() { + const source = $('created-code'); + const text = source.textContent.trim(); + const notice = $('copy-message'); + if (!text) { notice.textContent = '请先创建寄件码。'; return; } + // 新版剪贴板 API 仅在 HTTPS 等安全上下文可用;权限拒绝时继续尝试兼容路径。 + if (window.isSecureContext && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + notice.textContent = '已复制,可发给投递人。'; + return; + } catch { /* 浏览器或嵌入页面可能拒绝权限,保留下方手动复制能力。 */ } + } + let selected = false; + try { + // 直接选中页面上的口令,不创建隐藏输入框,也不复制其他页面内容。 + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(source); + selection.removeAllRanges(); + selection.addRange(range); + selected = selection.toString() === text; + // 旧接口虽已弃用,但仍可为部分 HTTP 浏览器提供降级支持;不能保证所有浏览器允许。 + if (selected && document.execCommand('copy')) { + notice.textContent = '已复制,可发给投递人。'; + return; + } + } catch { /* 禁止脚本复制时仍显示明确的手动操作说明,不误报成功。 */ } + notice.textContent = selected + ? '已选中寄件码,请按 Ctrl+C(Mac 为 ⌘C),或长按选中文字后复制。' + : '请选中上方寄件码,按 Ctrl+C(Mac 为 ⌘C),或长按文字复制。'; +} diff --git a/apps/delivery/static/common.js b/apps/delivery/static/common.js new file mode 100644 index 000000000..f2c7dd414 --- /dev/null +++ b/apps/delivery/static/common.js @@ -0,0 +1,21 @@ +// 所有服务端/文件名文本都通过 textContent 渲染,禁止将不可信内容拼接为 HTML。 +export const $ = (id) => document.getElementById(id); +export function message(node, text, error = false) { node.textContent = text; node.className = `message ${error ? 'error' : 'success'}`; } +export function bytes(size) { if (size < 1024) return `${size} B`; const units = ['KiB','MiB','GiB']; let n = size / 1024, i = 0; while (n >= 1024 && i < 2) { n /= 1024; i++; } return `${n.toFixed(1)} ${units[i]}`; } +export function date(value) { return value ? new Date(value).toLocaleString() : '—'; } +export function errorText(body) { + const detail = body?.detail; + if (Array.isArray(detail)) return detail.map(item => item.msg).join(';'); + return typeof detail === 'string' ? detail : body?.message || '请求失败,请稍后重试'; +} +export async function api(path, {token, json, ...options} = {}) { + const headers = new Headers(options.headers); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (json !== undefined) { headers.set('Content-Type', 'application/json'); options.body = JSON.stringify(json); } + const response = await fetch(path, {...options, headers, cache: 'no-store'}); + const body = await response.json().catch(() => ({})); + if (!response.ok) { const error = new Error(errorText(body)); error.status = response.status; throw error; } + return body.detail; +} +export function cell(row, text) { const node = row.insertCell(); node.textContent = text; return node; } +export function emptyRow(body, count, text) { body.replaceChildren(); const node = body.insertRow().insertCell(); node.colSpan = count; node.className = 'empty'; node.textContent = text; } diff --git a/apps/delivery/static/delivery.css b/apps/delivery/static/delivery.css new file mode 100644 index 000000000..bff84a1ad --- /dev/null +++ b/apps/delivery/static/delivery.css @@ -0,0 +1,5 @@ +/* 新功能保持轻量且独立于上游主题,使用相同的绿色强调色和清晰表单层级。 */ +:root{color-scheme:light;--ink:#1c322a;--muted:#61736b;--green:#175c48;--line:#dbe5df;--bg:#f4f7f3} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 system-ui,-apple-system,"Microsoft YaHei",sans-serif}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button{background:var(--green);color:#fff;border:1px solid var(--green);border-radius:9px;padding:9px 17px;cursor:pointer;white-space:nowrap}button:disabled{opacity:.5;cursor:not-allowed}button.quiet{background:white;color:var(--green);border-color:var(--line)}button.danger{color:#a33128}button:hover:not(:disabled){filter:brightness(.94)}:focus-visible{outline:3px solid #86bba6;outline-offset:3px}[hidden]{display:none!important}.shell{width:min(1160px,calc(100% - 48px));margin:auto}.header{min-height:88px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-bottom:1px solid var(--line)}.brand{font-size:21px;font-weight:750;color:var(--ink);text-decoration:none}.brand span{font-size:13px;font-weight:500;margin-left:8px;color:var(--muted)}nav{display:flex;gap:20px;align-items:center}main{padding:36px 0 60px}.visitor{max-width:780px}.eyebrow{color:var(--green);font-size:12px;letter-spacing:2px;margin:4px 0 8px}h1{font-size:clamp(28px,4vw,38px);line-height:1.3;letter-spacing:-1px;margin:0 0 16px}h2{font-size:19px;margin:0 0 20px}.intro{color:var(--muted);margin:0 0 28px;max-width:680px}.card{background:white;border:1px solid var(--line);border-radius:16px;padding:26px;margin-bottom:22px;box-shadow:0 3px 10px #203a2a03}.row{display:flex;gap:14px;align-items:end}.grow{flex:1}.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px 24px}.full{grid-column:1/-1}label{display:block;font-size:14px;font-weight:600}input:not([type=checkbox]),select{display:block;width:100%;border:1px solid #cbd7d0;border-radius:8px;background:#fff;color:var(--ink);padding:10px 12px;margin-top:7px;min-height:44px}input[type=file]{border:0;background:transparent;padding:8px 0}input[type=checkbox]{accent-color:var(--green)}.muted,.footnote{color:var(--muted);font-size:13px}.footnote{text-align:center}.message{min-height:0;margin:14px 0 0;overflow-wrap:anywhere}.message:empty{display:none}.error{color:#aa3129}.success{color:#176647}.step{display:inline-grid;place-items:center;border-radius:50%;background:#eaf3ee;color:var(--green);width:30px;height:30px;font-size:14px;margin-right:8px}.file-picker{border:1px dashed #9eb5a8;border-radius:12px;padding:22px;background:#f9fbf8}.file-picker span{display:block;font-weight:400;font-size:13px;color:var(--muted)}progress{display:block;width:100%;height:14px;margin:10px 0;accent-color:var(--green)}#progress-box{margin-top:24px}.results{padding:0;list-style:none}.results li{padding:10px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.login{max-width:480px}.login button{margin-top:18px}.section-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px}.section-head h2{margin:0}.inline{display:flex;align-items:center;gap:7px;font-weight:400}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px;text-align:left}th{color:var(--muted);font-weight:500;background:#f8faf7;white-space:nowrap}th,td{padding:13px 12px;border-bottom:1px solid var(--line);vertical-align:top}td{overflow-wrap:anywhere;max-width:280px}.actions{display:flex;gap:7px;flex-wrap:wrap}.actions button{font-size:12px;padding:5px 9px}.badge{display:inline-block;background:#edf4ef;padding:2px 8px;border-radius:6px;white-space:nowrap}.pagination{display:flex;justify-content:flex-end;align-items:center;gap:14px;margin-top:18px;font-size:13px}.notice{padding:18px;background:#edf6ee;border-radius:10px;margin-top:20px}code{font-family:ui-monospace,monospace;font-size:20px;letter-spacing:1px;overflow-wrap:anywhere}.empty{text-align:center;color:var(--muted);padding:28px} +/* 小屏表单按一列排列,表格保留横向滚动而不截断管理操作。 */ +@media(max-width:700px){.shell{width:calc(100% - 28px)}.header{min-height:78px;align-items:flex-start;padding:20px 0;flex-direction:column;gap:10px}nav{flex-wrap:wrap;font-size:13px;gap:14px}main{padding-top:26px}.card{padding:20px 16px}.grid{grid-template-columns:1fr}.row,.section-head{align-items:stretch;flex-direction:column}.section-head .row{flex-direction:row;align-items:center;justify-content:space-between}.pagination{justify-content:center}.brand span{margin-left:4px}} diff --git a/apps/delivery/static/delivery.html b/apps/delivery/static/delivery.html new file mode 100644 index 000000000..343111f03 --- /dev/null +++ b/apps/delivery/static/delivery.html @@ -0,0 +1,38 @@ + + + + + 凭码寄件 · 文件驿站 + + + + + +
文件驿站 FileRelay返回取件首页
+
+

文件投递

把文件,安心交给对方。

+

输入对方给你的寄件码,即可上传资料。无需注册,收到的文件仅由管理员管理。

+
+

1 验证寄件码

+
+ + +
+

+
+ +

寄件码只用于上传。投递成功后如需取回文件,请联系接收方。

+
+ + diff --git a/apps/delivery/static/delivery.js b/apps/delivery/static/delivery.js new file mode 100644 index 000000000..490568fc1 --- /dev/null +++ b/apps/delivery/static/delivery.js @@ -0,0 +1,63 @@ +import {$, api, bytes, date, errorText, message} from './common.js'; + +// 访客凭证只保存在当前页面内存,不进入 URL、Cookie 或浏览器持久化存储。 +let session = null; +let busy = false; +function describe() { $('delivery-info').textContent = `${session.name} · 剩余 ${session.remaining} 次 · 单文件上限 ${bytes(session.upload_size)} · 有效期至 ${date(session.expires_at)}`; } +$('verify-form').addEventListener('submit', async (event) => { + event.preventDefault(); if (busy) return; + $('verify-button').disabled = true; + session = null; $('upload-section').hidden = true; + try { + session = await api('/api/delivery/verify', {method:'POST', json:{code:$('code').value.trim()}}); + describe(); $('upload-section').hidden = false; + message($('verify-message'), '验证通过,可以投递文件。'); + } catch (error) { message($('verify-message'), error.message, true); } + finally { $('verify-button').disabled = false; } +}); +$('files').addEventListener('change', () => { + const files = [...$('files').files]; + $('selection-info').textContent = `已选择 ${files.length} 个文件,共 ${bytes(files.reduce((total,file) => total+file.size,0))}`; +}); +function upload(file) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/delivery/upload'); + xhr.setRequestHeader('Authorization', `Bearer ${session.token}`); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) $('progress').value = event.loaded / event.total * 100; + $('progress-text').textContent = `${file.name} · ${Math.round($('progress').value)}%${$('progress').value >= 100 ? ',正在保存' : ''}`; + }; + xhr.onload = () => { + let data; try { data = JSON.parse(xhr.responseText); } catch { data = {}; } + if (xhr.status >= 200 && xhr.status < 300) resolve(data.detail); + else reject(new Error(errorText(data))); + }; + xhr.onerror = () => reject(new Error('网络连接中断,结果尚未确认。请联系接收方或重新验证剩余次数,勿直接重复投递。')); + const form = new FormData(); form.append('file', file); xhr.send(form); + }); +} +$('upload-form').addEventListener('submit', async (event) => { + event.preventDefault(); if (!session || busy) return; + const files = [...$('files').files]; + if (!files.length) return; + if (files.length > session.remaining) { message($('upload-message'), '所选文件数超过剩余次数,请减少文件或联系管理员。', true); return; } + if (files.some(file => file.size > session.upload_size)) { message($('upload-message'), '所选文件中有文件超过单文件大小限制。', true); return; } + busy = true; $('upload-button').disabled = true; $('verify-button').disabled = true; $('files').disabled = true; + $('progress-box').hidden = false; $('results').replaceChildren(); message($('upload-message'), '正在投递,请保持页面打开。'); + let failed = false; + try { + for (const file of files) { + $('progress').value = 0; + const result = document.createElement('li'); $('results').append(result); + result.textContent = `${file.name} · 正在上传`; + try { await upload(file); session.remaining--; describe(); result.textContent = `${file.name} · 投递成功`; result.className = 'success'; } + catch (error) { result.textContent = `${file.name} · ${error.message}`; result.className = 'error'; failed = true; break; } + } + message($('upload-message'), failed ? '后续文件已停止上传。成功的文件无需重传;请重新选择未成功的文件。' : '全部文件投递成功,接收方可以在后台查看。', failed); + // 每次批次后清空选择,避免再次点击时把已成功文件重复提交。 + $('files').value = ''; $('selection-info').textContent = ''; + } finally { + busy = false; $('upload-button').disabled = false; $('verify-button').disabled = false; $('files').disabled = false; $('progress-box').hidden = true; + } +}); diff --git a/apps/delivery/static/entry.css b/apps/delivery/static/entry.css new file mode 100644 index 000000000..2de9e907d --- /dev/null +++ b/apps/delivery/static/entry.css @@ -0,0 +1,2 @@ +/* 类名前缀隔离上游主题样式;不覆盖其导航和表单组件。 */ +.filerelay-entry{position:fixed;right:20px;bottom:24px;z-index:9999;display:flex;gap:8px;font:14px/1.4 system-ui,sans-serif}.filerelay-entry a{display:block;padding:10px 18px;border-radius:24px;background:#175c48;color:#fff;text-decoration:none;box-shadow:0 2px 8px #0002}.filerelay-entry a[hidden]{display:none}.filerelay-entry a:focus-visible{outline:3px solid #86bba6;outline-offset:3px}@media(max-width:600px){.filerelay-entry{right:12px;bottom:12px}.filerelay-entry a{padding:9px 13px}} diff --git a/apps/delivery/static/entry.js b/apps/delivery/static/entry.js new file mode 100644 index 000000000..710802ba3 --- /dev/null +++ b/apps/delivery/static/entry.js @@ -0,0 +1,9 @@ +// 上游主题仍单独构建;使用小型入口桥接其 hash 路由,后台页面可直接进入寄件管理。 +(() => { + const nav = document.createElement('nav'); + nav.className = 'filerelay-entry'; nav.setAttribute('aria-label', '寄件功能'); + const send = document.createElement('a'); send.href = '/delivery'; send.textContent = '凭码寄件'; nav.append(send); + const manage = document.createElement('a'); manage.href = '/delivery/admin'; manage.textContent = '寄件管理'; nav.append(manage); + const update = () => { manage.hidden = !window.location.hash.startsWith('#/admin'); }; + window.addEventListener('hashchange', update); update(); document.body.append(nav); +})(); diff --git a/apps/delivery/static/logo.svg b/apps/delivery/static/logo.svg new file mode 100644 index 000000000..cf13c3c1a --- /dev/null +++ b/apps/delivery/static/logo.svg @@ -0,0 +1,2 @@ + + diff --git a/apps/delivery/storage.py b/apps/delivery/storage.py new file mode 100644 index 000000000..53a27015d --- /dev/null +++ b/apps/delivery/storage.py @@ -0,0 +1,117 @@ +"""寄件专用存储边界:创建前检查配置,OneDrive 使用精确对象路径和私有下载。""" + +import asyncio +import importlib.util +import shutil +import tempfile +from pathlib import Path, PurePosixPath +from urllib.parse import quote, urlparse + +from fastapi import HTTPException +from starlette.background import BackgroundTask + +from core.settings import settings +from core.storage import OneDriveFileStorage, StoredDownload, storages + + +def validate_storage_config(kind): + """检查本机已有配置和可选依赖,不把缺少配置的后端包装成可用寄件码。""" + required = { + "local": [], + "s3": ["s3_access_key_id", "s3_secret_access_key", "s3_bucket_name"], + "webdav": ["webdav_url"], + "onedrive": ["onedrive_domain", "onedrive_client_id", "onedrive_username", "onedrive_password", "onedrive_root_path"], + "opendal": ["opendal_scheme"], + } + missing = [key for key in required[kind] if not str(getattr(settings, key, "") or "").strip()] + if missing: + raise HTTPException(422, f"请先在站点后台配置 {kind}:" + "、".join(missing)) + if kind in {"s3", "webdav"}: + url = settings.webdav_url if kind == "webdav" else (settings.s3_endpoint_url or f"https://{settings.s3_hostname}") + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise HTTPException(422, f"请先配置有效的 {kind} 服务地址") + modules = {"onedrive": ["msal", "office365"], "opendal": ["opendal"]} + if any(importlib.util.find_spec(module) is None for module in modules.get(kind, [])): + packages = "msal 和 Office365-REST-Python-Client" if kind == "onedrive" else "opendal" + raise HTTPException(422, "服务器缺少可选存储依赖,请先安装 " + packages) + if kind == "opendal": + prefix = "opendal_" + str(settings.opendal_scheme) + "_" + if settings.opendal_scheme == "memory" or not any(key.startswith(prefix) and value for key, value in settings.items()): + raise HTTPException(422, "请先配置 OpenDAL 持久化存储参数,寄件不支持 memory 后端") + + +async def get_storage(kind): + # OneDrive 构造器会请求 Graph,放入线程以免阻塞其他用户的上传与租约心跳。 + if kind == "onedrive": + return await asyncio.to_thread(DeliveryOneDriveStorage) + return storages[kind]() + + +class DeliveryOneDriveStorage(OneDriveFileStorage): + """独立适配已有 SDK,避免旧分享目录约定及匿名分享链接影响私有寄件。""" + + async def save_file(self, stream, save_path, content_type=None): + # SDK 的 upload_file 同时支持小文件和大文件上传会话,调用对象必须是父目录。 + def upload(): + path = PurePosixPath(save_path) + folder = self.root_path + for part in path.parent.parts: + try: + folder = folder.get_by_path(part).get().execute_query() + except self._ClientRequestException as exc: + if exc.code != "itemNotFound": + raise + parent = folder + try: + parent.create_folder(part).execute_query() + except self._ClientRequestException as conflict: + if conflict.code not in {"nameAlreadyExists", "itemAlreadyExists"}: + raise + # SDK 可能对同名目录自动改名;重新按指定路径读取,避免文件落到别处。 + folder = parent.get_by_path(part).get().execute_query() + with tempfile.TemporaryDirectory(prefix="fr-") as temporary: + local = Path(temporary) / path.name + with local.open("wb") as target: + shutil.copyfileobj(stream, target, length=256 * 1024) + folder.upload_file(str(local)).execute_query() + await asyncio.to_thread(upload) + + def _delete(self, save_path): + # 删除完整文件对象,不截去扩展名,也不递归删除存放其他寄件的目标目录。 + try: + self.root_path.get_by_path(str(save_path).replace("\\", "/")).delete_object().execute_query() + except self._ClientRequestException as exc: + if exc.code != "itemNotFound": + raise + + async def get_file_response(self, file_code): + # 通过已认证 Graph 会话下载到临时文件,不创建 anonymous 分享权限。 + temporary = tempfile.TemporaryFile(mode="w+b") + def fetch(): + self.root_path.get_by_path(file_code.get_file_path()).download_session(temporary).execute_query() + size = temporary.tell() + temporary.seek(0) + return size + downloading = asyncio.create_task(asyncio.to_thread(fetch)) + try: + size = await asyncio.shield(downloading) + except BaseException: + # 线程操作结束后再关闭句柄,避免断线导致后台线程写入已关闭文件。 + try: + await downloading + finally: + temporary.close() + raise + async def stream(): + try: + while chunk := await asyncio.to_thread(temporary.read, 256 * 1024): + yield chunk + finally: + temporary.close() + name = file_code.prefix + file_code.suffix + return StoredDownload( + filename=name, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name, safe='')}", "Content-Length": str(size)}, + stream_factory=stream, background=BackgroundTask(temporary.close), + ) diff --git a/apps/delivery/views.py b/apps/delivery/views.py new file mode 100644 index 000000000..0703eb704 --- /dev/null +++ b/apps/delivery/views.py @@ -0,0 +1,218 @@ +"""寄件 HTTP 边界:鉴权先于读取上传体,管理接口不接受寄件凭证。""" + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response +from fastapi.responses import FileResponse, RedirectResponse +from starlette.datastructures import UploadFile +from pydantic import ValidationError + +from apps.admin.dependencies import get_admin_session +from apps.base.dependencies import get_client_ip +from apps.base.models import DeliveryCode, DeliveryFile, FileCodes +from apps.base.pages import theme_has_delivery_ui +from apps.base.services import response_from_download +from apps.base.utils import ip_limit +from apps.delivery import services +from apps.delivery.schemas import CreateDeliveryCode, SetDeliveryEnabled, VerifyDeliveryCode, DeliveryShareOptions +from apps.delivery.storage import get_storage +from core.errors import StorageError +from core.logger import logger +from core.response import APIResponse +from core.settings import settings + +def private_admin_response(response: Response): + # 管理列表现在含口令原文,禁止浏览器或中间代理缓存响应。 + response.headers["Cache-Control"] = "no-store" + + +public_api = APIRouter(prefix="/api/delivery", tags=["寄件投递"]) +admin_api = APIRouter(prefix="/admin/delivery", tags=["寄件管理"], dependencies=[Depends(get_admin_session), Depends(private_admin_response)]) +pages = APIRouter() +STATIC = Path(__file__).parent / "static" + + +@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("/upload") +async def upload(request: Request, authorization: str | None = Header(default=None)): + code_id = services.upload_identity(authorization) + await services.active_code(code_id) + ip = ip_limit["upload"](request) + ip_limit["upload"].add_ip(ip) + record = await services.reserve_slot(code_id) + heartbeat = asyncio.create_task(services.heartbeat(record)) + try: + # 手动读取 multipart,使无权限请求在磁盘缓冲之前被拒绝;流式计算真实请求体上限。 + limit = max(0, int(settings.upload_size)) + 1024 * 1024 + received = 0 + + async def limited_receive(): + nonlocal received + message = await request.receive() + received += len(message.get("body", b"")) + if received > limit: + raise HTTPException(413, "上传请求超过站点大小限制") + return message + + bounded = Request(request.scope, receive=limited_receive) + async with bounded.form(max_files=1, max_fields=2) as form: + file = form.get("file") + if (not isinstance(file, UploadFile) or len(form.multi_items()) != len(form) + or set(form) - {"file", "expire_style", "expire_value"}): + raise HTTPException(422, "请求只能包含一个文件及过期参数") + options = {} + if "expire_style" in form or "expire_value" in form: + try: + options = DeliveryShareOptions.model_validate({ + "expire_style": form.get("expire_style"), "expire_value": form.get("expire_value", 1) + }).model_dump() + except ValidationError: + raise HTTPException(422, "请选择有效的过期方式和正整数期限") from None + result = await services.store_upload(record, file, **options) + return APIResponse(detail=result) + except StorageError: + # 存储错误可能含目标 URL 或远端信息,不向访客返回原始异常。 + logger.warning("寄件存储失败 id=%s", record.id, exc_info=True) + raise HTTPException(503, "文件保存失败,请稍后重试或联系管理员") from None + finally: + heartbeat.cancel() + await asyncio.gather(heartbeat, return_exceptions=True) + # 无论 HTTP 异常、断线或写入失败,都释放未完成的次数并跟踪文件清理。 + await asyncio.shield(services.abort_upload(record.id)) + + +@admin_api.get("/codes") +async def list_codes(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), include_deleted: bool = False): + query = DeliveryCode.filter(owner_id="admin") + if not include_deleted: + query = query.filter(deleted=False) + total = await query.count() + records = await query.order_by("-id").offset((page - 1) * page_size).limit(page_size) + return APIResponse(detail={"items": [await services.code_summary(item) for item in records], "total": total}) + + +@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): + changed = await DeliveryCode.filter(id=code_id, owner_id="admin", deleted=False).update(enabled=data.enabled) + 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, owner_id="admin").update(deleted=True, enabled=False) + if not changed: + raise HTTPException(404, "寄件码不存在") + return APIResponse(detail={"message": "寄件码已删除,已收文件仍保留"}) + + +@admin_api.get("/codes/{code_id}/files") +async def list_files(code_id: int, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), unshared_only: bool = False): + if not await DeliveryCode.filter(id=code_id, owner_id="admin").exists(): + raise HTTPException(404, "寄件码不存在") + query = DeliveryFile.filter(delivery_id=code_id, owner_id="admin").exclude(status="deleted") + # 已生成分享的收件使用文件管理列表;兼容区仅保留私有收件和上传/清理中的记录。 + if unshared_only: + query = query.exclude(status="shared") + total = await query.count() + items = await query.order_by("-id").offset((page - 1) * page_size).limit(page_size).values( + "id", "filename", "size", "status", "storage_type", "created_at", "share_id" + ) + # 使用当前分享信息,原后台修改取件码或有效期后这里同步显示。 + shares = await FileCodes.filter(id__in=[item["share_id"] for item in items if item["share_id"] is not None]) + by_id = {share.id: share for share in shares} + for item in items: + share = by_id.get(item["share_id"]) + item["retrieval_code"] = share.code if share else None + item["expired_at"] = share.expired_at if share else None + item["expired_count"] = share.expired_count if share else None + if share is not None and await share.is_expired(): + item["status"] = "expired" + return APIResponse(detail={"items": items, "total": total}) + + +@admin_api.get("/files/{file_id}/download") +async def download(file_id: int): + record = await DeliveryFile.filter(id=file_id, owner_id="admin", status__in=["stored", "shared"]).first() + if not record: + raise HTTPException(404, "收件文件不存在或尚未完成") + # 文本寄件直接复用普通文本分享,不尝试从存储读取不存在的文件对象。 + if record.share_id is not None: + share = await FileCodes.filter(id=record.share_id).first() + if share is not None and share.text is not None: + return Response(content=share.text, media_type="text/plain", headers={ + "Cache-Control": "no-store", "Content-Disposition": 'attachment; filename="Text.txt"', + "X-Content-Type-Options": "nosniff", + }) + storage = await get_storage(record.storage_type) + response = response_from_download(await storage.get_file_response(services.stored_file(record))) + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + +@admin_api.delete("/files/{file_id}") +async def delete_file(file_id: int): + record = await DeliveryFile.filter(id=file_id, owner_id="admin").first() + if not record or record.status == "deleted": + raise HTTPException(404, "收件文件不存在") + if record.status in {"pending", "finalizing"}: + raise HTTPException(409, "该文件正在上传,请先禁用寄件码并等待上传结束") + await services.request_file_removal(file_id) + # 文件和收件记录均删除才算完成;清理失败时记录仍在,后台会继续重试。 + if await DeliveryFile.filter(id=file_id).exists(): + raise HTTPException(503, "存储暂时不可用,已排队自动重试删除") + return APIResponse(detail={"message": "文件已删除"}) + + +def page_response(filename): + """页面无外部依赖;禁缓存和同源 CSP 防止口令、管理员凭证泄露。""" + return FileResponse(STATIC / filename, headers={ + "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + }) + + +@pages.get("/delivery", include_in_schema=False) +@pages.get("/delivery/", include_in_schema=False) +async def delivery_page(): + # 旧地址继续可用,安装原生寄件主题后跳转至其 Vue 路由。 + if theme_has_delivery_ui(): + return RedirectResponse('/#/delivery', status_code=307) + return page_response("delivery.html") + + +@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 page_response("admin.html") + + +@pages.get("/delivery-assets/{filename}", include_in_schema=False) +async def asset(filename: str): + if filename not in {"delivery.css", "delivery.js", "admin.js", "common.js", "entry.js", "entry.css", "logo.svg"}: + raise HTTPException(404, "资源不存在") + return FileResponse(STATIC / filename, headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"}) diff --git a/core/storage.py b/core/storage.py index c5ab29066..2be47309e 100644 --- a/core/storage.py +++ b/core/storage.py @@ -206,9 +206,8 @@ async def save_file( filename = await sanitize_filename(path_obj.name) # 构建安全的完整保存路径 safe_save_path = self._resolve_safe_path(f"{directory}/{filename}" if directory not in {"", "."} else filename) - # 确保目录存在 - if not safe_save_path.parent.exists(): - safe_save_path.parent.mkdir(parents=True) + # 多个寄件可同时写入同一目标目录,幂等创建避免“先检查再创建”的并发冲突。 + safe_save_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(self._save, stream, safe_save_path) async def delete_file(self, file_code: StoredFile): 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..3e91a1420 --- /dev/null +++ b/docs/guide/delivery.md @@ -0,0 +1,89 @@ +# 寄件码 + +寄件码是管理员发放的受限上传授权。关闭游客自由上传后,访客仍可凭寄件码进入上传页,选择文件过期方式;上传成功后获得独立的普通取件码,接收者在首页取件,无需进入管理后台。寄件码本身不授予管理员或下载权限。 + +## 使用方法 + +1. 访问 `/delivery/admin`,用本站管理员密码登录。新版 2024 主题会进入原后台侧栏的“寄件管理”,复用管理员会话;未更新的主题仍进入独立管理页。 +2. 填写用途名称、有效期、最大文件数、存储类型和目标目录,创建寄件码。口令留空时自动生成 16 位随机口令;自定义时为 8 至 64 位字母、数字、下划线或短横线,区分大小写。 +3. 复制创建结果中的口令,也可以随时在 2024 主题管理列表的“寄件码”列查看和复制。 +4. 把站点的 `/delivery` 地址和口令发给投递人。 +5. 访客验证后选择文件及过期时间/领取次数,上传成功后复制取件码或打开取件链接。接收者使用首页取件流程下载。 +6. 新上传会同时出现在普通文件管理与对应寄件码的收件列表。“查看收件”复用文件管理列表与详情、下载、编辑和删除操作,分页只统计当前寄件码的关联分享;旧私有收件及处理中记录单独保留。 + +关闭原后台的“允许游客上传”不会影响有效寄件码。无需为了临时收件重新开放自由上传。 + +## 次数与文件 + +- 验证寄件码后直接使用普通发送页:支持文件、文本、拖放与粘贴、多文件打包、发件记录。每成功创建一份分享扣一次;多文件与普通发送一致先打成 ZIP,整个压缩包扣一次。 +- 分片开关沿用站点设置:开启时使用普通分片初始化、上传、续传和合并接口;关闭时复用普通预签名/代理上传。每一步都携带寄件凭证并校验会话归属,其他寄件码、游客或管理员令牌不能冒用该上传会话。 +- 服务器先原子预占次数,成功后转为已使用;失败不扣成功次数。列表的“占用”表示尚未结束的上传。 +- 寄件码过期、禁用、删除时不能新上传;在最终保存确认之前失效的上传也会失败并进行清理。 +- 同名文件保留各自内容,实际存储名增加唯一前缀,后台下载时仍使用原显示名。 +- 网络中断时可能出现服务器已保存但客户端没有收到结果,先查看后台收件或重新校验剩余次数,不自动重传。 +- 寄件码有效期只限制上传授权;文件有效期由上传时的选择决定,并受全站上限和过期方式白名单约束。 +- 停用或删除寄件码会撤销后续上传权限,但不会撤销已经生成的取件码。文件过期、次数耗尽或管理员删除文件时,取件码失效,文件进入清理。 +- 删除文件不恢复已经用过的上传次数。两个管理入口及自动过期任务使用相同的关联清理流程,失败残留持续计入容量,稍后自动重试。 +- 文件到期或领取次数耗尽后,沿用普通文件管理每 10 分钟运行的过期清理任务;实际文件清理成功后物理删除收件记录,失败时保留记录重试。历史已清理且大小归零的收件空记录由寄件清理任务分批移除。 +- “显示已撤销的寄件码”仅用于查看已撤销的上传授权及其仍有效的收件,不是文件回收站;寄件码仍保留撤销标记,以防旧授权重新生效。 +- 用户已确认:此前的私有收件保持私有,不自动生成取件码。旧客户端若未提交过期参数,也沿用旧私有投递语义;新 2024 页面始终显式提交分享过期策略。 +- 文件大小和类型沿用站点设置;已收文件和清理中的残留文件计入全站容量限制。 + +## 存储配置 + +支持现有适配层中的 `local`、`webdav`、`s3`、`onedrive`、`opendal`。创建时检查所选后端的必要配置和可选依赖,缺失时拒绝发码;这不替代远端连通性或凭据有效性检查。收件记录保存存储类型,后续切换默认存储不会把其改成另一种后端。 + +OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截去扩展名;管理员下载通过认证的 Graph 会话流式写入临时文件,不创建匿名分享链接。大文件上传使用 SDK 的上传会话能力。OpenDAL 必须配置持久化后端,不接受每次实例化都会丢失内容的 memory 存储。 + +目标是对应存储根目录下的相对路径,例如 `inbox/project-a`。本地文件实际保存在 `data/inbox/project-a`,WebDAV/云存储使用其配置根目录或对象键前缀。禁止绝对路径、`..`、URL 转义路径及 Windows 保留设备名。访客不能指定目标路径,也不会收到真实路径。 + +存储凭据不复制到寄件记录;如果变更同一后端的账号、桶名、根目录或服务地址,需先迁移旧文件,否则旧记录可能无法下载。远端桶和目录应保持私有,应用中的管理员权限无法覆盖第三方自行开放的公开访问设置。 + +本次没有新增账号体系。`owner_id` 固定为服务端的管理员身份,客户端不能指定;将来加入子用户时需要同时补充其认证、归属及目录隔离授权。 + +## API + +所有路径都相对于站点根地址,JSON 响应内容位于 `detail`。 + +| 方法与路径 | 权限 | 用途 | +| --- | --- | --- | +| POST `/api/delivery/verify` | 寄件口令 | JSON `{code}`,获得短期上传凭证 | +| POST `/api/delivery/upload` | 寄件 Bearer Token | multipart 包含一个 `file`、`expire_style`、`expire_value`,成功返回 `code` 取件码 | +| GET `/admin/delivery/codes` | 管理员 | 分页列表,支持 `include_deleted` | +| POST `/admin/delivery/codes` | 管理员 | 创建口令 | +| PATCH `/admin/delivery/codes/{id}` | 管理员 | JSON `{enabled}` 启用/禁用 | +| DELETE `/admin/delivery/codes/{id}` | 管理员 | 撤销口令,保留收件 | +| GET `/admin/delivery/codes/{id}/files` | 管理员 | 分页收件列表 | +| GET `/admin/delivery/files/{id}/download` | 管理员 | 下载私有收件 | +| DELETE `/admin/delivery/files/{id}` | 管理员 | 删除收件文件 | + +创建参数包括 `name`、可选的 `code`、`storage_type`、`target_path`、带时区的 `expires_at`、`max_uploads`。省略口令时由服务端生成。分页使用 `page`、`page_size`,默认 20 条、上限 100 条。 + +寄件凭证有效期 15 分钟,只能用于该寄件码上传。每次上传仍检查口令状态。口令摘要使用站点签名密钥,轮换 `jwt_secret` 会同时使原寄件码失效,轮换前需计划重新发码。 + +管理员列表通过 `code` 字段返回已保存的口令原文,响应禁止缓存,访客接口不会返回该字段。迁移 009 前的旧记录只有不可逆摘要,显示“旧寄件码未保存原文”,不会自动换码。持有者下次成功验证旧码后自动补存原文;没有原码且无人能验证时,不能直接还原,只能由管理员另建寄件码。 + +## 部署和恢复 + +安装后端依赖并准备对应主题的构建产物后,在仓库目录启动服务,首次访问时完成管理员初始化: + +```powershell +# 仅监听本机,开发预览不开放到公网。 +python -m uvicorn main:app --host 127.0.0.1 --port 12345 +``` + +初始化入口为 `http://127.0.0.1:12345/setup`,寄件管理为 `http://127.0.0.1:12345/delivery/admin`。 + +- 代码启动时自动执行 `migrations_008.py`,增加 `deliverycode` 和 `deliveryfile` 两张表,保留原分享数据。 +- `migrations_009.py` 为寄件码增加可空的 `code_value` 原文字段,保留旧摘要和已分发口令的有效性。数据库及备份包含寄件码原文,应继续使用原有权限保护。 +- `migrations_010.py` 增加 `deliveryfile.share_id` 关联普通分享。旧行保持 NULL;新分享容量由 FileCodes 计算,私有收件及清理残留另计,避免同一文件重复占用配额。 +- 2024 原生寄件页面在独立前端仓库,构建后更新 `themes/2024`。通过构建标记识别新页面,旧 `/delivery` 和 `/delivery/admin` 分别跳转到 `/#/delivery` 与 `/#/admin/delivery`。 +- `apps/delivery/static` 保留旧主题回退页面。2023 主题本次不开发、不重新构建;未包含原生寄件的主题继续使用该回退。 +- 原主题仍由原 Docker 构建流程生成。纯源码开发时,寄件页可独立使用;原取件页需提供 `themes/2024` 或 `themes/2023` 构建产物。 +- 新清理任务每分钟检查一次;心跳停止超过两小时的上传视为异常遗留,释放次数并清理文件。删除失败的记录保留为“等待清理”,继续计入容量并自动重试。 +- 已存文件的后台下载优先使用浏览器文件系统接口流式保存;不支持的浏览器回退为 Blob,大文件下载的内存占用较高。 +- SQLite 数据库与上传文件需要一起备份。降级前保留完整数据库;旧版不认识寄件表和寄件容量规则,不能把仅回退代码视为完整回滚。 + +新版 2024 页面直接复用普通上传服务:`/share/text/`、`/share/file/`、`/chunk/upload/*`、`/presign/upload/*` 接受有效寄件 Bearer Token。寄件授权只改变权限、存储目录与次数归属,生成的取件码、过期策略和下载流程仍为普通分享。分片会话跨请求持久化,重新验证同一寄件码并选择同一文件可续传;令牌过期须重新验证。取消或空闲超过两小时的寄件上传释放次数并清理残留,清理失败保留记录重试。 + +旧 `/api/delivery/upload` 保留为旧客户端兼容接口;未传过期参数的旧私有收件语义不变。新页面不再调用这一独立上传实现。 diff --git a/main.py b/main.py index 0127b270b..3e9f12ea2 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.delivery.services 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 93daea4b8..f9a3095a8 100644 --- a/readme.md +++ b/readme.md @@ -21,7 +21,15 @@ -## 一条命令开始 +## 寄件码 + +本仓库在 FileCodeBox 基础上增加 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码投递到指定目录;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 + +- 访客投递:`/delivery` +- 寄件管理:`/delivery/admin`(使用本站管理员密码) +- 后端在本仓库,2024 原生寄件页面在独立前端仓库;启动时自动执行迁移 008–010。旧主题保留独立页面回退。使用包含前后端改动的自建程序;下方上游预构建镜像不包含此扩展。 + +## 上游镜像快速开始 ```bash docker run -d --restart unless-stopped \ diff --git a/tests/test_issue_476_theme_assets.py b/tests/test_issue_476_theme_assets.py index c6facf72f..d55fe3975 100644 --- a/tests/test_issue_476_theme_assets.py +++ b/tests/test_issue_476_theme_assets.py @@ -32,8 +32,9 @@ def test_resolves_assets_from_current_theme(self): "assets", self.get_theme_index_asset("themes/2024") ) - self.assertIn("themes/2023/assets", str(theme_2023_asset)) - self.assertIn("themes/2024/assets", str(theme_2024_asset)) + # 路径断言统一为 POSIX 表示,避免 Windows 反斜线造成环境性误报。 + self.assertIn("themes/2023/assets", theme_2023_asset.as_posix()) + self.assertIn("themes/2024/assets", theme_2024_asset.as_posix()) def test_rejects_theme_asset_path_traversal(self): settings.themes_select = "themes/2024" diff --git a/tests/test_security_gaps.py b/tests/test_security_gaps.py index 96b0df5b1..2eb9e378b 100644 --- a/tests/test_security_gaps.py +++ b/tests/test_security_gaps.py @@ -88,9 +88,10 @@ def test_successful_login_does_not_consume_failure_quota(self): class DockerRuntimeUserTests(unittest.TestCase): def test_dockerfile_defaults_to_root_for_volume_compatibility(self): # 默认 root:兼容已有 data 卷权限;如需非 root 可由编排层自行指定 user - text = Path("Dockerfile").read_text() + # 源文件采用 UTF-8,不依赖 Windows 的 GBK 默认编码。 + text = Path("Dockerfile").read_text(encoding="utf-8") self.assertNotIn("USER appuser", text) - compose = Path("docker-compose.yml").read_text() + compose = Path("docker-compose.yml").read_text(encoding="utf-8") self.assertNotIn('user: "1000:1000"', compose) From 8a2d86ef352894e7be07d014e30211ddf77b011d Mon Sep 17 00:00:00 2001 From: dawnStamp <310845922@qq.com> Date: Tue, 15 Sep 2026 21:51:09 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(=E5=AF=84=E4=BB=B6):=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E6=94=B6=E8=80=97=E5=B0=BD=E5=AF=84=E4=BB=B6?= =?UTF-8?q?=E7=A0=81=E5=B9=B6=E4=BF=9D=E7=95=99=E5=B7=B2=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 物理删除寄件码并移除历史撤销筛选,清理旧撤销记录。保留上传完成重试与普通取件能力,同步旧主题和使用说明。 --- apps/base/upload_access.py | 13 +++++++++---- apps/delivery/services.py | 15 ++++++++++++++- apps/delivery/static/admin.html | 4 ++-- apps/delivery/static/admin.js | 7 ++++--- apps/delivery/views.py | 11 +++++------ docs/guide/delivery.md | 6 +++--- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/base/upload_access.py b/apps/base/upload_access.py index 4030b2d92..c1d716d22 100644 --- a/apps/base/upload_access.py +++ b/apps/base/upload_access.py @@ -32,7 +32,6 @@ async def authorize_upload(request: Request, authorization: str | None = Header( raise HTTPException(401, "上传凭证无效或已过期") from None if payload.get("purpose") == "delivery" and not payload.get("is_admin"): access.code_id = int(payload["delivery_id"]) - await active_code(access.code_id) if access.code_id is None: await share_required_login(authorization) @@ -48,11 +47,17 @@ async def authorize_upload(request: Request, authorization: str | None = Header( elif access.code_id is not None: raise HTTPException(404, "上传会话不属于该寄件码") + completion_request = ( + "/complete/" in request.url.path or "/confirm/" in request.url.path or "/proxy/" in request.url.path + ) + # 耗尽口令已被回收时,原凭证只可取回自己已完成会话的结果,不能启动或续传文件。 + completed_retry = completion_request and access.record is not None and access.record.status == "shared" + if access.code_id is not None and not completed_retry: + await active_code(access.code_id) + # 完成阶段原子抢占,避免并发合并或代理上传覆盖同一个已发布文件。 finalizing = False - if access.record is not None and access.record.status == "pending" and ( - "/complete/" in request.url.path or "/confirm/" in request.url.path or "/proxy/" in request.url.path - ): + if access.record is not None and access.record.status == "pending" and completion_request: changed = await DeliveryFile.filter(id=access.record.id, status="pending").update(status="finalizing", updated_at=await get_now()) if changed != 1: raise HTTPException(409, "上传正在完成,请稍后重试") diff --git a/apps/delivery/services.py b/apps/delivery/services.py index a5af649f9..7e9ab329f 100644 --- a/apps/delivery/services.py +++ b/apps/delivery/services.py @@ -10,7 +10,7 @@ from fastapi import HTTPException from tortoise.exceptions import IntegrityError -from tortoise.expressions import F +from tortoise.expressions import F, Q from tortoise.transactions import in_transaction from apps.admin.dependencies import create_token, verify_token @@ -241,6 +241,10 @@ async def clean_file(record_id): async def cleanup_once(): """有限批次回收崩溃残留,避免任务持有大量 ORM 对象。""" + # 兼容清理旧撤销记录及历史耗尽口令,不删除关联的分享或实际文件。 + code_ids = await recyclable_codes().limit(100).values_list("id", flat=True) + if code_ids: + await recyclable_codes().filter(id__in=code_ids).delete() before = await get_now() - timedelta(seconds=STALE_SECONDS) for record in await DeliveryFile.filter(status__in=["pending", "finalizing"], updated_at__lt=before).limit(100): await abort_upload(record.id, stale_before=before) @@ -323,6 +327,8 @@ async def commit_delivery(record, share_fields=None): share = await FileCodes.create(using_db=conn, **fields) await DeliveryFile.filter(id=record.id).using_db(conn).update(share_id=share.id, size=share.size) await StorageReservation.filter(token__in=reservation_tokens(record.token)).using_db(conn).delete() + # 最后一次成功上传与口令回收同事务提交;尚有额度或上传占用时保留。 + await recyclable_codes().filter(id=record.delivery_id).using_db(conn).delete() return share @@ -331,3 +337,10 @@ async def normalize_delivery_filename(file_name): """新旧上传统一清理显示名,并预留唯一前缀所需的文件系统字节空间。""" filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1]) return filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file" + + +def recyclable_codes(): + """历史撤销口令与无上传占用的耗尽口令可回收,不清理仍可使用的多次口令。""" + return DeliveryCode.filter(owner_id="admin").filter( + Q(deleted=True) | Q(reserved_count=0, used_count__gte=F("max_uploads")) + ) diff --git a/apps/delivery/static/admin.html b/apps/delivery/static/admin.html index 625b3d718..023e6c0c9 100644 --- a/apps/delivery/static/admin.html +++ b/apps/delivery/static/admin.html @@ -32,13 +32,13 @@

创建寄件码

-

寄件码列表

+

寄件码列表

用途 / 编号目标存储有效期状态成功 / 占用 / 总数操作
diff --git a/apps/delivery/static/admin.js b/apps/delivery/static/admin.js index b279c4fe9..cd51427e4 100644 --- a/apps/delivery/static/admin.js +++ b/apps/delivery/static/admin.js @@ -25,7 +25,8 @@ function pagination(total, current, previous, next, info) { } async function loadCodes() { const sequence = ++listSequence; - const result = await request(`/codes?page=${page}&include_deleted=${$('include-deleted').checked}`); + // 已删除寄件码不再作为可筛选的历史记录保留。 + const result = await request(`/codes?page=${page}`); if (sequence !== listSequence) return; if (!result.items.length && page > 1) { page--; return loadCodes(); } $('codes').replaceChildren(); @@ -41,7 +42,7 @@ async function loadCodes() { action(actions, item.enabled ? '禁用' : '启用', async () => { await request(`/codes/${item.id}`, {method:'PATCH', json:{enabled:!item.enabled}}); await loadCodes(); }); action(actions, '删除', async () => { if (!window.confirm(`删除“${item.name}”的寄件码?会撤销投递权限,但保留已收到的文件。`)) return; - await request(`/codes/${item.id}`, {method:'DELETE'}); message($('message'), '寄件码已删除。勾选“显示已删除”可继续管理收件。'); await loadCodes(); + await request(`/codes/${item.id}`, {method:'DELETE'}); message($('message'), '寄件码已永久删除,已生成的取件码仍可正常使用。'); await loadCodes(); }, true); } } @@ -99,7 +100,7 @@ $('create-form').addEventListener('submit', async event => { // HTTP 下的兼容复制必须直接在点击回调中触发,避免额外异步调度丢失用户手势。 $('copy-code').addEventListener('click', copyCreatedCode); eventAction('logout','click',() => { token=''; sessionStorage.removeItem(tokenKey); authScreen(false); message($('message'),'已退出当前寄件管理会话。'); }); -eventAction('refresh','click',loadCodes); eventAction('include-deleted','change',() => {page=1; return loadCodes();}); +eventAction('refresh','click',loadCodes); eventAction('previous','click',() => {page=Math.max(1,page-1); return loadCodes();}); eventAction('next','click',() => {page++; return loadCodes();}); eventAction('refresh-files','click',loadFiles); eventAction('files-previous','click',() => {filePage=Math.max(1,filePage-1); return loadFiles();}); eventAction('files-next','click',() => {filePage++; return loadFiles();}); // datetime-local 使用本机时区显示,提交时转成带时区的 ISO 日期。 diff --git a/apps/delivery/views.py b/apps/delivery/views.py index 0703eb704..1d2ac29ad 100644 --- a/apps/delivery/views.py +++ b/apps/delivery/views.py @@ -96,10 +96,9 @@ async def limited_receive(): @admin_api.get("/codes") -async def list_codes(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), include_deleted: bool = False): - query = DeliveryCode.filter(owner_id="admin") - if not include_deleted: - query = query.filter(deleted=False) +async def list_codes(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)): + # 旧撤销标记仅用于迁移清理,不再提供已删除寄件码的回收视图。 + query = DeliveryCode.filter(owner_id="admin", deleted=False) total = await query.count() records = await query.order_by("-id").offset((page - 1) * page_size).limit(page_size) return APIResponse(detail={"items": [await services.code_summary(item) for item in records], "total": total}) @@ -120,8 +119,8 @@ async def toggle(code_id: int, data: SetDeliveryEnabled): @admin_api.delete("/codes/{code_id}") async def delete_code(code_id: int): - # 撤销口令不删除收到的资料;已删口令可在回收视图中查看对应文件。 - changed = await DeliveryCode.filter(id=code_id, owner_id="admin").update(deleted=True, enabled=False) + # 寄件码物理删除;收件记录保留存储映射,已生成的普通取件码继续独立有效。 + changed = await DeliveryCode.filter(id=code_id, owner_id="admin").delete() if not changed: raise HTTPException(404, "寄件码不存在") return APIResponse(detail={"message": "寄件码已删除,已收文件仍保留"}) diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md index 3e91a1420..2491e8876 100644 --- a/docs/guide/delivery.md +++ b/docs/guide/delivery.md @@ -25,7 +25,7 @@ - 停用或删除寄件码会撤销后续上传权限,但不会撤销已经生成的取件码。文件过期、次数耗尽或管理员删除文件时,取件码失效,文件进入清理。 - 删除文件不恢复已经用过的上传次数。两个管理入口及自动过期任务使用相同的关联清理流程,失败残留持续计入容量,稍后自动重试。 - 文件到期或领取次数耗尽后,沿用普通文件管理每 10 分钟运行的过期清理任务;实际文件清理成功后物理删除收件记录,失败时保留记录重试。历史已清理且大小归零的收件空记录由寄件清理任务分批移除。 -- “显示已撤销的寄件码”仅用于查看已撤销的上传授权及其仍有效的收件,不是文件回收站;寄件码仍保留撤销标记,以防旧授权重新生效。 +- 寄件码手动删除时物理删除记录;上传成功且额度耗尽、没有其他上传占用时自动清除寄件码。剩余额度仍可用的多次寄件码继续保留。已生成的普通取件码及文件不会随寄件码删除,仍按自己的期限或领取次数清理。旧撤销记录由清理任务分批回收,不再提供历史筛选入口。 - 用户已确认:此前的私有收件保持私有,不自动生成取件码。旧客户端若未提交过期参数,也沿用旧私有投递语义;新 2024 页面始终显式提交分享过期策略。 - 文件大小和类型沿用站点设置;已收文件和清理中的残留文件计入全站容量限制。 @@ -49,10 +49,10 @@ OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截 | --- | --- | --- | | POST `/api/delivery/verify` | 寄件口令 | JSON `{code}`,获得短期上传凭证 | | POST `/api/delivery/upload` | 寄件 Bearer Token | multipart 包含一个 `file`、`expire_style`、`expire_value`,成功返回 `code` 取件码 | -| GET `/admin/delivery/codes` | 管理员 | 分页列表,支持 `include_deleted` | +| GET `/admin/delivery/codes` | 管理员 | 分页列出当前寄件码 | | POST `/admin/delivery/codes` | 管理员 | 创建口令 | | PATCH `/admin/delivery/codes/{id}` | 管理员 | JSON `{enabled}` 启用/禁用 | -| DELETE `/admin/delivery/codes/{id}` | 管理员 | 撤销口令,保留收件 | +| DELETE `/admin/delivery/codes/{id}` | 管理员 | 物理删除口令,保留已生成的分享 | | GET `/admin/delivery/codes/{id}/files` | 管理员 | 分页收件列表 | | GET `/admin/delivery/files/{id}/download` | 管理员 | 下载私有收件 | | DELETE `/admin/delivery/files/{id}` | 管理员 | 删除收件文件 | From 691ef3cd65733e5be69876ccc2ff147ecc0fbe39 Mon Sep 17 00:00:00 2001 From: dawnStamp <310845922@qq.com> Date: Wed, 16 Sep 2026 23:41:18 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(=E9=85=8D=E7=BD=AE):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D2023=E4=B8=BB=E9=A2=98=E8=AE=BE=E7=BD=AE=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=85=BC=E5=AE=B9=E4=B8=8E=E9=85=8D=E7=BD=AE=E4=BF=9D?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/services.py | 35 ++++++++++++++++++++++++++++++++--- apps/admin/views.py | 7 ++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/admin/services.py b/apps/admin/services.py index 1c73585ea..327daf151 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -1496,6 +1496,20 @@ async def share_local_file(self, item): class ConfigService: + # 2023 设置页仍使用迁移前的字段名;仅在旧管理接口边界转换,存储保持 snake_case。 + LEGACY_CONFIG_FIELDS = { + "errorCount": "error_count", + "errorMinute": "error_minute", + "expireStyle": "expire_style", + "openUpload": "open_upload", + "robotsText": "robots_text", + "showAdminAddr": "show_admin_addr", + "themesChoices": "themes_choices", + "themesSelect": "themes_select", + "uploadCount": "upload_count", + "uploadMinute": "upload_minute", + "uploadSize": "upload_size", + } INT_FIELDS = { "admin_session_expire", "enable_chunk", @@ -1519,20 +1533,35 @@ class ConfigService: } FLOAT_FIELDS = {"opacity"} - def get_config(self): + def get_config(self, *, legacy: bool = False): config = dict(settings.items()) config["admin_token"] = "" for key in INTERNAL_CONFIG_KEYS: config.pop(key, None) + if legacy: + # 每个配置只返回一种键名,避免旧页面整表提交时携带两个互相冲突的值。 + for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items(): + config[old_key] = config.pop(current_key) return config async def update_config(self, data: dict): current_config = dict(settings.items()) next_config = dict(current_config) + # 必须在白名单过滤前转换,否则旧主题保存成功但主题、上传限制等实际未更新。 + normalized_data = dict(data) + for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items(): + if old_key not in normalized_data: + continue + value = normalized_data.pop(old_key) + if current_key in normalized_data and normalized_data[current_key] != value: + raise HTTPException(status_code=400, detail=f"{current_key} 配置值冲突") + normalized_data[current_key] = value update_data = { key: value - for key, value in data.items() - if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS + for key, value in normalized_data.items() + if key in settings.default_config + and key not in INTERNAL_CONFIG_KEYS + and key != "themes_choices" # 主题清单由程序维护,兼容旧字段时也不能允许客户端改写。 } admin_token = update_data.get("admin_token") diff --git a/apps/admin/views.py b/apps/admin/views.py index 45c9c358b..0a9485d6b 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -527,7 +527,10 @@ async def file_view_presets_delete_post( async def get_config( config_service: ConfigService = Depends(get_config_service), ): - return APIResponse(detail=config_service.get_config()) + # 与实际返回的主题保持一致;2024 和公共 API 继续使用标准字段,2023 设置页使用旧字段。 + from apps.base.pages import resolve_theme_root + + return APIResponse(detail=config_service.get_config(legacy=resolve_theme_root().name == "2023")) @admin_api.patch("/config/update") @@ -537,6 +540,8 @@ async def update_config( file_service: FileService = Depends(get_file_service), ): data.pop("themes_choices", None) + # 旧主题会整表提交配置,同样剔除只读主题列表。 + data.pop("themesChoices", None) await config_service.update_config(data) await file_service.record_admin_activity( action="config.update", From 6d94d410a077d07586c1068499ccc7f397b92665 Mon Sep 17 00:00:00 2001 From: dawnStamp <310845922@qq.com> Date: Sat, 19 Sep 2026 12:23:37 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(=E5=AF=84=E4=BB=B6=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=92=8C=E7=A0=81=E7=BC=96=E8=BE=91=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AD=98=E5=82=A8=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增寄件码搜索筛选、备注标签、批量操作与配置编辑,将新建和修改口令上限设为32位。 统一系统与寄件路径校验,新增011和012迁移,保存文件及上传会话的实际存储类型并完善下载清理逻辑。 --- apps/admin/services.py | 60 ++++--- apps/base/metadata.py | 28 +++ apps/base/migrations/migrations_011.py | 36 ++++ apps/base/migrations/migrations_012.py | 16 ++ apps/base/models.py | 14 +- apps/base/services.py | 65 +++++-- apps/base/share_storage.py | 11 ++ apps/base/tasks.py | 17 +- apps/base/upload_access.py | 11 +- apps/base/utils.py | 7 +- apps/base/views.py | 43 ++++- apps/delivery/schemas.py | 157 ++++++++++++++--- apps/delivery/services.py | 164 +++++++++++++++++- apps/delivery/static/admin.html | 12 +- apps/delivery/static/admin.js | 11 +- apps/delivery/views.py | 30 +++- core/path_validation.py | 24 +++ docs/guide/delivery.md | 28 ++- .../plans/2026-09-17-delivery-management.md | 18 ++ 19 files changed, 634 insertions(+), 118 deletions(-) create mode 100644 apps/base/metadata.py create mode 100644 apps/base/migrations/migrations_011.py create mode 100644 apps/base/migrations/migrations_012.py create mode 100644 core/path_validation.py create mode 100644 docs/superpowers/plans/2026-09-17-delivery-management.md diff --git a/apps/admin/services.py b/apps/admin/services.py index 327daf151..876dc380c 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -16,10 +16,11 @@ settings, ) from apps.base.config import refresh_settings +from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags from apps.base.services import response_from_download, stored_file_of from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue -from apps.base.share_storage import remove_delivery_share, storage_for_share +from apps.base.share_storage import remove_delivery_share, storage_for_share, storage_type_for_share from apps.base.utils import get_expire_info, get_file_path_name from apps.base.quota import release_storage, reserve_storage from fastapi import HTTPException @@ -103,7 +104,8 @@ async def _delete_file_code(self, file_code: FileCodes): if await remove_delivery_share(file_code): return if file_code.text is None: - 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() @@ -677,6 +679,10 @@ async def get_file_detail(self, file_id: int): is_text=is_text, ) + # 详情展示记录实际后端;历史普通文件没有可靠来源时显示 unknown。 + actual_storage_type = await storage_type_for_share(file_code) + # 未知来源以 NULL 交给前端本地化,不能直接输出英文文案。 + display_storage_type = actual_storage_type detail.update( { "filename": detail["name"], @@ -687,7 +693,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, @@ -699,7 +705,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, @@ -722,33 +728,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): @@ -1459,6 +1444,9 @@ async def share_local_file(self, item): raise HTTPException(status_code=404, detail="文件不存在") reservation_token = f"local:{uuid.uuid4().hex}" + # 在读取和写入之间固定实际后端,避免管理端上传遇到设置切换而记录错位。 + storage_type = settings.file_storage + storage = storages[storage_type]() await reserve_storage(reservation_token, local_file.size, ttl_seconds=3600) try: data = await local_file.read() # bytes(read 内部用 with 关闭句柄) @@ -1468,7 +1456,7 @@ async def share_local_file(self, item): path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name( item ) - await self.file_storage.save_file(io.BytesIO(data), save_path) + await storage.save_file(io.BytesIO(data), save_path) try: await FileCodes.create( code=code, @@ -1480,9 +1468,11 @@ async def share_local_file(self, item): expired_at=expired_at, expired_count=expired_count, used_count=used_count, + # 管理端本地文件分享同样固定保存时的真实后端。 + storage_type=storage_type, ) except Exception: - await self.file_storage.delete_file( + await storage.delete_file( StoredFile(file_path=path, uuid_file_name=uuid_file_name) ) raise @@ -1564,6 +1554,14 @@ async def update_config(self, data: dict): and key != "themes_choices" # 主题清单由程序维护,兼容旧字段时也不能允许客户端改写。 } + # 与寄件目录采用相同规则,允许空前缀表示普通上传的默认日期目录。 + if "storage_path" in update_data: + from core.path_validation import validate_storage_directory + try: + update_data["storage_path"] = validate_storage_directory(update_data["storage_path"], allow_empty=True) + except ValueError as exc: + raise HTTPException(422, str(exc)) from None + admin_token = update_data.get("admin_token") admin_password_changed = False if admin_token is None or admin_token == "": 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_011.py b/apps/base/migrations/migrations_011.py new file mode 100644 index 000000000..f421133ec --- /dev/null +++ b/apps/base/migrations/migrations_011.py @@ -0,0 +1,36 @@ +"""为普通文件及上传会话保存实际存储后端快照。""" + +from tortoise import connections + + +async def migrate(): + """幂等增加字段,并仅用寄件关联补全可验证的旧记录。""" + conn = connections.get("default") + tables = { + "filecodes": "storage_type", + "uploadchunk": "storage_type", + "presignuploadsession": "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/models.py b/apps/base/models.py index 4b2e7f147..db464dfd4 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -30,6 +30,8 @@ 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) + # 普通文件创建时固定实际后端;历史记录没有可靠来源时保留 NULL。 + storage_type = fields.CharField(max_length=20, null=True) async def is_expired(self): if self.expired_at is None: @@ -52,6 +54,8 @@ class UploadChunk(models.Model): chunk_size = fields.IntField() file_name = fields.CharField(max_length=255) save_path = fields.CharField(max_length=512, null=True) + # 分片会话必须固定后端,避免全站设置切换后续传或清理到错误位置。 + storage_type = fields.CharField(max_length=20, null=True) created_at = fields.DatetimeField(auto_now_add=True) completed = fields.BooleanField(default=False) @@ -75,6 +79,8 @@ class PresignUploadSession(models.Model): file_name = fields.CharField(max_length=255) file_size = fields.BigIntField() save_path = fields.CharField(max_length=512) + # 直传 URL、代理上传和会话清理均使用初始化时确定的后端。 + storage_type = fields.CharField(max_length=20, null=True) mode = fields.CharField(max_length=10) # "direct" 或 "proxy" expire_value = fields.IntField(default=1) expire_style = fields.CharField(max_length=20, default="day") @@ -102,8 +108,13 @@ class DeliveryCode(models.Model): code_digest = fields.CharField(max_length=64, unique=True) # 与普通取件码一样保留原文供管理员管理;旧记录为 NULL,不能从摘要反推。 code_value = fields.CharField(max_length=64, null=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) owner_id = fields.CharField(max_length=64, default="admin", index=True) + # system 仅标记寄件码跟随设置,实际收件记录始终保存解析后的存储类型与目录。 storage_type = fields.CharField(max_length=20) target_path = fields.CharField(max_length=200) expires_at = fields.DatetimeField() @@ -126,7 +137,8 @@ class DeliveryFile(models.Model): token = fields.CharField(max_length=64, unique=True) filename = fields.CharField(max_length=255, default="") stored_name = fields.CharField(max_length=255, default="") - file_path = fields.CharField(max_length=200) + # system 路径可包含 share/data 日期目录及唯一文件名,长度与普通文件路径对齐。 + file_path = fields.CharField(max_length=255) storage_type = fields.CharField(max_length=20) size = fields.BigIntField(default=0) status = fields.CharField(max_length=20, default="pending", index=True) diff --git a/apps/base/services.py b/apps/base/services.py index e384fa70a..42443d24b 100644 --- a/apps/base/services.py +++ b/apps/base/services.py @@ -111,18 +111,23 @@ async def create_file_record( ) prefix, suffix = os.path.splitext(file_name) - 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, + storage_type = extra_fields.pop("storage_type", None) + share_fields = { + "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, - ) + } + # 未指定时由普通创建入口固定当前后端;NULL 不覆盖这一安全默认值。 + if storage_type: + share_fields["storage_type"] = storage_type + await create_upload_share(access, **share_fields) return code @staticmethod @@ -171,7 +176,8 @@ async def create_file_share( path, uuid_file_name = os.path.split(save_path) token = f"file:{uuid.uuid4().hex}" await reserve_storage(token, size, ttl_seconds=3600) - storage = await upload_storage(access) + storage_type = access.record.storage_type if access is not None and access.record is not None else settings.file_storage + storage = await upload_storage(access, storage_type) try: expired_at, expired_count, used_count, code = await get_expire_info( expire_value, expire_style @@ -187,6 +193,7 @@ async def create_file_share( expired_at=expired_at, expired_count=expired_count, used_count=used_count, + storage_type=storage_type, ) except Exception: await rollback_saved_file( @@ -207,7 +214,13 @@ async def complete_chunked_upload( 失败路径的配额释放与清理范围与原实现逐一对齐: 完整性校验失败仅抛 400(预留由 TTL 兜底);合并失败清理分片文件后抛 500。 """ - storage = await upload_storage(access) + # 旧会话没有快照时只在本次开始解析一次,并在成功完成后写入文件记录。 + storage_type = chunk_info.storage_type or settings.file_storage + if chunk_info.storage_type is None: + # 合并失败后仍保留该解析结果,重试不能因设置切换而改后端。 + await UploadChunk.filter(upload_id=upload_id).update(storage_type=storage_type) + chunk_info.storage_type = storage_type + storage = await upload_storage(access, storage_type) await reserve_storage( f"chunk:{upload_id}", chunk_info.file_size, ttl_seconds=chunk_reservation_ttl() ) @@ -268,6 +281,7 @@ async def complete_chunked_upload( uuid_file_name=safe_file_name, prefix=prefix, suffix=suffix, + storage_type=storage_type, ) try: await storage.clean_chunks(upload_id, save_path) @@ -299,6 +313,11 @@ async def commit_proxy_upload( 校验失败不释放预留(与原实现一致,由 TTL 兜底)。 """ + # 进入代理上传前就冻结旧会话的兼容回退值,后续 await 不再读取设置。 + storage_type = session.storage_type or settings.file_storage + if session.storage_type is None: + session.storage_type = storage_type + await session.save(update_fields=["storage_type"]) await reserve_storage( f"presign:{session.upload_id}", session.file_size, @@ -310,14 +329,15 @@ async def commit_proxy_upload( if abs(file_size - session.file_size) > 1024: raise HTTPException(400, "文件大小与声明不符") - storage = await upload_storage(access) + storage = await upload_storage(access, storage_type) try: await storage.save_file(file.file, session.save_path, file.content_type) except Exception as e: raise HTTPException(500, f"文件保存失败: {str(e)}") return await FileUploadService._commit_presign_record( - session, file_size, storage, access=access, context="预签名代理上传:记录创建失败" + session, file_size, storage, access=access, storage_type=storage_type, + context="预签名代理上传:记录创建失败" ) @staticmethod @@ -326,6 +346,11 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s 预留失败说明配额已耗尽,此时清理远端临时文件与会话后原样抛出。 """ + # 在预留容量前冻结旧会话的兼容回退值,整条确认流程使用同一后端。 + storage_type = session.storage_type or settings.file_storage + if session.storage_type is None: + session.storage_type = storage_type + await session.save(update_fields=["storage_type"]) try: await reserve_storage( f"presign:{session.upload_id}", @@ -333,7 +358,7 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s ttl_seconds=PRESIGN_SESSION_EXPIRES, ) except HTTPException: - storage = await upload_storage(access) + storage = await upload_storage(access, storage_type) try: if await storage.file_exists(session.save_path): await storage.delete_file( @@ -347,21 +372,23 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s await release_storage(f"presign:{session.upload_id}") raise - storage = await upload_storage(access) + storage = await upload_storage(access, storage_type) 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="预签名确认:记录创建失败" + session, session.file_size, storage, access=access, storage_type=storage_type, + context="预签名确认:记录创建失败" ) @staticmethod - async def _commit_presign_record(session, file_size, storage, *, access=None, context): + async def _commit_presign_record(session, file_size, storage, *, access=None, storage_type, context): """代理上传与直传共用记录提交、失败回滚及会话释放,避免两条路径行为分叉。""" try: code = await FileUploadService.create_file_record( session.file_name, file_size, os.path.dirname(session.save_path), session.expire_value, session.expire_style, access=access, + storage_type=storage_type, ) except Exception: await rollback_saved_file( diff --git a/apps/base/share_storage.py b/apps/base/share_storage.py index fb30c713b..59160eb2e 100644 --- a/apps/base/share_storage.py +++ b/apps/base/share_storage.py @@ -15,9 +15,20 @@ async def storage_for_share(file_code, fallback=None): # 延迟导入以保持应用模块边界,沿用 OneDrive 的精确对象键适配。 from apps.delivery.storage import get_storage return await get_storage(record.storage_type) + # 普通文件优先使用创建时的后端快照,历史 NULL 才兼容旧行为。 + if 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) -> str | None: + """返回详情页可展示的实际后端;历史普通文件无来源时明确标记未知。""" + record = await delivery_record(file_code) + if record is not None: + return record.storage_type + return file_code.storage_type + + async def remove_delivery_share(file_code): """先撤销取件记录再清理文件;失败残留继续占用容量并交由后台重试。""" record = await delivery_record(file_code) diff --git a/apps/base/tasks.py b/apps/base/tasks.py index ad2fcf374..3cdc6f82f 100644 --- a/apps/base/tasks.py +++ b/apps/base/tasks.py @@ -20,7 +20,7 @@ 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.share_storage import remove_delivery_share +from apps.base.share_storage import remove_delivery_share, storage_for_share from core.utils import get_now @@ -28,7 +28,6 @@ async def delete_expire_files(): while True: try: await refresh_settings() - file_storage = None # 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除 if settings.file_storage == "local": for root, dirs, files in os.walk(f"{data_root}/share/data"): @@ -50,11 +49,13 @@ async def delete_expire_files(): logger.warning("寄件分享过期清理失败 id=%s", exp.id, exc_info=True) continue try: - if file_storage is None: - file_storage = storages[settings.file_storage]() - await file_storage.delete_file(stored_file_of(exp)) + # 文本内容只在数据库中;普通文件按快照清理,历史 NULL 才兼容旧设置。 + if exp.text is None: + await (await storage_for_share(exp)).delete_file(stored_file_of(exp)) except Exception as e: logger.error(f"删除过期文件失败 code={exp.code}: {e}") + # 文件定位或后端异常时保留记录,下一轮按同一快照重试。 + continue try: await exp.delete() except Exception as e: @@ -69,7 +70,6 @@ async def clean_incomplete_uploads(): while True: try: await refresh_settings() - file_storage: FileStorageInterface = storages[settings.file_storage]() expire_hours = getattr(settings, "chunk_expire_hours", 24) now = await get_now() expire_time = now - datetime.timedelta(hours=expire_hours) @@ -84,7 +84,8 @@ async def clean_incomplete_uploads(): _, _, _, _, save_path = await get_chunk_file_path_name( session.file_name, session.upload_id ) - await file_storage.clean_chunks(session.upload_id, save_path) + storage = storages[session.storage_type or settings.file_storage]() + await storage.clean_chunks(session.upload_id, save_path) except Exception as e: logger.error( f"清理分片文件失败 upload_id={session.upload_id}: {e}" @@ -111,7 +112,6 @@ async def clean_expired_presign_sessions(): while True: try: await refresh_settings() - storage: FileStorageInterface = storages[settings.file_storage]() now = await get_now() expired_sessions = await PresignUploadSession.filter( expires_at__lt=now @@ -119,6 +119,7 @@ async def clean_expired_presign_sessions(): for session in expired_sessions: if session.mode == "direct": try: + storage = storages[session.storage_type or settings.file_storage]() if await storage.file_exists(session.save_path): await storage.delete_file( StoredFile( diff --git a/apps/base/upload_access.py b/apps/base/upload_access.py index c1d716d22..69da21261 100644 --- a/apps/base/upload_access.py +++ b/apps/base/upload_access.py @@ -22,7 +22,7 @@ class UploadAccess: async def authorize_upload(request: Request, authorization: str | None = Header(default=None)): """校验每一步的授权和会话归属,游客模式也不能访问寄件上传会话。""" from apps.admin.dependencies import share_required_login, verify_token - from apps.delivery.services import active_code, heartbeat + from apps.delivery.services import active_code, heartbeat, upload_identity access = UploadAccess() if authorization and authorization.startswith("Bearer "): @@ -53,6 +53,8 @@ async def authorize_upload(request: Request, authorization: str | None = Header( # 耗尽口令已被回收时,原凭证只可取回自己已完成会话的结果,不能启动或续传文件。 completed_retry = completion_request and access.record is not None and access.record.status == "shared" if access.code_id is not None and not completed_retry: + # 新上传和续传必须校验寄件码版本;改码后旧临时凭证不能再投递。 + access.code_id = await upload_identity(authorization) await active_code(access.code_id) # 完成阶段原子抢占,避免并发合并或代理上传覆盖同一个已发布文件。 @@ -102,17 +104,20 @@ async def prepare_upload(access, file_name, file_size, upload_id): return record.token, f"{record.file_path}/{record.stored_name}" -async def upload_storage(access=None): +async def upload_storage(access=None, storage_type: str | None = None): """上传和合并均使用寄件码选定的存储,普通上传仍遵循全站设置。""" if access is not None and access.record is not None: from apps.delivery.storage import get_storage return await get_storage(access.record.storage_type) - return storages[settings.file_storage]() + # 普通会话一旦创建即使用其快照,NULL 仅兼容升级前尚未完成的会话。 + return storages[storage_type or settings.file_storage]() async def create_upload_share(access=None, **fields): """共用普通取件记录;寄件扣次与关联记录提交必须处于同一事务。""" if access is None or access.record is None: + # 普通单文件上传在创建记录的同一时刻固化实际存储类型。 + fields.setdefault("storage_type", settings.file_storage) return await FileCodes.create(**fields) # 授权适配层只传递身份,扣次及私有/公开收件事务集中在寄件业务层。 from apps.delivery.services import commit_delivery diff --git a/apps/base/utils.py b/apps/base/utils.py index 6ac03895c..720173030 100644 --- a/apps/base/utils.py +++ b/apps/base/utils.py @@ -9,6 +9,7 @@ from apps.base.dependencies import IPRateLimit from apps.base.models import FileCodes from core.settings import settings +from core.path_validation import validate_storage_directory from core.utils import ( get_random_num, get_random_string, @@ -34,7 +35,11 @@ async def build_file_path( Always use get_now() (UTC+8); do not switch to server-local time. """ today = await get_now() - storage_path = settings.storage_path.strip("/") + # 与寄件自定义目录共用校验;此处仍保留普通上传的日期及唯一标识目录。 + try: + storage_path = validate_storage_directory(settings.storage_path, allow_empty=True) + except ValueError as exc: + raise HTTPException(422, f"系统文件存储路径无效,请在设置中修正:{exc}") from None filename = await sanitize_filename(unquote(file_name or "")) base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" path = f"{storage_path}/{base_path}" if storage_path else base_path diff --git a/apps/base/views.py b/apps/base/views.py index e176a372c..04acac52e 100644 --- a/apps/base/views.py +++ b/apps/base/views.py @@ -348,6 +348,8 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun ) # 创建新的上传会话 + # 普通会话在任何异步准备前先固定后端,避免设置切换穿透到本次会话。 + initial_storage_type = settings.file_storage 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}" @@ -359,6 +361,8 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun data.file_name, upload_id ) try: + # 普通分片会话在初始化时固定后端,寄件会话沿用收件记录的实际后端。 + storage_type = access.record.storage_type if access is not None and access.record is not None else initial_storage_type await UploadChunk.create( upload_id=upload_id, chunk_index=-1, @@ -368,6 +372,7 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun chunk_hash=data.file_hash, file_name=safe_file_name, save_path=delivery_path or save_path, + storage_type=storage_type, ) except Exception: await abort_access(access) @@ -453,7 +458,12 @@ async def upload_chunk( save_path = chunk_info.save_path # 保存分片到存储 - storage = await upload_storage(access) + # 旧会话首次续传后补写快照;本请求及之后都使用同一个后端。 + storage_type = chunk_info.storage_type or settings.file_storage + if chunk_info.storage_type is None: + await UploadChunk.filter(upload_id=upload_id).update(storage_type=storage_type) + chunk_info.storage_type = storage_type + storage = await upload_storage(access, storage_type) try: await storage.save_chunk( upload_id, chunk_index, chunk_data, chunk_hash, save_path @@ -475,6 +485,7 @@ async def upload_chunk( "chunk_size": chunk_info.chunk_size, "file_name": chunk_info.file_name, "save_path": chunk_info.save_path, + "storage_type": storage_type, }, ) return APIResponse(detail={"chunk_hash": chunk_hash}) @@ -493,7 +504,7 @@ async def cancel_upload(upload_id: str, access: Annotated[UploadAccess, Depends( save_path = chunk_info.save_path # 清理存储中的临时文件 - storage = await upload_storage(access) + storage = await upload_storage(access, chunk_info.storage_type or settings.file_storage) if save_path: try: await storage.clean_chunks(upload_id, save_path) @@ -571,13 +582,26 @@ 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 session.mode == "direct": + storage = await upload_storage(access, session.storage_type) + try: + if await storage.file_exists(session.save_path): + await storage.delete_file( + StoredFile( + file_path=os.path.dirname(session.save_path), + uuid_file_name=os.path.basename(session.save_path), + ) + ) + except Exception: + logger.warning("过期预签名会话:清理临时文件失败 upload_id=%s", upload_id, exc_info=True) await session.delete() await release_storage(f"presign:{upload_id}") raise HTTPException(404, "上传会话已过期") @@ -599,6 +623,8 @@ async def presign_upload_init( ) validate_expire_style(data.expire_style) + # 普通预签名会话在准备寄件记录和远端 URL 前固定实际后端。 + initial_storage_type = settings.file_storage 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}" @@ -611,7 +637,9 @@ async def presign_upload_init( ) if delivery_path: save_path = delivery_path - storage: FileStorageInterface = await upload_storage(access) + # 预签名会话保存快照,后续代理、确认和清理不会受全站设置影响。 + storage_type = access.record.storage_type if access is not None and access.record is not None else initial_storage_type + storage: FileStorageInterface = await upload_storage(access, storage_type) presigned_url = await storage.generate_presigned_upload_url( save_path, PRESIGN_SESSION_EXPIRES ) @@ -627,6 +655,7 @@ async def presign_upload_init( expire_value=data.expire_value, expire_style=data.expire_style, expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES), + storage_type=storage_type, ) except Exception: await abort_access(access) @@ -658,7 +687,7 @@ async def presign_upload_proxy( result = await completed_upload(access) if result: return APIResponse(detail=result) - session = await _get_valid_session(upload_id, expected_mode="proxy") + 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}) @@ -672,7 +701,7 @@ async def presign_upload_confirm(upload_id: str, access: Annotated[UploadAccess, result = await completed_upload(access) if result: return APIResponse(detail=result) - session = await _get_valid_session(upload_id, expected_mode="direct") + 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}) @@ -711,7 +740,7 @@ async def presign_upload_cancel(upload_id: str, access: Annotated[UploadAccess, raise HTTPException(404, "上传会话不存在") if session.mode == "direct": - storage: FileStorageInterface = await upload_storage(access) + storage: FileStorageInterface = await upload_storage(access, session.storage_type) try: if await storage.file_exists(session.save_path): temp_file_code = StoredFile( diff --git a/apps/delivery/schemas.py b/apps/delivery/schemas.py index 0c628a1b1..8ea66bebc 100644 --- a/apps/delivery/schemas.py +++ b/apps/delivery/schemas.py @@ -2,47 +2,38 @@ import re from datetime import datetime, timezone, timedelta -from pathlib import PurePosixPath +from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags +from core.path_validation import validate_storage_directory -class CreateDeliveryCode(BaseModel): + +class DeliveryCodeConfig(BaseModel): + """创建和编辑共用配置校验,编辑请求不能改动口令或计数。""" # 禁止静默接受 owner_id 等越权字段,未来多用户必须由服务端身份决定归属。 model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) name: str = Field(min_length=1, max_length=100) - code: str = Field(default="", max_length=64) - storage_type: str = "local" - target_path: str = Field(min_length=1, max_length=200) + # system 表示上传开始时跟随全站配置;旧记录的明确存储值继续保留。 + storage_type: str = "system" + target_path: str = Field(default="", max_length=200) expires_at: datetime max_uploads: int = Field(default=1, ge=1, le=100000) - @field_validator("code") - @classmethod - def validate_code(cls, value): - if value and not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", value): - raise ValueError("寄件码须为 8 至 64 位字母、数字、下划线或短横线") - return value - @field_validator("storage_type") @classmethod def validate_storage(cls, value): - if value not in {"local", "s3", "webdav", "onedrive", "opendal"}: + # 配置入口与设置页保持一致;历史收件的存储驱动仍保留供读取和清理。 + if value not in {"system", "local", "s3", "webdav"}: raise ValueError("不支持的存储类型") return value @field_validator("target_path") @classmethod def validate_path(cls, value): - # 同时约束 POSIX、Windows 与 URL 语义,避免 WebDAV 二次解码或盘符逃逸。 - parts = value.split("/") - if (PurePosixPath(value).is_absolute() - or any(part in {"", ".", ".."} for part in parts) - or not re.fullmatch(r"[\w ./-]+", value, re.UNICODE) - or any(part.endswith((".", " ")) for part in parts) - or any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?", part) for part in parts)): - raise ValueError("目标目录必须是根目录内的相对路径,如 inbox/project-a") - return value + # 目录校验与系统设置共用,避免同一路径在两个入口产生不同结果。 + return validate_storage_directory(value, allow_empty=True, max_length=200) @field_validator("expires_at") @classmethod @@ -54,6 +45,126 @@ def validate_expiry(cls, value): raise ValueError("有效期必须晚于当前时间") return value + @model_validator(mode="after") + def validate_storage_mode(self): + # 跟随系统时不保存隐藏表单中残留的自定义目录。 + if self.storage_type == "system": + self.target_path = "" + elif not self.target_path: + raise ValueError("自定义存储位置必须填写目标目录") + return self + + +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) + storage_type: str | None = None + target_path: str | None = Field(default=None, max_length=200) + 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("storage_type") + @classmethod + def validate_storage(cls, value): + if value is not None and value not in {"system", "local", "s3", "webdav"}: + raise ValueError("不支持的存储类型") + return value + + @field_validator("target_path") + @classmethod + def validate_path(cls, value): + return None if value is None else validate_storage_directory(value, allow_empty=True, max_length=200) + + @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", "storage_type", "target_path", "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) diff --git a/apps/delivery/services.py b/apps/delivery/services.py index 7e9ab329f..3d391c3cb 100644 --- a/apps/delivery/services.py +++ b/apps/delivery/services.py @@ -3,6 +3,7 @@ import asyncio import hashlib import hmac +import json import os import secrets import uuid @@ -16,7 +17,7 @@ from apps.admin.dependencies import create_token, verify_token from apps.base.file_validation import validate_upload_file from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue, UploadChunk, PresignUploadSession, StorageReservation -from apps.base.utils import get_expire_info, validate_expire_style +from apps.base.utils import build_file_path, get_expire_info, validate_expire_style from apps.base.quota import _sql_placeholders, reserve_storage from apps.delivery.storage import get_storage, validate_storage_config from core.logger import logger @@ -36,7 +37,7 @@ def code_digest(code: str) -> str: return hmac.new(secret.encode(), ("delivery-code:" + code).encode(), hashlib.sha256).hexdigest() -def upload_identity(authorization: str | None) -> int: +async def upload_identity(authorization: str | None) -> int: """只接受用途为 delivery 的凭证;管理员 token 也不能被误当作寄件授权。""" if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "请先验证寄件码") @@ -44,7 +45,13 @@ def upload_identity(authorization: str | None) -> int: payload = verify_token(authorization[7:]) if payload.get("purpose") != "delivery" or payload.get("is_admin"): raise ValueError("凭证用途错误") - return int(payload["delivery_id"]) + code_id = int(payload["delivery_id"]) + # 历史令牌按初始版本 1 解释;改码后同样会立即失效。 + token_version = payload.get("delivery_version", 1) + record = await DeliveryCode.filter(id=code_id, owner_id="admin", 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 @@ -58,13 +65,14 @@ async def active_code(code_id: int) -> DeliveryCode: async def create_code(data): """创建时保存原文,便于管理员后续查看;访客响应仍不提供任何口令列表。""" - validate_storage_config(data.storage_type) + validate_storage_config(settings.file_storage if data.storage_type == "system" else data.storage_type) code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16)) try: record = await DeliveryCode.create( code_digest=code_digest(code), code_value=code, name=data.name, owner_id="admin", storage_type=data.storage_type, target_path=data.target_path, expires_at=data.expires_at, max_uploads=data.max_uploads, + note=data.note, tags=data.tags, ) except IntegrityError: raise HTTPException(409, "该寄件码已被使用,请设置其他口令") from None @@ -86,6 +94,7 @@ async def code_summary(record): return { "id": record.id, "name": record.name, "storage_type": record.storage_type, "code": record.code_value, + "note": record.note, "tags": record.tags if isinstance(record.tags, list) else [], "target_path": record.target_path, "expires_at": record.expires_at, "max_uploads": record.max_uploads, "used_count": record.used_count, "reserved_count": record.reserved_count, "enabled": record.enabled, @@ -107,7 +116,10 @@ async def verify_code(code: str): if record.code_value is None: await DeliveryCode.filter(id=record.id, code_value__isnull=True).update(code_value=code) # 凭证仅含寄件 ID,不携带 is_admin、目标路径或下载口令。 - token = create_token({"purpose": "delivery", "delivery_id": record.id}, expires_in=TOKEN_TTL) + token = create_token( + {"purpose": "delivery", "delivery_id": record.id, "delivery_version": record.auth_version}, + expires_in=TOKEN_TTL, + ) return { "token": token, "expires_in": TOKEN_TTL, "name": record.name, "remaining": remaining, "expires_at": record.expires_at, @@ -131,9 +143,16 @@ async def reserve_slot(code_id: int): if count != 1: raise HTTPException(409, "寄件码已失效或没有剩余上传次数") code = await DeliveryCode.get(id=code_id).using_db(conn) + # 只在新上传预占时解析系统配置并快照;续传、下载和清理沿用记录中的实际位置。 + storage_type = code.storage_type + target_path = code.target_path + if storage_type == "system": + storage_type = settings.file_storage + validate_storage_config(storage_type) + target_path, *_ = await build_file_path("delivery", uuid.uuid4().hex) return await DeliveryFile.create( delivery_id=code.id, owner_id="admin", token=uuid.uuid4().hex, - file_path=code.target_path, storage_type=code.storage_type, using_db=conn, + file_path=target_path, storage_type=storage_type, using_db=conn, ) @@ -319,6 +338,8 @@ async def commit_delivery(record, share_fields=None): share = None if share_fields is not None: fields = dict(share_fields) + # 取件记录保存寄件时解析后的存储类型,后续读取和清理不依赖可变全局设置。 + fields["storage_type"] = record.storage_type # 存储名保证唯一,显示名、文本分享和原取件规则保持不变。 if "text" not in fields: fields["prefix"], fields["suffix"] = os.path.splitext(record.filename) @@ -344,3 +365,134 @@ def recyclable_codes(): return DeliveryCode.filter(owner_id="admin").filter( Q(deleted=True) | Q(reserved_count=0, used_count__gte=F("max_uploads")) ) + + +async def update_code(code_id, data): + """原子更新可编辑配置,改码时同步撤销当前版本的临时寄件凭证。""" + async with in_transaction() as conn: + record = await DeliveryCode.filter(id=code_id, owner_id="admin", 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, "上传总次数不能小于已使用次数与上传中占用次数之和,请刷新后重试") + storage_requested = "storage_type" in changes or "target_path" in changes + storage_type = changes.get("storage_type", record.storage_type) + target_path = changes.get("target_path", record.target_path) + if storage_type == "system": + target_path = "" + elif not target_path: + raise HTTPException(400, "自定义存储位置必须填写目标目录") + # 整理名称或元数据时不依赖当前后端配置;仅实际改存储设置才重新校验。 + if storage_requested and (storage_type != record.storage_type or target_path != record.target_path): + validate_storage_config(settings.file_storage if storage_type == "system" else storage_type) + changes["storage_type"] = storage_type + changes["target_path"] = target_path + if new_code and new_code != record.code_value: + # 版本由数据库递增,两个改码请求并发时任一旧令牌都不会被错误复用。 + changes.update(code_digest=code_digest(new_code), 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 owner_id = 'admin' 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(*, keyword="", status="all", storage_type="all", tag="", sort_by="created_at", sort_order="desc"): + """在服务层统一筛选与排序,分页前得到的 total 与列表结果保持一致。""" + if status not in {"all", "active", "disabled", "expired", "exhausted"}: + raise HTTPException(400, "不支持的寄件码状态筛选") + if storage_type not in {"all", "system", "local", "s3", "webdav"}: + raise HTTPException(400, "不支持的存储类型筛选") + if sort_by not in {"created_at", "expires_at", "name", "code", "used_count", "max_uploads"} or sort_order not in {"asc", "desc"}: + raise HTTPException(400, "不支持的排序方式") + items = [await code_summary(record) for record in await DeliveryCode.filter(owner_id="admin", deleted=False)] + keyword, tag = keyword.strip().lower(), tag.strip().lower() + def matched(item): + if status != "all" and item["status"] != status: + return False + if storage_type != "all" and item["storage_type"] != storage_type: + return False + if tag and tag not in {str(value).lower() for value in item["tags"]}: + return False + values = [item["name"], item["code"], item["note"], *item["tags"]] + return not keyword or any(keyword in str(value).lower() for value in values if value) + items = [item for item in items if matched(item)] + def sort_value(item): + value = item.get(sort_by) + if sort_by in {"created_at", "expires_at"}: + primary = value.timestamp() if value is not None else float("-inf") + elif sort_by in {"used_count", "max_uploads"}: + primary = int(value or 0) + else: + primary = str(value or "").casefold() + return primary, item["id"] + items.sort(key=sort_value, reverse=sort_order == "desc") + return items + + +async def batch_codes(data): + """批量操作在同一事务中先完整校验,任一记录不合法时整批不变更。""" + async with in_transaction() as conn: + records = await DeliveryCode.filter(id__in=data.ids, owner_id="admin", 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 == "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, owner_id="admin").using_db(conn).delete() + 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)) + assignments = ", ".join(f"{field} = {placeholders[index]}" for index, field in enumerate(changes)) + id_placeholders = ", ".join(placeholders[len(changes):]) + 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[list(changes).index('max_uploads')]}" + changed, _ = await conn.execute_query( + f"UPDATE deliverycode SET {assignments} WHERE id IN ({id_placeholders}) " + f"AND owner_id = 'admin' AND deleted = 0{condition}", values, + ) + if changed != len(data.ids): + raise HTTPException(409, "寄件码状态已变化,请刷新后重试") + result = await DeliveryCode.filter(id__in=data.ids, owner_id="admin", deleted=False).using_db(conn) + return {"items": [await code_summary(record) for record in result], "count": len(result)} diff --git a/apps/delivery/static/admin.html b/apps/delivery/static/admin.html index 023e6c0c9..b5c976e20 100644 --- a/apps/delivery/static/admin.html +++ b/apps/delivery/static/admin.html @@ -21,12 +21,14 @@

管理员登录

使用现有站点管理员密码。寄

创建寄件码

- - - + + + + - -

目录相对于所选存储的根目录。云存储/WebDAV 需先在原后台配置;已收到的文件不会随寄件码到期而删除。

+ + +

默认使用设置中的存储方式和文件路径规则;自定义目录相对于所选存储的根目录。云存储/WebDAV 需先在原后台配置;每次成功分享扣一次(多文件打包算一次,文本也算一次);寄件码期限不改变取件码期限。

diff --git a/apps/delivery/static/admin.js b/apps/delivery/static/admin.js index cd51427e4..2206f9716 100644 --- a/apps/delivery/static/admin.js +++ b/apps/delivery/static/admin.js @@ -33,7 +33,7 @@ async function loadCodes() { if (!result.items.length) emptyRow($('codes'), 6, '还没有寄件码。创建后即可邀请对方投递。'); for (const item of result.items) { const row = $('codes').insertRow(); - cell(row, `${item.name} #${item.id}`); cell(row, `${item.storage_type} · ${item.target_path}`); cell(row, date(item.expires_at)); + cell(row, `${item.name} #${item.id}`); cell(row, item.storage_type === 'system' ? '跟随系统设置' : `${item.storage_type} · ${item.target_path}`); cell(row, date(item.expires_at)); const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = stateNames[item.status] || item.status; row.insertCell().append(badge); cell(row, `${item.used_count} / ${item.reserved_count} / ${item.max_uploads}`); const actions = row.insertCell(); actions.className = 'actions'; @@ -92,7 +92,7 @@ $('login-form').addEventListener('submit', async event => { $('create-form').addEventListener('submit', async event => { event.preventDefault(); const button=event.submitter; button.disabled=true; $('created').hidden=true; try { - const result=await request('/codes', {method:'POST', json:{name:$('name').value, code:$('new-code').value.trim(), storage_type:$('storage').value, target_path:$('target').value, expires_at:new Date($('expires').value).toISOString(), max_uploads:Number($('maximum').value)}}); + const result=await request('/codes', {method:'POST', json:{name:$('name').value, code:$('new-code').value.trim(), storage_type:$('storage').value, target_path:$('storage').value === 'system' ? '' : $('target').value, expires_at:new Date($('expires').value).toISOString(), max_uploads:Number($('maximum').value)}}); $('created-code').textContent=result.code; $('created').hidden=false; $('copy-message').textContent=''; $('new-code').value=''; page=1; message($('message'),'寄件码已创建,请保存下方口令并发给投递人。'); await loadCodes(); } catch(error) { report(error); } finally { button.disabled=false; } @@ -140,3 +140,10 @@ async function copyCreatedCode() { ? '已选中寄件码,请按 Ctrl+C(Mac 为 ⌘C),或长按选中文字后复制。' : '请选中上方寄件码,按 Ctrl+C(Mac 为 ⌘C),或长按文字复制。'; } + +// 跟随系统模式隐藏目录输入;切回自定义后恢复必填,避免隐藏字段阻止提交。 +$('storage').addEventListener('change', () => { + const custom = $('storage').value !== 'system'; + $('target-label').hidden = !custom; + $('target').required = custom; +}); diff --git a/apps/delivery/views.py b/apps/delivery/views.py index 1d2ac29ad..684342f1f 100644 --- a/apps/delivery/views.py +++ b/apps/delivery/views.py @@ -15,7 +15,7 @@ from apps.base.services import response_from_download from apps.base.utils import ip_limit from apps.delivery import services -from apps.delivery.schemas import CreateDeliveryCode, SetDeliveryEnabled, VerifyDeliveryCode, DeliveryShareOptions +from apps.delivery.schemas import BatchDeliveryCodes, CreateDeliveryCode, SetDeliveryEnabled, UpdateDeliveryCode, VerifyDeliveryCode, DeliveryShareOptions from apps.delivery.storage import get_storage from core.errors import StorageError from core.logger import logger @@ -49,7 +49,7 @@ async def verify(data: VerifyDeliveryCode, request: Request): @public_api.post("/upload") async def upload(request: Request, authorization: str | None = Header(default=None)): - code_id = services.upload_identity(authorization) + code_id = await services.upload_identity(authorization) await services.active_code(code_id) ip = ip_limit["upload"](request) ip_limit["upload"].add_ip(ip) @@ -96,12 +96,14 @@ async def limited_receive(): @admin_api.get("/codes") -async def list_codes(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)): - # 旧撤销标记仅用于迁移清理,不再提供已删除寄件码的回收视图。 - query = DeliveryCode.filter(owner_id="admin", deleted=False) - total = await query.count() - records = await query.order_by("-id").offset((page - 1) * page_size).limit(page_size) - return APIResponse(detail={"items": [await services.code_summary(item) for item in records], "total": total}) +async def list_codes( + page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str = "", + status: str = "all", storage_type: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc", +): + # 在切片前统一完成筛选排序,total 与当前查询条件下的项目数一致。 + items = await services.list_codes(keyword=keyword, status=status, storage_type=storage_type, tag=tag, sort_by=sort_by, sort_order=sort_order) + start = (page - 1) * page_size + return APIResponse(detail={"items": items[start:start + page_size], "total": len(items)}) @admin_api.post("/codes", status_code=201) @@ -215,3 +217,15 @@ async def asset(filename: str): if filename not in {"delivery.css", "delivery.js", "admin.js", "common.js", "entry.js", "entry.css", "logo.svg"}: raise HTTPException(404, "资源不存在") return FileResponse(STATIC / filename, headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"}) + + +@admin_api.put("/codes/{code_id}") +async def update_config(code_id: int, data: UpdateDeliveryCode): + """管理员编辑配置,启停操作仍使用兼容的 PATCH 接口。""" + 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)) diff --git a/core/path_validation.py b/core/path_validation.py new file mode 100644 index 000000000..09ff561cb --- /dev/null +++ b/core/path_validation.py @@ -0,0 +1,24 @@ +"""设置和寄件配置共用目录校验,仅验证输入,不改变各自的路径生成规则。""" + +import re +from pathlib import PurePosixPath + + +def validate_storage_directory(value: str, *, allow_empty: bool = False, max_length: int = 200) -> str: + """限制为存储根目录内的相对目录,跨本地、对象存储和 WebDAV 使用同一规则。""" + if not isinstance(value, str): + raise ValueError("存储目录必须是字符串") + value = value.strip() + if not value and allow_empty: + return "" + parts = value.split("/") + if ( + not value or len(value) > max_length + or PurePosixPath(value).is_absolute() + or not re.fullmatch(r"[\w ./-]+", value, re.UNICODE) + or any(part in {"", ".", ".."} for part in parts) + or any(part.endswith((".", " ")) for part in parts) + or any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?", part) for part in parts) + ): + raise ValueError(f"存储目录须为 {max_length} 字以内的相对路径,如 inbox/project-a;不能包含绝对路径、路径跳转或保留名称") + return value diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md index 2491e8876..39d813428 100644 --- a/docs/guide/delivery.md +++ b/docs/guide/delivery.md @@ -5,12 +5,14 @@ ## 使用方法 1. 访问 `/delivery/admin`,用本站管理员密码登录。新版 2024 主题会进入原后台侧栏的“寄件管理”,复用管理员会话;未更新的主题仍进入独立管理页。 -2. 填写用途名称、有效期、最大文件数、存储类型和目标目录,创建寄件码。口令留空时自动生成 16 位随机口令;自定义时为 8 至 64 位字母、数字、下划线或短横线,区分大小写。 +2. 填写用途名称、有效期和最大文件数,创建寄件码。默认跟随系统设置中的存储方式和文件路径;需要单独指定时勾选“自定义存储位置”,填写存储类型和目标目录。口令留空时自动生成 16 位随机口令;自定义时为 8 至 32 位字母、数字、下划线或短横线(历史长码仍可验证),区分大小写。 3. 复制创建结果中的口令,也可以随时在 2024 主题管理列表的“寄件码”列查看和复制。 -4. 把站点的 `/delivery` 地址和口令发给投递人。 +4. 创建成功窗口展示用途、有效期、剩余次数、口令和二维码。可复制寄件链接发给投递人,或点击“前往寄件”在新标签页打开上传页;链接自动验证寄件码,无效时显示原因。也可继续分享 `/delivery` 地址和口令。 5. 访客验证后选择文件及过期时间/领取次数,上传成功后复制取件码或打开取件链接。接收者使用首页取件流程下载。 6. 新上传会同时出现在普通文件管理与对应寄件码的收件列表。“查看收件”复用文件管理列表与详情、下载、编辑和删除操作,分页只统计当前寄件码的关联分享;旧私有收件及处理中记录单独保留。 +2024 主题寄件管理的“编辑”可修改用途名称、有效期、上传总次数、存储类型和目录。已使用次数保持不变,总次数不能小于已使用与上传中占用次数之和。寄件码可以修改,新码最长 32 位;未修改时保留原码。改码会撤销旧码及旧上传凭证,已生成的取件码不受影响。存储修改只影响随后开始的上传,已收文件和上传中会话仍使用原来的存储位置。 + 关闭原后台的“允许游客上传”不会影响有效寄件码。无需为了临时收件重新开放自由上传。 ## 次数与文件 @@ -31,7 +33,7 @@ ## 存储配置 -支持现有适配层中的 `local`、`webdav`、`s3`、`onedrive`、`opendal`。创建时检查所选后端的必要配置和可选依赖,缺失时拒绝发码;这不替代远端连通性或凭据有效性检查。收件记录保存存储类型,后续切换默认存储不会把其改成另一种后端。 +新建和编辑的自定义存储选项与系统设置统一为 `local`、`s3`、`webdav`;`system` 表示跟随系统设置,不是额外的存储后端。前端两个页面共用选项和名称,寄件配置接口同步限制。历史 OneDrive/OpenDAL 记录的读取、上传及清理适配保留,不自动迁移文件;编辑这些旧码时需主动改选受支持的存储或跟随系统。创建时检查所选后端的必要配置,缺失时拒绝发码;这不替代远端连通性或凭据有效性检查。收件记录保存存储类型,后续切换默认存储不会把其改成另一种后端。 OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截去扩展名;管理员下载通过认证的 Graph 会话流式写入临时文件,不创建匿名分享链接。大文件上传使用 SDK 的上传会话能力。OpenDAL 必须配置持久化后端,不接受每次实例化都会丢失内容的 memory 存储。 @@ -49,7 +51,9 @@ OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截 | --- | --- | --- | | POST `/api/delivery/verify` | 寄件口令 | JSON `{code}`,获得短期上传凭证 | | POST `/api/delivery/upload` | 寄件 Bearer Token | multipart 包含一个 `file`、`expire_style`、`expire_value`,成功返回 `code` 取件码 | -| GET `/admin/delivery/codes` | 管理员 | 分页列出当前寄件码 | +| PUT `/admin/delivery/codes/{id}` | 管理员 | 按提交字段修改口令、名称、有效期、次数、存储及备注标签,保留计数 | +| GET `/admin/delivery/codes` | 管理员 | 按关键字、状态、存储、标签筛选并排序分页 | +| POST `/admin/delivery/codes/batch` | 管理员 | 批量启用、停用、删除或修改期限和次数;同一事务校验 | | POST `/admin/delivery/codes` | 管理员 | 创建口令 | | PATCH `/admin/delivery/codes/{id}` | 管理员 | JSON `{enabled}` 启用/禁用 | | DELETE `/admin/delivery/codes/{id}` | 管理员 | 物理删除口令,保留已生成的分享 | @@ -87,3 +91,19 @@ python -m uvicorn main:app --host 127.0.0.1 --port 12345 新版 2024 页面直接复用普通上传服务:`/share/text/`、`/share/file/`、`/chunk/upload/*`、`/presign/upload/*` 接受有效寄件 Bearer Token。寄件授权只改变权限、存储目录与次数归属,生成的取件码、过期策略和下载流程仍为普通分享。分片会话跨请求持久化,重新验证同一寄件码并选择同一文件可续传;令牌过期须重新验证。取消或空闲超过两小时的寄件上传释放次数并清理残留,清理失败保留记录重试。 旧 `/api/delivery/upload` 保留为旧客户端兼容接口;未传过期参数的旧私有收件语义不变。新页面不再调用这一独立上传实现。 + +### 跟随系统存储设置 + +新建寄件码默认 `storage_type=system`,`target_path` 留空。每次新上传开始时读取当前 `file_storage`,复用普通上传的 `storage_path/share/data/年/月/日/唯一标识` 路径规则,并把实际存储类型和目录保存到收件记录。续传、下载和清理使用已保存的位置,不会因寄件码切换模式而重新定位。 + +已有寄件码保留原存储类型和目录,无需数据库迁移;编辑时可以取消“自定义存储位置”切换为跟随系统。该模式只跟随存储方式和路径规则,其他上传限制仍沿用现有逻辑。 + +## 管理一致性与存储快照 + +寄件列表提供关键字搜索(名称、口令、备注和标签)、状态/存储/标签筛选、排序及本页选择。批量操作包含启用、停用、删除、修改有效期或可寄件次数;删除仅撤销上传授权,不删除关联文件。备注最多 2000 字,标签最多 12 个、每个 24 字,去空白和忽略大小写去重规则与文件管理共用。过期记录可以只维护备注,不必续期。 + +“最多可寄件次数”按每次成功分享扣减,多文件打成一个 ZIP 算一次,文本也算一次。寄件码的 expires_at 仅约束上传授权;对应取件码仍按自己的 expired_at/expired_count 生效,两者不联动。 + +迁移 011 为普通文件、分片和预签名会话补充 storage_type 快照,后续切换全站存储时下载、续传及清理使用记录中的后端。已有寄件关联可可靠补全;没有来源证据的历史普通文件保持未知,详情不再把当前设置显示为原始后端,但读写兼容路径仍需使用当前设置。此迁移不搬运用户文件,也不能自动恢复曾经切换存储后失联的文件。 + +迁移 012 为寄件码增加备注、标签和授权版本;历史口令原文和摘要保持不变。系统路径前缀与寄件自定义目录共用相对路径校验,系统仍生成日期和唯一标识子目录,寄件自定义仍使用指定目录。更新同一后端的账号、桶或根目录仍需单独迁移文件,存储类型快照不保存凭据副本。 diff --git a/docs/superpowers/plans/2026-09-17-delivery-management.md b/docs/superpowers/plans/2026-09-17-delivery-management.md new file mode 100644 index 000000000..660611189 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-delivery-management.md @@ -0,0 +1,18 @@ +# 寄件管理与文件管理统一实现计划 + +目标:按用户确认范围统一存储显示与定位、路径校验、寄件码编辑(8~32位,默认16位)、管理列表搜索筛选批量操作和备注标签。寄件授权期限与取件期限始终独立。 + +架构:复用文件管理组件、元数据规则与服务边界;普通文件和上传会话记录实际存储类型,历史未知数据不伪造来源。寄件列表保留独立实体和鉴权。 + +- [x] 存储:真实后端显示、普通文件及上传会话存储快照、下载清理使用快照、幂等迁移011(存储代理)。 +- [x] 寄件后端:搜索筛选排序、备注标签、批量启停/删除/期限和额度调整、码修改与兼容验证、迁移012(管理接口代理)。 +- [x] 寄件前端:复用原管理交互,服务类型与接口对齐,最多32位及次数文案(界面代理)。 +- [x] 公共路径:提取目录校验用于系统设置和寄件配置,保留各自生成规则(主代理)。 +- [x] 汇总审查与架构/类型/语法/构建检查,同步本地主题、更新文档;不运行测试、不提交、不部署。 + +接口约定:GET /admin/delivery/codes 参数 page/page_size/keyword/status/storage_type/tag/sort_by/sort_order;响应 items/total,item新增note/tags。PUT /codes/{id} 允许可选code(空或省略保持原码)、note/tags;创建同样允许note/tags。POST /codes/batch JSON ids/action,可选expires_at/max_uploads,action=enable/disable/delete/update;批量事务校验所有ID和额度后更新,删除不影响文件。备注最多2000字、标签最多12个每个24字并去重,与文件管理一致。 + +决策:本轮有效期不引入永久授权;保持现有未来截止时间规则,强调两类码独立。历史超过32位口令仍可验证;新建/修改最大32位,旧码未修改可保留。批量删除确认一次、服务端原子处理,不触碰取件文件。不存在可靠存储来源的旧记录保留未知,不自动迁移用户数据。 + + +验证记录:后端变更AST、路由导入、diff检查通过;前端架构/类型/生产构建通过;本地主题逐文件SHA256匹配。未运行测试、数据库迁移或浏览器交互验收,未部署、提交或推送。首次构建发现Vue多语句事件语法错误,提取filterByTag后重建通过。 From db72e9c0fee8ecc1b8852a4d4b22d2fc63c16dae Mon Sep 17 00:00:00 2001 From: dawnstamp <310845922@qq.con> Date: Sat, 19 Sep 2026 15:52:30 +0800 Subject: [PATCH 5/8] =?UTF-8?q?chore(=E5=AF=84=E4=BB=B6):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E4=B8=AA=E4=BA=BA=E5=93=81=E7=89=8C=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E4=B8=8E=E5=BC=80=E5=8F=91=E8=BF=87=E7=A8=8B=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/delivery/static/admin.html | 2 +- apps/delivery/static/admin.js | 3 ++- apps/delivery/static/delivery.html | 2 +- apps/delivery/static/entry.css | 2 +- apps/delivery/static/entry.js | 2 +- docs/guide/delivery.md | 6 +++--- .../plans/2026-09-17-delivery-management.md | 18 ------------------ readme.md | 6 +++--- 8 files changed, 12 insertions(+), 29 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-17-delivery-management.md diff --git a/apps/delivery/static/admin.html b/apps/delivery/static/admin.html index b5c976e20..f793ba345 100644 --- a/apps/delivery/static/admin.html +++ b/apps/delivery/static/admin.html @@ -8,7 +8,7 @@ -
文件驿站 FileRelay
+
文件快递柜 FileCodeBox

管理员工作台

寄件管理

用一枚口令,收齐一份资料。控制有效期、投递次数与收件目录。

diff --git a/apps/delivery/static/admin.js b/apps/delivery/static/admin.js index 2206f9716..6cf5a6100 100644 --- a/apps/delivery/static/admin.js +++ b/apps/delivery/static/admin.js @@ -1,7 +1,8 @@ import {$, api, bytes, date, cell, emptyRow, errorText, message} from './common.js'; // 管理凭证只存在当前标签页会话中;不与访客寄件凭证混用。 -const tokenKey = 'filerelay_delivery_admin_token'; +// 使用项目统一的会话键名,旧版独立页面用户需要重新登录。 +const tokenKey = 'filecodebox_delivery_admin_token'; let token = sessionStorage.getItem(tokenKey) || ''; let page = 1, filePage = 1, selected = null, listSequence = 0, fileSequence = 0; const stateNames = {active:'可投递', disabled:'已禁用', expired:'已过期', exhausted:'次数耗尽', deleted:'已删除', pending:'上传中', stored:'已收到', cleanup:'等待清理'}; diff --git a/apps/delivery/static/delivery.html b/apps/delivery/static/delivery.html index 343111f03..d73e48f23 100644 --- a/apps/delivery/static/delivery.html +++ b/apps/delivery/static/delivery.html @@ -8,7 +8,7 @@ -
文件驿站 FileRelay返回取件首页
+
文件快递柜 FileCodeBox返回取件首页

文件投递

把文件,安心交给对方。

输入对方给你的寄件码,即可上传资料。无需注册,收到的文件仅由管理员管理。

diff --git a/apps/delivery/static/entry.css b/apps/delivery/static/entry.css index 2de9e907d..210c0c004 100644 --- a/apps/delivery/static/entry.css +++ b/apps/delivery/static/entry.css @@ -1,2 +1,2 @@ /* 类名前缀隔离上游主题样式;不覆盖其导航和表单组件。 */ -.filerelay-entry{position:fixed;right:20px;bottom:24px;z-index:9999;display:flex;gap:8px;font:14px/1.4 system-ui,sans-serif}.filerelay-entry a{display:block;padding:10px 18px;border-radius:24px;background:#175c48;color:#fff;text-decoration:none;box-shadow:0 2px 8px #0002}.filerelay-entry a[hidden]{display:none}.filerelay-entry a:focus-visible{outline:3px solid #86bba6;outline-offset:3px}@media(max-width:600px){.filerelay-entry{right:12px;bottom:12px}.filerelay-entry a{padding:9px 13px}} +.filecodebox-delivery-entry{position:fixed;right:20px;bottom:24px;z-index:9999;display:flex;gap:8px;font:14px/1.4 system-ui,sans-serif}.filecodebox-delivery-entry a{display:block;padding:10px 18px;border-radius:24px;background:#175c48;color:#fff;text-decoration:none;box-shadow:0 2px 8px #0002}.filecodebox-delivery-entry a[hidden]{display:none}.filecodebox-delivery-entry a:focus-visible{outline:3px solid #86bba6;outline-offset:3px}@media(max-width:600px){.filecodebox-delivery-entry{right:12px;bottom:12px}.filecodebox-delivery-entry a{padding:9px 13px}} diff --git a/apps/delivery/static/entry.js b/apps/delivery/static/entry.js index 710802ba3..7ad7da12e 100644 --- a/apps/delivery/static/entry.js +++ b/apps/delivery/static/entry.js @@ -1,7 +1,7 @@ // 上游主题仍单独构建;使用小型入口桥接其 hash 路由,后台页面可直接进入寄件管理。 (() => { const nav = document.createElement('nav'); - nav.className = 'filerelay-entry'; nav.setAttribute('aria-label', '寄件功能'); + nav.className = 'filecodebox-delivery-entry'; nav.setAttribute('aria-label', '寄件功能'); const send = document.createElement('a'); send.href = '/delivery'; send.textContent = '凭码寄件'; nav.append(send); const manage = document.createElement('a'); manage.href = '/delivery/admin'; manage.textContent = '寄件管理'; nav.append(manage); const update = () => { manage.hidden = !window.location.hash.startsWith('#/admin'); }; diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md index 39d813428..d6a78ab3b 100644 --- a/docs/guide/delivery.md +++ b/docs/guide/delivery.md @@ -28,7 +28,7 @@ - 删除文件不恢复已经用过的上传次数。两个管理入口及自动过期任务使用相同的关联清理流程,失败残留持续计入容量,稍后自动重试。 - 文件到期或领取次数耗尽后,沿用普通文件管理每 10 分钟运行的过期清理任务;实际文件清理成功后物理删除收件记录,失败时保留记录重试。历史已清理且大小归零的收件空记录由寄件清理任务分批移除。 - 寄件码手动删除时物理删除记录;上传成功且额度耗尽、没有其他上传占用时自动清除寄件码。剩余额度仍可用的多次寄件码继续保留。已生成的普通取件码及文件不会随寄件码删除,仍按自己的期限或领取次数清理。旧撤销记录由清理任务分批回收,不再提供历史筛选入口。 -- 用户已确认:此前的私有收件保持私有,不自动生成取件码。旧客户端若未提交过期参数,也沿用旧私有投递语义;新 2024 页面始终显式提交分享过期策略。 +- 历史私有收件保持私有,不自动生成取件码。旧客户端若未提交过期参数,也沿用旧私有投递语义;新 2024 页面始终显式提交分享过期策略。 - 文件大小和类型沿用站点设置;已收文件和清理中的残留文件计入全站容量限制。 ## 存储配置 @@ -41,7 +41,7 @@ OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截 存储凭据不复制到寄件记录;如果变更同一后端的账号、桶名、根目录或服务地址,需先迁移旧文件,否则旧记录可能无法下载。远端桶和目录应保持私有,应用中的管理员权限无法覆盖第三方自行开放的公开访问设置。 -本次没有新增账号体系。`owner_id` 固定为服务端的管理员身份,客户端不能指定;将来加入子用户时需要同时补充其认证、归属及目录隔离授权。 +寄件功能不引入新的账号体系。`owner_id` 固定为服务端的管理员身份,客户端不能指定;将来加入子用户时需要同时补充其认证、归属及目录隔离授权。 ## API @@ -82,7 +82,7 @@ python -m uvicorn main:app --host 127.0.0.1 --port 12345 - `migrations_009.py` 为寄件码增加可空的 `code_value` 原文字段,保留旧摘要和已分发口令的有效性。数据库及备份包含寄件码原文,应继续使用原有权限保护。 - `migrations_010.py` 增加 `deliveryfile.share_id` 关联普通分享。旧行保持 NULL;新分享容量由 FileCodes 计算,私有收件及清理残留另计,避免同一文件重复占用配额。 - 2024 原生寄件页面在独立前端仓库,构建后更新 `themes/2024`。通过构建标记识别新页面,旧 `/delivery` 和 `/delivery/admin` 分别跳转到 `/#/delivery` 与 `/#/admin/delivery`。 -- `apps/delivery/static` 保留旧主题回退页面。2023 主题本次不开发、不重新构建;未包含原生寄件的主题继续使用该回退。 +- `apps/delivery/static` 保留旧主题回退页面。2023 版前端源码未修改;未包含原生寄件的主题继续使用该回退。 - 原主题仍由原 Docker 构建流程生成。纯源码开发时,寄件页可独立使用;原取件页需提供 `themes/2024` 或 `themes/2023` 构建产物。 - 新清理任务每分钟检查一次;心跳停止超过两小时的上传视为异常遗留,释放次数并清理文件。删除失败的记录保留为“等待清理”,继续计入容量并自动重试。 - 已存文件的后台下载优先使用浏览器文件系统接口流式保存;不支持的浏览器回退为 Blob,大文件下载的内存占用较高。 diff --git a/docs/superpowers/plans/2026-09-17-delivery-management.md b/docs/superpowers/plans/2026-09-17-delivery-management.md deleted file mode 100644 index 660611189..000000000 --- a/docs/superpowers/plans/2026-09-17-delivery-management.md +++ /dev/null @@ -1,18 +0,0 @@ -# 寄件管理与文件管理统一实现计划 - -目标:按用户确认范围统一存储显示与定位、路径校验、寄件码编辑(8~32位,默认16位)、管理列表搜索筛选批量操作和备注标签。寄件授权期限与取件期限始终独立。 - -架构:复用文件管理组件、元数据规则与服务边界;普通文件和上传会话记录实际存储类型,历史未知数据不伪造来源。寄件列表保留独立实体和鉴权。 - -- [x] 存储:真实后端显示、普通文件及上传会话存储快照、下载清理使用快照、幂等迁移011(存储代理)。 -- [x] 寄件后端:搜索筛选排序、备注标签、批量启停/删除/期限和额度调整、码修改与兼容验证、迁移012(管理接口代理)。 -- [x] 寄件前端:复用原管理交互,服务类型与接口对齐,最多32位及次数文案(界面代理)。 -- [x] 公共路径:提取目录校验用于系统设置和寄件配置,保留各自生成规则(主代理)。 -- [x] 汇总审查与架构/类型/语法/构建检查,同步本地主题、更新文档;不运行测试、不提交、不部署。 - -接口约定:GET /admin/delivery/codes 参数 page/page_size/keyword/status/storage_type/tag/sort_by/sort_order;响应 items/total,item新增note/tags。PUT /codes/{id} 允许可选code(空或省略保持原码)、note/tags;创建同样允许note/tags。POST /codes/batch JSON ids/action,可选expires_at/max_uploads,action=enable/disable/delete/update;批量事务校验所有ID和额度后更新,删除不影响文件。备注最多2000字、标签最多12个每个24字并去重,与文件管理一致。 - -决策:本轮有效期不引入永久授权;保持现有未来截止时间规则,强调两类码独立。历史超过32位口令仍可验证;新建/修改最大32位,旧码未修改可保留。批量删除确认一次、服务端原子处理,不触碰取件文件。不存在可靠存储来源的旧记录保留未知,不自动迁移用户数据。 - - -验证记录:后端变更AST、路由导入、diff检查通过;前端架构/类型/生产构建通过;本地主题逐文件SHA256匹配。未运行测试、数据库迁移或浏览器交互验收,未部署、提交或推送。首次构建发现Vue多语句事件语法错误,提取filterByTag后重建通过。 diff --git a/readme.md b/readme.md index f9a3095a8..ba92bcc1a 100644 --- a/readme.md +++ b/readme.md @@ -23,13 +23,13 @@ ## 寄件码 -本仓库在 FileCodeBox 基础上增加 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码投递到指定目录;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 +使用 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码投递到指定目录;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 - 访客投递:`/delivery` - 寄件管理:`/delivery/admin`(使用本站管理员密码) -- 后端在本仓库,2024 原生寄件页面在独立前端仓库;启动时自动执行迁移 008–010。旧主题保留独立页面回退。使用包含前后端改动的自建程序;下方上游预构建镜像不包含此扩展。 +- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–012。2023 前端源码未修改,未包含原生寄件功能的主题使用后端提供的独立页面。 -## 上游镜像快速开始 +## 一条命令开始 ```bash docker run -d --restart unless-stopped \ From 95e84fbdebb6540f64f59dd0781f93d8dc3d7993 Mon Sep 17 00:00:00 2001 From: dawnstamp <310845922@qq.con> Date: Sat, 19 Sep 2026 17:44:51 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(=E5=AF=84=E4=BB=B6):=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E6=8E=88=E6=9D=83=E4=B8=8A=E4=BC=A0=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=BB=AD=E6=9C=9F=E4=B8=8E=E6=94=B6?= =?UTF-8?q?=E4=BB=B6=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用主干上传和存储驱动,移除独立上传、影子文件表及旧主题后台。仅为寄件保留后端关联、原子次数预占和容量校验;S3 寄件使用代理上传,拒绝并清理旧直传会话。 移除摘要双存,缺少原文的旧码停用并保留收件关联。恢复普通上传、2023 配置和公共驱动的上游行为。 验证:后端完整回归 134 项通过、2 项跳过,13 个子测试通过;三种存储寄件业务链与多文件 ZIP 投递验证通过。 --- apps/admin/dependencies.py | 7 +- apps/admin/services.py | 76 +---- apps/admin/views.py | 7 +- apps/base/migrations/migrations_011.py | 4 +- apps/base/migrations/migrations_013.py | 72 +++++ apps/base/migrations/migrations_014.py | 54 ++++ apps/base/models.py | 43 +-- apps/base/pages.py | 4 - apps/base/quota.py | 37 ++- apps/base/schemas.py | 14 +- apps/base/services.py | 86 ++--- apps/base/share_storage.py | 38 +-- apps/base/tasks.py | 34 +- apps/base/upload_access.py | 141 ++++----- apps/base/upload_sessions.py | 147 +++++++++ apps/base/utils.py | 7 +- apps/base/views.py | 75 ++--- apps/delivery/schemas.py | 6 - apps/delivery/services.py | 401 +++++------------------- apps/delivery/static/admin.html | 50 --- apps/delivery/static/admin.js | 150 --------- apps/delivery/static/common.js | 21 -- apps/delivery/static/delivery.css | 5 - apps/delivery/static/delivery.html | 38 --- apps/delivery/static/delivery.js | 63 ---- apps/delivery/static/entry.css | 2 - apps/delivery/static/entry.js | 9 - apps/delivery/static/logo.svg | 2 - apps/delivery/storage.py | 101 +----- apps/delivery/views.py | 216 ++++--------- core/storage.py | 5 +- docs/guide/delivery.md | 118 ++----- main.py | 2 +- readme.md | 2 +- tests/test_delivery_authorization.py | 377 ++++++++++++++++++++++ tests/test_issue_464_admin_file_list.py | 9 + tests/test_issue_476_theme_assets.py | 5 +- tests/test_security_gaps.py | 5 +- 38 files changed, 1056 insertions(+), 1377 deletions(-) create mode 100644 apps/base/migrations/migrations_013.py create mode 100644 apps/base/migrations/migrations_014.py create mode 100644 apps/base/upload_sessions.py delete mode 100644 apps/delivery/static/admin.html delete mode 100644 apps/delivery/static/admin.js delete mode 100644 apps/delivery/static/common.js delete mode 100644 apps/delivery/static/delivery.css delete mode 100644 apps/delivery/static/delivery.html delete mode 100644 apps/delivery/static/delivery.js delete mode 100644 apps/delivery/static/entry.css delete mode 100644 apps/delivery/static/entry.js delete mode 100644 apps/delivery/static/logo.svg create mode 100644 tests/test_delivery_authorization.py 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 c80de2c58..feb451e9d 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 DeliveryCode, DeliveryFile, 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, @@ -27,7 +27,7 @@ should_skip_storage_delete, ) from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags -from apps.base.share_storage import remove_delivery_share, storage_for_share, storage_type_for_share +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 @@ -104,9 +104,7 @@ 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): - # 寄件分享在两个管理入口使用相同撤销与清理逻辑,避免重复计费或遗留可用取件码。 - if await remove_delivery_share(file_code): - return + # 寄件文件与普通文件共用删除流程,失败时保留记录供重试。 # NAS 引用只删除分享记录,不能删除原始文件。 if not should_skip_storage_delete(file_code): storage = await storage_for_share(file_code, self._file_storage) @@ -227,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 @@ -502,12 +501,9 @@ async def list_files( query = FileCodes.all() if delivery_id is not None: # 收件列表复用文件管理的数据与操作,只限定当前管理员选中的寄件码。 - if not await DeliveryCode.filter(id=delivery_id, owner_id="admin").exists(): + if not await DeliveryCode.filter(id=delivery_id).exists(): raise HTTPException(404, "寄件码不存在") - share_ids = await DeliveryFile.filter( - delivery_id=delivery_id, owner_id="admin", status="shared" - ).values_list("share_id", flat=True) - query = query.filter(id__in=share_ids) + query = query.filter(delivery_id=delivery_id) all_files = await query now = await get_now() enriched_files = [] @@ -610,7 +606,8 @@ async def _build_admin_file_item( ) data = { "id": file_code.id, - "code": file_code.code, + "code": "" if file_code.is_private else file_code.code, + "is_private": file_code.is_private, "prefix": file_code.prefix, "suffix": file_code.suffix, "uuid_file_name": file_code.uuid_file_name, @@ -685,10 +682,8 @@ async def get_file_detail(self, file_id: int): is_text=is_text, ) - # 详情展示记录实际后端;历史普通文件没有可靠来源时显示 unknown。 - actual_storage_type = await storage_type_for_share(file_code) - # 未知来源以 NULL 交给前端本地化,不能直接输出英文文案。 - display_storage_type = actual_storage_type + # 只有寄件文件展示授权后端,普通文件仍显示站点当前设置。 + display_storage_type = await storage_type_for_share(file_code) detail.update( { "filename": detail["name"], @@ -1417,7 +1412,7 @@ async def download_file(self, file_id: int): if file_code.text: return APIResponse(detail=file_code.text) else: - # 统一处理 NAS 引用路径及普通/寄件文件的存储快照。 + # 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): @@ -1432,7 +1427,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, @@ -1462,7 +1458,6 @@ async def share_local_file(self, item): suffix=suffix, uuid_file_name=local_file.file, file_path=LOCAL_REF_MARKER, - storage_type="local", size=local_file.size or 0, expired_at=expired_at, expired_count=expired_count, @@ -1477,20 +1472,6 @@ async def share_local_file(self, item): class ConfigService: - # 2023 设置页仍使用迁移前的字段名;仅在旧管理接口边界转换,存储保持 snake_case。 - LEGACY_CONFIG_FIELDS = { - "errorCount": "error_count", - "errorMinute": "error_minute", - "expireStyle": "expire_style", - "openUpload": "open_upload", - "robotsText": "robots_text", - "showAdminAddr": "show_admin_addr", - "themesChoices": "themes_choices", - "themesSelect": "themes_select", - "uploadCount": "upload_count", - "uploadMinute": "upload_minute", - "uploadSize": "upload_size", - } INT_FIELDS = { "admin_session_expire", "enable_chunk", @@ -1514,45 +1495,22 @@ class ConfigService: } FLOAT_FIELDS = {"opacity"} - def get_config(self, *, legacy: bool = False): + def get_config(self): config = dict(settings.items()) config["admin_token"] = "" for key in INTERNAL_CONFIG_KEYS: config.pop(key, None) - if legacy: - # 每个配置只返回一种键名,避免旧页面整表提交时携带两个互相冲突的值。 - for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items(): - config[old_key] = config.pop(current_key) return config async def update_config(self, data: dict): current_config = dict(settings.items()) next_config = dict(current_config) - # 必须在白名单过滤前转换,否则旧主题保存成功但主题、上传限制等实际未更新。 - normalized_data = dict(data) - for old_key, current_key in self.LEGACY_CONFIG_FIELDS.items(): - if old_key not in normalized_data: - continue - value = normalized_data.pop(old_key) - if current_key in normalized_data and normalized_data[current_key] != value: - raise HTTPException(status_code=400, detail=f"{current_key} 配置值冲突") - normalized_data[current_key] = value update_data = { key: value - for key, value in normalized_data.items() - if key in settings.default_config - and key not in INTERNAL_CONFIG_KEYS - and key != "themes_choices" # 主题清单由程序维护,兼容旧字段时也不能允许客户端改写。 + for key, value in data.items() + if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS } - # 与寄件目录采用相同规则,允许空前缀表示普通上传的默认日期目录。 - if "storage_path" in update_data: - from core.path_validation import validate_storage_directory - try: - update_data["storage_path"] = validate_storage_directory(update_data["storage_path"], allow_empty=True) - except ValueError as exc: - raise HTTPException(422, str(exc)) from None - admin_token = update_data.get("admin_token") admin_password_changed = False if admin_token is None or admin_token == "": diff --git a/apps/admin/views.py b/apps/admin/views.py index b5af97dca..2523bcca8 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -532,10 +532,7 @@ async def file_view_presets_delete_post( async def get_config( config_service: ConfigService = Depends(get_config_service), ): - # 与实际返回的主题保持一致;2024 和公共 API 继续使用标准字段,2023 设置页使用旧字段。 - from apps.base.pages import resolve_theme_root - - return APIResponse(detail=config_service.get_config(legacy=resolve_theme_root().name == "2023")) + return APIResponse(detail=config_service.get_config()) @admin_api.patch("/config/update") @@ -545,8 +542,6 @@ async def update_config( file_service: FileService = Depends(get_file_service), ): data.pop("themes_choices", None) - # 旧主题会整表提交配置,同样剔除只读主题列表。 - data.pop("themesChoices", None) await config_service.update_config(data) await file_service.record_admin_activity( action="config.update", diff --git a/apps/base/migrations/migrations_011.py b/apps/base/migrations/migrations_011.py index f421133ec..e8f757347 100644 --- a/apps/base/migrations/migrations_011.py +++ b/apps/base/migrations/migrations_011.py @@ -1,4 +1,4 @@ -"""为普通文件及上传会话保存实际存储后端快照。""" +"""仅为寄件分享保存授权指定的实际存储后端。""" from tortoise import connections @@ -8,8 +8,6 @@ async def migrate(): conn = connections.get("default") tables = { "filecodes": "storage_type", - "uploadchunk": "storage_type", - "presignuploadsession": "storage_type", } for table, column in tables.items(): columns = await conn.execute_query_dict(f"PRAGMA table_info({table})") 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/models.py b/apps/base/models.py index db464dfd4..1e4c73128 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -30,7 +30,10 @@ 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) - # 普通文件创建时固定实际后端;历史记录没有可靠来源时保留 NULL。 + # 寄件文件直接关联授权;历史私有收件仅供后台读取,不自动公开。 + 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): @@ -54,8 +57,6 @@ class UploadChunk(models.Model): chunk_size = fields.IntField() file_name = fields.CharField(max_length=255) save_path = fields.CharField(max_length=512, null=True) - # 分片会话必须固定后端,避免全站设置切换后续传或清理到错误位置。 - storage_type = fields.CharField(max_length=20, null=True) created_at = fields.DatetimeField(auto_now_add=True) completed = fields.BooleanField(default=False) @@ -79,8 +80,6 @@ class PresignUploadSession(models.Model): file_name = fields.CharField(max_length=255) file_size = fields.BigIntField() save_path = fields.CharField(max_length=512) - # 直传 URL、代理上传和会话清理均使用初始化时确定的后端。 - storage_type = fields.CharField(max_length=20, null=True) mode = fields.CharField(max_length=10) # "direct" 或 "proxy" expire_value = fields.IntField(default=1) expire_style = fields.CharField(max_length=20, default="day") @@ -99,21 +98,27 @@ 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) - code_digest = fields.CharField(max_length=64, unique=True) - # 与普通取件码一样保留原文供管理员管理;旧记录为 NULL,不能从摘要反推。 - code_value = fields.CharField(max_length=64, null=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) - owner_id = fields.CharField(max_length=64, default="admin", index=True) # system 仅标记寄件码跟随设置,实际收件记录始终保存解析后的存储类型与目录。 storage_type = fields.CharField(max_length=20) target_path = fields.CharField(max_length=200) @@ -126,26 +131,6 @@ class DeliveryCode(models.Model): created_at = fields.DatetimeField(auto_now_add=True) -class DeliveryFile(models.Model): - """寄件收件记录;pending 占用次数,stored 计入永久容量,独立于公开分享。""" - - id = fields.IntField(pk=True) - delivery_id = fields.IntField(index=True) - # 新的授权分享关联普通取件记录;NULL 表示旧版私有收件,绝不自动公开。 - share_id = fields.IntField(null=True, index=True) - owner_id = fields.CharField(max_length=64, default="admin", index=True) - token = fields.CharField(max_length=64, unique=True) - filename = fields.CharField(max_length=255, default="") - stored_name = fields.CharField(max_length=255, default="") - # system 路径可包含 share/data 日期目录及唯一文件名,长度与普通文件路径对齐。 - file_path = fields.CharField(max_length=255) - storage_type = fields.CharField(max_length=20) - size = fields.BigIntField(default=0) - status = fields.CharField(max_length=20, default="pending", index=True) - created_at = fields.DatetimeField(auto_now_add=True) - updated_at = fields.DatetimeField(auto_now=True) - - file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes") upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk") key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue") diff --git a/apps/base/pages.py b/apps/base/pages.py index 9c2e67b90..c93fa2a1f 100644 --- a/apps/base/pages.py +++ b/apps/base/pages.py @@ -97,10 +97,6 @@ async def index(request=None, exc=None): # 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") - # 原生 2024 寄件页面自行提供导航,旧主题仍保留独立入口以兼容滚动更新。 - if not theme_has_delivery_ui(template): - template = template.replace("", '' - '') return HTMLResponse( content=template .replace("{{title}}", html.escape(str(settings.name))) diff --git a/apps/base/quota.py b/apps/base/quota.py index a2940cf6c..6df9c1c82 100644 --- a/apps/base/quota.py +++ b/apps/base/quota.py @@ -5,7 +5,7 @@ from tortoise.expressions import Q from tortoise.functions import Sum -from apps.base.models import DeliveryFile, FileCodes, StorageReservation +from apps.base.models import FileCodes, StorageReservation from apps.base.local_share import LOCAL_REF_MARKER from core.settings import settings from core.utils import get_now @@ -67,17 +67,13 @@ def get_storage_limit() -> int: async def get_storage_usage() -> dict[str, int | None]: now = await get_now() # SQL 聚合:此函数在每次上传配额检查时调用,禁止全表拉取(D3) - # NAS 引用不占上传容量;私有寄件及清理残留仍需单独计入。 + # 成功文件只计 FileCodes;寄件上传残留保留在容量预留中直到删除成功。 used_rows = await owned_storage_queryset().annotate(total=Sum("size")).values("total") - # 私有收件与失败残留计入;shared 已由 FileCodes 计算,不能重复计费。 - delivery_rows = await DeliveryFile.filter(status__in=["stored", "cleanup"]).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() - used_bytes = (used_rows[0]["total"] or 0) + (delivery_rows[0]["total"] or 0) + used_bytes = used_rows[0]["total"] or 0 reserved_bytes = reserved_rows[0]["total"] or 0 return { "limit": limit, @@ -90,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 @@ -111,8 +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 deliveryfile WHERE status IN ('stored', 'cleanup')), 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]} """, @@ -135,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/schemas.py b/apps/base/schemas.py index ecf5b8112..f4cf4a405 100644 --- a/apps/base/schemas.py +++ b/apps/base/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel class SelectFileModel(BaseModel): @@ -7,15 +7,13 @@ class SelectFileModel(BaseModel): class InitChunkUploadModel(BaseModel): file_name: str - # 在计算分片数之前限制非法或过大的分片,避免除零和单片内存失控。 - chunk_size: int = Field(default=5 * 1024 * 1024, ge=1, le=5 * 1024 * 1024) - file_size: int = Field(ge=1) + chunk_size: int = 5 * 1024 * 1024 + file_size: int file_hash: str class CompleteUploadModel(BaseModel): - # 分享完成沿用正数期限,不能通过分片接口绕开表单约束。 - expire_value: int = Field(ge=1) + expire_value: int expire_style: str @@ -23,8 +21,8 @@ class CompleteUploadModel(BaseModel): class PresignUploadInitRequest(BaseModel): """预签名上传初始化请求""" file_name: str - file_size: int = Field(ge=1) - expire_value: int = Field(default=1, ge=1) + file_size: int + expire_value: int = 1 expire_style: str = "day" diff --git a/apps/base/services.py b/apps/base/services.py index d9df98646..64ea7964f 100644 --- a/apps/base/services.py +++ b/apps/base/services.py @@ -12,7 +12,7 @@ from core.logger import logger from core.settings import settings -from core.storage import FileStorageInterface, StoredDownload, StoredFile +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 @@ -42,7 +42,7 @@ 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) - # NAS 引用已在上方直接定位;其他分享复用寄件/普通文件的存储快照。 + # 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)) @@ -98,6 +98,10 @@ def chunk_reservation_ttl() -> int: class FileUploadService: """统一的文件上传服务""" + @staticmethod + def _storage() -> FileStorageInterface: + return storages[settings.file_storage]() + @staticmethod async def generate_file_path( file_name: str, upload_id: str | None = None @@ -121,23 +125,11 @@ async def create_file_record( ) prefix, suffix = os.path.splitext(file_name) - storage_type = extra_fields.pop("storage_type", None) - share_fields = { - "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, - } - # 未指定时由普通创建入口固定当前后端;NULL 不覆盖这一安全默认值。 - if storage_type: - share_fields["storage_type"] = storage_type - await create_upload_share(access, **share_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 @@ -151,7 +143,7 @@ async def create_text_share( if access is not None and access.record is not None: access.record.stored_name = "" await access.record.save(update_fields=["stored_name"]) - token = f"text:{uuid.uuid4().hex}" + 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( @@ -184,10 +176,9 @@ async def create_file_share( if delivery_path: save_path = delivery_path path, uuid_file_name = os.path.split(save_path) - token = f"file:{uuid.uuid4().hex}" + token = access.record.token if access and access.record else f"file:{uuid.uuid4().hex}" await reserve_storage(token, size, ttl_seconds=3600) - storage_type = access.record.storage_type if access is not None and access.record is not None else settings.file_storage - storage = await upload_storage(access, storage_type) + 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 @@ -203,7 +194,6 @@ async def create_file_share( expired_at=expired_at, expired_count=expired_count, used_count=used_count, - storage_type=storage_type, ) except Exception: await rollback_saved_file( @@ -224,13 +214,7 @@ async def complete_chunked_upload( 失败路径的配额释放与清理范围与原实现逐一对齐: 完整性校验失败仅抛 400(预留由 TTL 兜底);合并失败清理分片文件后抛 500。 """ - # 旧会话没有快照时只在本次开始解析一次,并在成功完成后写入文件记录。 - storage_type = chunk_info.storage_type or settings.file_storage - if chunk_info.storage_type is None: - # 合并失败后仍保留该解析结果,重试不能因设置切换而改后端。 - await UploadChunk.filter(upload_id=upload_id).update(storage_type=storage_type) - chunk_info.storage_type = storage_type - storage = await upload_storage(access, storage_type) + 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() ) @@ -241,8 +225,8 @@ async def complete_chunked_upload( if len(completed_chunks) != chunk_info.total_chunks: raise HTTPException(400, "分片不完整") - # 每片已按声明范围校验,合并时使用实际文件大小,避免将尾片向上取整。 - max_total_size = chunk_info.file_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: @@ -291,13 +275,14 @@ async def complete_chunked_upload( uuid_file_name=safe_file_name, prefix=prefix, suffix=suffix, - storage_type=storage_type, ) try: await storage.clean_chunks(upload_id, save_path) await UploadChunk.filter(upload_id=upload_id).delete() except Exception: - logger.warning("分享已创建,分片清理稍后重试 upload_id=%s", upload_id, exc_info=True) + 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": access.record.filename if access is not None and access.record is not None else safe_file_name} @@ -323,11 +308,6 @@ async def commit_proxy_upload( 校验失败不释放预留(与原实现一致,由 TTL 兜底)。 """ - # 进入代理上传前就冻结旧会话的兼容回退值,后续 await 不再读取设置。 - storage_type = session.storage_type or settings.file_storage - if session.storage_type is None: - session.storage_type = storage_type - await session.save(update_fields=["storage_type"]) await reserve_storage( f"presign:{session.upload_id}", session.file_size, @@ -336,17 +316,19 @@ 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 = await upload_storage(access, storage_type) + 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)}") return await FileUploadService._commit_presign_record( - session, file_size, storage, access=access, storage_type=storage_type, + session, file_size, storage, access=access, context="预签名代理上传:记录创建失败" ) @@ -356,11 +338,10 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s 预留失败说明配额已耗尽,此时清理远端临时文件与会话后原样抛出。 """ - # 在预留容量前冻结旧会话的兼容回退值,整条确认流程使用同一后端。 - storage_type = session.storage_type or settings.file_storage - if session.storage_type is None: - session.storage_type = storage_type - await session.save(update_fields=["storage_type"]) + 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}", @@ -368,7 +349,7 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s ttl_seconds=PRESIGN_SESSION_EXPIRES, ) except HTTPException: - storage = await upload_storage(access, storage_type) + 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( @@ -382,23 +363,22 @@ async def confirm_direct_upload(session: PresignUploadSession, access=None) -> s await release_storage(f"presign:{session.upload_id}") raise - storage = await upload_storage(access, storage_type) + 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, storage_type=storage_type, + session, session.file_size, storage, access=access, context="预签名确认:记录创建失败" ) @staticmethod - async def _commit_presign_record(session, file_size, storage, *, access=None, storage_type, context): + async def _commit_presign_record(session, file_size, storage, *, access=None, context): """代理上传与直传共用记录提交、失败回滚及会话释放,避免两条路径行为分叉。""" try: code = await FileUploadService.create_file_record( session.file_name, file_size, os.path.dirname(session.save_path), session.expire_value, session.expire_style, access=access, - storage_type=storage_type, ) except Exception: await rollback_saved_file( diff --git a/apps/base/share_storage.py b/apps/base/share_storage.py index d893e8a52..3a72182f1 100644 --- a/apps/base/share_storage.py +++ b/apps/base/share_storage.py @@ -1,45 +1,19 @@ -"""寄件生成的普通分享使用原投递后端,避免全站存储切换后找错文件。""" +"""寄件文件按授权后端定位;普通文件与 NAS 引用保持上游行为。""" -from apps.base.models import DeliveryFile from apps.base.local_share import is_local_ref from core.settings import settings from core.storage import storages -async def delivery_record(file_code): - return await DeliveryFile.filter(share_id=file_code.id).first() - - async def storage_for_share(file_code, fallback=None): - # NAS 引用固定来自本地目录,不受站点默认存储切换影响。 - if is_local_ref(file_code): - return storages["local"]() - record = await delivery_record(file_code) - if record is not None: - # 延迟导入以保持应用模块边界,沿用 OneDrive 的精确对象键适配。 - from apps.delivery.storage import get_storage - return await get_storage(record.storage_type) - # 普通文件优先使用创建时的后端快照,历史 NULL 才兼容旧行为。 - if file_code.storage_type: + 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) -> str | None: - """返回详情页可展示的实际后端;历史普通文件无来源时明确标记未知。""" +async def storage_type_for_share(file_code): + """普通详情仍显示站点设置;NAS 引用及寄件按各自来源显示。""" if is_local_ref(file_code): return "local" - record = await delivery_record(file_code) - if record is not None: - return record.storage_type - return file_code.storage_type - - -async def remove_delivery_share(file_code): - """先撤销取件记录再清理文件;失败残留继续占用容量并交由后台重试。""" - record = await delivery_record(file_code) - if record is None: - return False - from apps.delivery.services import request_file_removal - await request_file_removal(record.id) - return True + 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 c41c00288..ee8866ec4 100644 --- a/apps/base/tasks.py +++ b/apps/base/tasks.py @@ -20,14 +20,15 @@ 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.share_storage import remove_delivery_share, storage_for_share -from apps.base.local_share import should_skip_storage_delete +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(): while True: try: await refresh_settings() + file_storage: FileStorageInterface = storages[settings.file_storage]() # 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除 if settings.file_storage == "local": for root, dirs, files in os.walk(f"{data_root}/share/data"): @@ -36,26 +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 await remove_delivery_share(exp): - continue - except Exception: - # 关联清理事务失败时保留分享,下一轮重试,不能落入普通删除分支丢失关联。 - logger.warning("寄件分享过期清理失败 id=%s", exp.id, exc_info=True) - continue - try: - # 文本内容只在数据库中;NAS 引用不删原文件,其他文件按存储快照清理。 - if not should_skip_storage_delete(exp): - await (await storage_for_share(exp)).delete_file(stored_file_of(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}") - # 文件定位或后端异常时保留记录,下一轮按同一快照重试。 - continue + # 寄件保留失败记录供重试;普通文件清理不改变上游语义。 + if exp.delivery_id is not None: + continue try: await exp.delete() except Exception as e: @@ -70,6 +68,7 @@ async def clean_incomplete_uploads(): while True: try: await refresh_settings() + file_storage: FileStorageInterface = storages[settings.file_storage]() expire_hours = getattr(settings, "chunk_expire_hours", 24) now = await get_now() expire_time = now - datetime.timedelta(hours=expire_hours) @@ -84,8 +83,7 @@ async def clean_incomplete_uploads(): _, _, _, _, save_path = await get_chunk_file_path_name( session.file_name, session.upload_id ) - storage = storages[session.storage_type or settings.file_storage]() - await storage.clean_chunks(session.upload_id, save_path) + await file_storage.clean_chunks(session.upload_id, save_path) except Exception as e: logger.error( f"清理分片文件失败 upload_id={session.upload_id}: {e}" @@ -112,6 +110,7 @@ async def clean_expired_presign_sessions(): while True: try: await refresh_settings() + storage: FileStorageInterface = storages[settings.file_storage]() now = await get_now() expired_sessions = await PresignUploadSession.filter( expires_at__lt=now @@ -119,7 +118,6 @@ async def clean_expired_presign_sessions(): for session in expired_sessions: if session.mode == "direct": try: - storage = storages[session.storage_type or settings.file_storage]() if await storage.file_exists(session.save_path): await storage.delete_file( StoredFile( diff --git a/apps/base/upload_access.py b/apps/base/upload_access.py index 69da21261..9990de5e7 100644 --- a/apps/base/upload_access.py +++ b/apps/base/upload_access.py @@ -1,141 +1,116 @@ -"""普通上传的可选寄件授权层;通过显式参数传递,不修改全站配置或管理员会话。""" +"""上传鉴权依赖:只解析授权与会话归属,存储、扣次和清理由公共上传服务完成。""" import asyncio from dataclasses import dataclass +from datetime import timedelta from fastapi import Header, HTTPException, Request -from apps.base.models import DeliveryFile, FileCodes +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 apps.delivery.storage import get_storage from core.utils import get_now @dataclass class UploadAccess: - """每个请求独立的上传身份;寄件记录的 token 同时绑定普通上传会话。""" - code_id: int | None = None - record: DeliveryFile | 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)): - """校验每一步的授权和会话归属,游客模式也不能访问寄件上传会话。""" - from apps.admin.dependencies import share_required_login, verify_token - from apps.delivery.services import active_code, heartbeat, upload_identity - + """游客、管理员与寄件身份隔离,跨码或无凭证访问会话统一返回不存在。""" access = UploadAccess() if authorization and authorization.startswith("Bearer "): try: payload = verify_token(authorization[7:]) except ValueError: - raise HTTPException(401, "上传凭证无效或已过期") from None + # 非寄件身份交回上游游客/管理员规则,不能改变游客开放时的普通上传行为。 + payload = {} if payload.get("purpose") == "delivery" and not payload.get("is_admin"): - access.code_id = int(payload["delivery_id"]) + 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") - if upload_id: - record = await DeliveryFile.filter(token=upload_id).first() - if record is not None or upload_id.startswith("d_"): - if record is None or record.delivery_id != access.code_id: - raise HTTPException(404, "上传会话不存在") - if record.status not in {"pending", "shared"}: - raise HTTPException(409, "上传会话正在完成或清理,请稍后重试") - access.record = record - elif access.code_id is not None: - raise HTTPException(404, "上传会话不属于该寄件码") - - completion_request = ( - "/complete/" in request.url.path or "/confirm/" in request.url.path or "/proxy/" in request.url.path - ) - # 耗尽口令已被回收时,原凭证只可取回自己已完成会话的结果,不能启动或续传文件。 - completed_retry = completion_request and access.record is not None and access.record.status == "shared" - if access.code_id is not None and not completed_retry: - # 新上传和续传必须校验寄件码版本;改码后旧临时凭证不能再投递。 - access.code_id = await upload_identity(authorization) + 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 = False - if access.record is not None and access.record.status == "pending" and completion_request: - changed = await DeliveryFile.filter(id=access.record.id, status="pending").update(status="finalizing", updated_at=await get_now()) - if changed != 1: - raise HTTPException(409, "上传正在完成,请稍后重试") - access.record.status = "finalizing" - finalizing = True - # 长时间传输续租;新会话在初始化后由后续分片请求续租,空闲会话由后台回收。 - task = None - if access.record is not None and access.record.status in {"pending", "finalizing"}: - await DeliveryFile.filter(id=access.record.id).update(updated_at=await get_now()) - task = asyncio.create_task(heartbeat(access.record)) + 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 is not None: + if not upload_id and access.record: await asyncio.shield(abort_access(access)) raise finally: if finalizing: - # 失败仍可重新提交完成请求;成功记录已变为 shared,不会被回退。 - await DeliveryFile.filter(id=access.record.id, status="finalizing").update(status="pending", updated_at=await get_now()) + 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 - from apps.delivery.services import reserve_slot, normalize_delivery_filename - - record = await reserve_slot(access.code_id) - # 先绑定记录,后续文件名处理或会话保存失败时依赖退出逻辑仍能释放占用。 - access.record = record - name = await normalize_delivery_filename(file_name) - record.token = "d_" + upload_id - record.filename = name - record.stored_name = record.token + "_" + name - record.size = file_size - await record.save() - return record.token, f"{record.file_path}/{record.stored_name}" - - -async def upload_storage(access=None, storage_type: str | None = 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: - from apps.delivery.storage import get_storage return await get_storage(access.record.storage_type) - # 普通会话一旦创建即使用其快照,NULL 仅兼容升级前尚未完成的会话。 - return storages[storage_type or settings.file_storage]() + return storages[settings.file_storage]() async def create_upload_share(access=None, **fields): - """共用普通取件记录;寄件扣次与关联记录提交必须处于同一事务。""" if access is None or access.record is None: - # 普通单文件上传在创建记录的同一时刻固化实际存储类型。 - fields.setdefault("storage_type", settings.file_storage) + # 普通文件保持上游行为,不记录公共存储快照。 + fields.pop("storage_type", None) return await FileCodes.create(**fields) - # 授权适配层只传递身份,扣次及私有/公开收件事务集中在寄件业务层。 - from apps.delivery.services import commit_delivery return await commit_delivery(access.record, fields) -async def abort_access(access): - """取消或初始化失败释放寄件占用,实际残留交由原清理流程重试。""" +async def abort_access(access, *, cleanup_after=None): if access is not None and access.record is not None: - from apps.delivery.services import abort_upload - await abort_upload(access.record.id) + await abort_upload(access.record.id, cleanup_after=cleanup_after) async def completed_upload(access): - """完成响应丢失后允许按原会话取回结果,不再合并、覆盖文件或重复扣次。""" - if access is None or access.record is None or access.record.status != "shared": + """响应丢失后按成功文件的会话标识重试,不重复写文件或扣次。""" + if access is None or access.completed is None: return None - share = await FileCodes.filter(id=access.record.share_id).first() - if share is None or await share.is_expired(): + share = access.completed + if await share.is_expired(): raise HTTPException(410, "该上传的文件已过期") - return {"code": share.code, "name": access.record.filename} + 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..d616b949e --- /dev/null +++ b/apps/base/upload_sessions.py @@ -0,0 +1,147 @@ +"""寄件上传的短期预留:复用容量记录,成功文件只进入 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 +from apps.delivery.storage import get_storage, validate_storage_config +from core.utils import get_now, sanitize_filename + +STALE_SECONDS = 7200 + + +async def reserve_slot(code_id, version, file_name, upload_id): + """次数原子预占与会话创建同事务;实际字节随后由共用配额入口预留。""" + filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1]) + filename = filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file" + 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, "寄件码已失效或没有剩余上传次数") + code = await DeliveryCode.get(id=code_id).using_db(conn) + storage_type, path = code.storage_type, code.target_path + if storage_type == "system": + storage_type = settings.file_storage + path, *_ = await build_file_path("delivery", upload_id) + validate_storage_config(storage_type) + token = "d_" + upload_id + if code.storage_type != "system": + path = f"{path}/{token}" + return await StorageReservation.create( + token=token, size=0, delivery_id=code_id, auth_version=version, + filename=filename, stored_name=token + "_" + filename, + 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 = await get_storage(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 = await get_storage(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/utils.py b/apps/base/utils.py index 720173030..6ac03895c 100644 --- a/apps/base/utils.py +++ b/apps/base/utils.py @@ -9,7 +9,6 @@ from apps.base.dependencies import IPRateLimit from apps.base.models import FileCodes from core.settings import settings -from core.path_validation import validate_storage_directory from core.utils import ( get_random_num, get_random_string, @@ -35,11 +34,7 @@ async def build_file_path( Always use get_now() (UTC+8); do not switch to server-local time. """ today = await get_now() - # 与寄件自定义目录共用校验;此处仍保留普通上传的日期及唯一标识目录。 - try: - storage_path = validate_storage_directory(settings.storage_path, allow_empty=True) - except ValueError as exc: - raise HTTPException(422, f"系统文件存储路径无效,请在设置中修正:{exc}") from None + storage_path = settings.storage_path.strip("/") filename = await sanitize_filename(unquote(file_name or "")) base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" path = f"{storage_path}/{base_path}" if storage_path else base_path diff --git a/apps/base/views.py b/apps/base/views.py index e8b14f88a..1b587b56b 100644 --- a/apps/base/views.py +++ b/apps/base/views.py @@ -13,10 +13,10 @@ from tortoise.expressions import Case, F, Q, When from apps.base.upload_access import UploadAccess, authorize_upload, prepare_upload, upload_storage, abort_access, completed_upload -from apps.base.models import DeliveryFile +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 delivery_record, storage_for_share +from apps.base.share_storage import storage_for_share from apps.base.services import ( PRESIGN_SESSION_EXPIRES, FileUploadService, @@ -103,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: @@ -157,7 +157,7 @@ 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) or await delivery_record(file_code) is not None: + 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: @@ -299,9 +299,12 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun access = access or UploadAccess() safe_file_name = await sanitize_filename(unquote(data.file_name or "")) validate_file_type(safe_file_name) - # 使用文件真实声明大小校验上限,最后一个分片通常小于整片大小。 + # 新增校验仅约束寄件授权,避免无效大小影响次数预占或容量计算。 + 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 - if data.file_size > settings.upload_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" @@ -309,7 +312,7 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun # 断点续传按寄件码隔离;普通上传不能恢复凭码创建的会话。 if access.code_id is not None: - tokens = await DeliveryFile.filter(delivery_id=access.code_id, status="pending").values_list("token", flat=True) + 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_") @@ -322,7 +325,7 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun if existing_session: if access.code_id is not None: - access.record = await DeliveryFile.get(token=existing_session.upload_id) + 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() @@ -350,8 +353,6 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun ) # 创建新的上传会话 - # 普通会话在任何异步准备前先固定后端,避免设置切换穿透到本次会话。 - initial_storage_type = settings.file_storage 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}" @@ -363,8 +364,6 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun data.file_name, upload_id ) try: - # 普通分片会话在初始化时固定后端,寄件会话沿用收件记录的实际后端。 - storage_type = access.record.storage_type if access is not None and access.record is not None else initial_storage_type await UploadChunk.create( upload_id=upload_id, chunk_index=-1, @@ -374,7 +373,6 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun chunk_hash=data.file_hash, file_name=safe_file_name, save_path=delivery_path or save_path, - storage_type=storage_type, ) except Exception: await abort_access(access) @@ -432,9 +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) - 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, "分片大小与声明的文件范围不一致") + 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: @@ -460,12 +460,7 @@ async def upload_chunk( save_path = chunk_info.save_path # 保存分片到存储 - # 旧会话首次续传后补写快照;本请求及之后都使用同一个后端。 - storage_type = chunk_info.storage_type or settings.file_storage - if chunk_info.storage_type is None: - await UploadChunk.filter(upload_id=upload_id).update(storage_type=storage_type) - chunk_info.storage_type = storage_type - storage = await upload_storage(access, storage_type) + storage = await upload_storage(access) try: await storage.save_chunk( upload_id, chunk_index, chunk_data, chunk_hash, save_path @@ -487,7 +482,6 @@ async def upload_chunk( "chunk_size": chunk_info.chunk_size, "file_name": chunk_info.file_name, "save_path": chunk_info.save_path, - "storage_type": storage_type, }, ) return APIResponse(detail={"chunk_hash": chunk_hash}) @@ -506,7 +500,7 @@ async def cancel_upload(upload_id: str, access: Annotated[UploadAccess, Depends( save_path = chunk_info.save_path # 清理存储中的临时文件 - storage = await upload_storage(access, chunk_info.storage_type or settings.file_storage) + storage = await upload_storage(access) if save_path: try: await storage.clean_chunks(upload_id, save_path) @@ -564,6 +558,8 @@ async def complete_upload( 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, access=access ) @@ -591,19 +587,9 @@ async def _get_valid_session( if not session: raise HTTPException(404, "上传会话不存在") if await session.is_expired(): - # 请求触发的过期处理也必须使用会话快照,不能等待后台任务误用新设置。 - if session.mode == "direct": - storage = await upload_storage(access, session.storage_type) - try: - if await storage.file_exists(session.save_path): - await storage.delete_file( - StoredFile( - file_path=os.path.dirname(session.save_path), - uuid_file_name=os.path.basename(session.save_path), - ) - ) - except Exception: - logger.warning("过期预签名会话:清理临时文件失败 upload_id=%s", upload_id, exc_info=True) + if access and access.record: + # 寄件过期仍须释放次数并跟踪残留;普通会话沿用原清理方式。 + await abort_access(access) await session.delete() await release_storage(f"presign:{upload_id}") raise HTTPException(404, "上传会话已过期") @@ -625,8 +611,8 @@ async def presign_upload_init( ) validate_expire_style(data.expire_style) - # 普通预签名会话在准备寄件记录和远端 URL 前固定实际后端。 - initial_storage_type = settings.file_storage + 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}" @@ -639,10 +625,10 @@ async def presign_upload_init( ) if delivery_path: save_path = delivery_path - # 预签名会话保存快照,后续代理、确认和清理不会受全站设置影响。 - storage_type = access.record.storage_type if access is not None and access.record is not None else initial_storage_type - storage: FileStorageInterface = await upload_storage(access, storage_type) - presigned_url = await storage.generate_presigned_upload_url( + 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" @@ -657,7 +643,6 @@ async def presign_upload_init( expire_value=data.expire_value, expire_style=data.expire_style, expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES), - storage_type=storage_type, ) except Exception: await abort_access(access) @@ -742,7 +727,7 @@ async def presign_upload_cancel(upload_id: str, access: Annotated[UploadAccess, raise HTTPException(404, "上传会话不存在") if session.mode == "direct": - storage: FileStorageInterface = await upload_storage(access, session.storage_type) + storage: FileStorageInterface = await upload_storage(access) try: if await storage.file_exists(session.save_path): temp_file_code = StoredFile( diff --git a/apps/delivery/schemas.py b/apps/delivery/schemas.py index 8ea66bebc..55d5fc98a 100644 --- a/apps/delivery/schemas.py +++ b/apps/delivery/schemas.py @@ -174,9 +174,3 @@ class VerifyDeliveryCode(BaseModel): class SetDeliveryEnabled(BaseModel): model_config = ConfigDict(extra="forbid") enabled: bool - - -class DeliveryShareOptions(BaseModel): - """上传选择的过期策略仍受全站白名单和最长保存时间约束。""" - expire_style: str = Field(min_length=1, max_length=20) - expire_value: int = Field(default=1, ge=1, le=1000000) diff --git a/apps/delivery/services.py b/apps/delivery/services.py index 3d391c3cb..d3c5082fa 100644 --- a/apps/delivery/services.py +++ b/apps/delivery/services.py @@ -1,13 +1,7 @@ -"""寄件业务:独立权限、数据库次数预占、存储落盘和失败回收。""" +"""寄件授权与管理:口令校验、令牌续期、数据库分页和配置更新。""" -import asyncio -import hashlib -import hmac import json -import os import secrets -import uuid -from datetime import timedelta from fastapi import HTTPException from tortoise.exceptions import IntegrityError @@ -15,26 +9,13 @@ from tortoise.transactions import in_transaction from apps.admin.dependencies import create_token, verify_token -from apps.base.file_validation import validate_upload_file -from apps.base.models import DeliveryCode, DeliveryFile, FileCodes, KeyValue, UploadChunk, PresignUploadSession, StorageReservation -from apps.base.utils import build_file_path, get_expire_info, validate_expire_style -from apps.base.quota import _sql_placeholders, reserve_storage -from apps.delivery.storage import get_storage, validate_storage_config -from core.logger import logger +from apps.base.models import DeliveryCode, StorageReservation +from apps.base.quota import _sql_placeholders +from apps.delivery.storage import validate_storage_config from core.settings import settings -from core.storage import StoredFile -from core.utils import get_now, sanitize_filename +from core.utils import get_now TOKEN_TTL = 900 -STALE_SECONDS = 7200 - - -def code_digest(code: str) -> str: - """校验使用带服务端密钥的摘要,与管理员读取原文的用途分开。""" - secret = str(settings.jwt_secret) - if not secret: - raise HTTPException(503, "系统签名密钥尚未初始化") - return hmac.new(secret.encode(), ("delivery-code:" + code).encode(), hashlib.sha256).hexdigest() async def upload_identity(authorization: str | None) -> int: @@ -48,7 +29,7 @@ async def upload_identity(authorization: str | None) -> int: code_id = int(payload["delivery_id"]) # 历史令牌按初始版本 1 解释;改码后同样会立即失效。 token_version = payload.get("delivery_version", 1) - record = await DeliveryCode.filter(id=code_id, owner_id="admin", deleted=False).first() + 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 @@ -57,7 +38,7 @@ async def upload_identity(authorization: str | None) -> int: async def active_code(code_id: int) -> DeliveryCode: - record = await DeliveryCode.filter(id=code_id, owner_id="admin", enabled=True, deleted=False).first() + 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 @@ -69,7 +50,7 @@ async def create_code(data): code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16)) try: record = await DeliveryCode.create( - code_digest=code_digest(code), code_value=code, name=data.name, owner_id="admin", + code_value=code, name=data.name, storage_type=data.storage_type, target_path=data.target_path, expires_at=data.expires_at, max_uploads=data.max_uploads, note=data.note, tags=data.tags, @@ -79,21 +60,21 @@ async def create_code(data): return {"item": await code_summary(record), "code": code} -async def code_summary(record): +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" - elif record.used_count >= record.max_uploads: - state = "exhausted" return { "id": record.id, "name": record.name, "storage_type": record.storage_type, - "code": record.code_value, + **({"code": record.code_value} if include_code else {}), "note": record.note, "tags": record.tags if isinstance(record.tags, list) else [], "target_path": record.target_path, "expires_at": record.expires_at, "max_uploads": record.max_uploads, "used_count": record.used_count, @@ -104,273 +85,44 @@ async def code_summary(record): async def verify_code(code: str): - record = await DeliveryCode.filter(code_digest=code_digest(code)).first() + 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 DeliveryFile.filter(delivery_id=record.id, status="pending", token__startswith="d_").exists(): + if remaining <= 0 and not await StorageReservation.filter(delivery_id=record.id, status__in=["pending", "finalizing"]).exists(): raise HTTPException(409, "可上传次数已耗尽或正在使用,请联系管理员") - # 旧码无法离线还原;持有者成功验证时补存其原码,不修改口令或重新生成。 - if record.code_value is None: - await DeliveryCode.filter(id=record.id, code_value__isnull=True).update(code_value=code) - # 凭证仅含寄件 ID,不携带 is_admin、目标路径或下载口令。 - token = create_token( - {"purpose": "delivery", "delivery_id": record.id, "delivery_version": record.auth_version}, - expires_in=TOKEN_TTL, - ) - return { - "token": token, "expires_in": TOKEN_TTL, "name": record.name, - "remaining": remaining, "expires_at": record.expires_at, - "upload_size": settings.upload_size, "allowed_file_types": settings.allowed_file_types, - "expire_style": settings.expire_style, "max_save_seconds": settings.max_save_seconds, - "enable_chunk": settings.enable_chunk, - } - - -async def reserve_slot(code_id: int): - """数据库原子预占最后一次上传;不以进程内锁代替跨 worker 并发控制。""" - async with in_transaction() as conn: - now = await get_now() - p = _sql_placeholders(2) - count, _ = await conn.execute_query( - f"UPDATE deliverycode SET reserved_count = reserved_count + 1 " - f"WHERE id = {p[0]} AND expires_at > {p[1]} AND owner_id = 'admin' " - "AND enabled = 1 AND deleted = 0 AND used_count + reserved_count < max_uploads", - [code_id, now], - ) - if count != 1: - raise HTTPException(409, "寄件码已失效或没有剩余上传次数") - code = await DeliveryCode.get(id=code_id).using_db(conn) - # 只在新上传预占时解析系统配置并快照;续传、下载和清理沿用记录中的实际位置。 - storage_type = code.storage_type - target_path = code.target_path - if storage_type == "system": - storage_type = settings.file_storage - validate_storage_config(storage_type) - target_path, *_ = await build_file_path("delivery", uuid.uuid4().hex) - return await DeliveryFile.create( - delivery_id=code.id, owner_id="admin", token=uuid.uuid4().hex, - file_path=target_path, storage_type=storage_type, using_db=conn, - ) - - -def stored_file(record): - """显示名保留,磁盘/对象键使用唯一名称,禁止同名覆盖。""" - prefix, suffix = os.path.splitext(record.filename) - return StoredFile(file_path=record.file_path, uuid_file_name=record.stored_name, prefix=prefix, suffix=suffix) - - -async def store_upload(record, file, *, expire_style=None, expire_value=1): - """继承类型与容量限制;只在落盘和记录事务都成功后扣减成功次数。""" - # 传入过期策略即明确请求生成取件码;旧客户端未传策略时仍保持私有收件语义。 - if expire_style is not None: - validate_expire_style(expire_style) - await get_expire_info(expire_value, expire_style) - await validate_upload_file(file) - size = file.size - if size is None: - file.file.seek(0, 2) - size = file.file.tell() - await file.seek(0) - if size > int(settings.upload_size): - raise HTTPException(413, "文件大小超过站点限制") - filename = await normalize_delivery_filename(file.filename) - record.filename = filename - record.stored_name = f"{record.token}_{filename}" - record.size = size - await reserve_storage("delivery:" + record.token, size, STALE_SECONDS) - await record.save(update_fields=["filename", "stored_name", "size", "updated_at"]) - storage = await get_storage(record.storage_type) - # shield 防止本地后台写线程被取消后仍写入已经关闭的临时文件。 - writing = asyncio.create_task(storage.save_file(file.file, f"{record.file_path}/{record.stored_name}", file.content_type)) - try: - await asyncio.shield(writing) - except asyncio.CancelledError: - try: - await writing - except Exception: - logger.warning("被取消的寄件写入失败 id=%s", record.id, exc_info=True) - raise - # 兼容接口仅负责构造分享字段,扣次和关联提交与普通上传共用同一事务。 - share_fields = None - if expire_style is not None: - expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style) - share_fields = dict(code=code, size=size, expired_at=expired_at, - expired_count=expired_count, used_count=used_count) - share = await commit_delivery(record, share_fields) - result = {"name": filename, "size": size, "message": "投递成功"} - if share is not None: - result.update(code=share.code, expired_at=share.expired_at, expired_count=share.expired_count) - return result - - -async def heartbeat(record): - """有效传输定期续租,进程异常退出后遗留记录才会被回收。""" - while True: - await asyncio.sleep(30) - changed = await DeliveryFile.filter(id=record.id, status__in=["pending", "finalizing"]).update(updated_at=await get_now()) - if not changed: - return - # 大文件慢速传输期间维持容量预留,避免 TTL 到期使其他上传超配额。 - await StorageReservation.filter(token__in=reservation_tokens(record.token)).update( - expires_at=await get_now() + timedelta(seconds=STALE_SECONDS) - ) - - -async def abort_upload(record_id: int, *, stale_before=None): - """先释放次数,再保留 cleanup 记录跟踪残留文件,清理成功后释放占用容量。""" - async with in_transaction() as conn: - query = DeliveryFile.filter(id=record_id, status__in=["pending", "finalizing"]) - if stale_before is not None: - query = query.filter(updated_at__lt=stale_before) - changed = await query.using_db(conn).update(status="cleanup", updated_at=await get_now()) - if changed: - record = await DeliveryFile.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 - ) - # 转为 cleanup 后由收件记录计费,统一释放所有上传预留。 - await StorageReservation.filter(token__in=reservation_tokens(record.token)).using_db(conn).delete() - await clean_file(record_id) + return await session_summary(record) -async def clean_file(record_id): - """实际文件删除成功后物理删除收件记录;失败时保留记录计费并重试。""" - record = await DeliveryFile.filter(id=record_id, status="cleanup").first() - if not record: - return - try: - if record.stored_name: - storage = await get_storage(record.storage_type) - # 复用普通上传的会话时,临时分片也必须按寄件存储回收,失败保留记录重试。 - if record.token.startswith("d_"): - await storage.clean_chunks(record.token, f"{record.file_path}/{record.stored_name}") - await storage.delete_file(stored_file(record)) - if record.token.startswith("d_"): - await UploadChunk.filter(upload_id=record.token).delete() - await PresignUploadSession.filter(upload_id=record.token).delete() - await StorageReservation.filter(token__in=reservation_tokens(record.token)).delete() - # 与普通文件管理一致,清理成功后不保留已删除文件的历史空壳。 - await DeliveryFile.filter(id=record.id, status="cleanup").delete() - except Exception: - logger.warning("寄件文件清理失败,将自动重试 id=%s", record.id, exc_info=True) - - -async def cleanup_once(): - """有限批次回收崩溃残留,避免任务持有大量 ORM 对象。""" - # 兼容清理旧撤销记录及历史耗尽口令,不删除关联的分享或实际文件。 - code_ids = await recyclable_codes().limit(100).values_list("id", flat=True) - if code_ids: - await recyclable_codes().filter(id__in=code_ids).delete() - before = await get_now() - timedelta(seconds=STALE_SECONDS) - for record in await DeliveryFile.filter(status__in=["pending", "finalizing"], updated_at__lt=before).limit(100): - await abort_upload(record.id, stale_before=before) - for record in await DeliveryFile.filter(status="cleanup").limit(100): - await clean_file(record.id) - # 分享创建成功后若分片回收失败,继续清理临时分片,不撤销已经生成的取件码。 - tokens = await UploadChunk.filter(chunk_index=-1, upload_id__startswith="d_").limit(100).values_list("upload_id", flat=True) - for record in await DeliveryFile.filter(token__in=tokens, status="shared"): - try: - storage = await get_storage(record.storage_type) - await storage.clean_chunks(record.token, f"{record.file_path}/{record.stored_name}") - await UploadChunk.filter(upload_id=record.token).delete() - except Exception: - logger.warning("寄件分享临时分片清理失败 id=%s", record.id, exc_info=True) - # 旧版本仅在物理清理成功后标记 deleted 并将大小归零,分批移除这些历史空壳。 - deleted_ids = await DeliveryFile.filter(status="deleted", size=0).limit(100).values_list("id", flat=True) - if deleted_ids: - await DeliveryFile.filter(id__in=deleted_ids, status="deleted", size=0).delete() - - -async def cleanup_loop(): - while True: - try: - await cleanup_once() - except Exception: - logger.warning("寄件清理任务异常,下轮继续", exc_info=True) - await asyncio.sleep(60) - - -async def request_file_removal(record_id): - """普通文件管理、寄件管理和过期清理共用撤销流程,容量始终只计算一次。""" - async with in_transaction() as conn: - record = await DeliveryFile.filter(id=record_id).using_db(conn).first() - # 管理员删除与自动过期可能同时触发;已被另一流程清除时视为完成。 - if record is None: - return - if record.status in {"pending", "finalizing"}: - raise HTTPException(409, "文件正在上传,请稍后再试") - if record.share_id is not None: - await FileCodes.filter(id=record.share_id).using_db(conn).delete() - # 元数据清理由原文件服务管理;这里仅清除对应记录,不能触碰其他分享。 - await KeyValue.filter(key=f"admin_file_metadata:{record.share_id}").using_db(conn).delete() - await DeliveryFile.filter(id=record_id, status__in=["stored", "shared"]).using_db(conn).update( - status="cleanup", updated_at=await get_now() - ) - await clean_file(record_id) - - - -def reservation_tokens(token): - """集中定义寄件会话的容量键,续租、提交和取消不会漏掉某一种上传方式。""" - tokens = ["delivery:" + token] - if token.startswith("d_"): - tokens.extend(["chunk:" + token, "presign:" + token]) - return tokens - - -async def commit_delivery(record, share_fields=None): - """统一提交寄件扣次;无分享字段时保留旧客户端的私有收件语义。""" - async with in_transaction() as conn: - changed = await DeliveryFile.filter(id=record.id, status__in=["pending", "finalizing"]).using_db(conn).update( - status="shared" if share_fields is not None else "stored", updated_at=await get_now() - ) - if changed != 1: - raise HTTPException(409, "该上传已完成或已被清理") - changed = await DeliveryCode.filter( - id=record.delivery_id, 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, "寄件码在上传期间失效") - share = None - if share_fields is not None: - fields = dict(share_fields) - # 取件记录保存寄件时解析后的存储类型,后续读取和清理不依赖可变全局设置。 - fields["storage_type"] = record.storage_type - # 存储名保证唯一,显示名、文本分享和原取件规则保持不变。 - if "text" not in fields: - fields["prefix"], fields["suffix"] = os.path.splitext(record.filename) - fields["file_path"] = record.file_path - fields["uuid_file_name"] = record.stored_name - share = await FileCodes.create(using_db=conn, **fields) - await DeliveryFile.filter(id=record.id).using_db(conn).update(share_id=share.id, size=share.size) - await StorageReservation.filter(token__in=reservation_tokens(record.token)).using_db(conn).delete() - # 最后一次成功上传与口令回收同事务提交;尚有额度或上传占用时保留。 - await recyclable_codes().filter(id=record.delivery_id).using_db(conn).delete() - return share - - - -async def normalize_delivery_filename(file_name): - """新旧上传统一清理显示名,并预留唯一前缀所需的文件系统字节空间。""" - filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1]) - return filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file" +async def session_summary(record): + """验证与续期使用相同白名单响应,不泄露路径、存储密钥或管理员授权。""" + 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, "upload_size": settings.upload_size, + "allowed_file_types": settings.allowed_file_types, "expire_style": settings.expire_style, + "max_save_seconds": settings.max_save_seconds, "enable_chunk": settings.enable_chunk, + } -def recyclable_codes(): - """历史撤销口令与无上传占用的耗尽口令可回收,不清理仍可使用的多次口令。""" - return DeliveryCode.filter(owner_id="admin").filter( - Q(deleted=True) | Q(reserved_count=0, used_count__gte=F("max_uploads")) - ) +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, owner_id="admin", deleted=False).using_db(conn).first() + 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) @@ -397,7 +149,7 @@ async def update_code(code_id, data): changes["target_path"] = target_path if new_code and new_code != record.code_value: # 版本由数据库递增,两个改码请求并发时任一旧令牌都不会被错误复用。 - changes.update(code_digest=code_digest(new_code), code_value=new_code, auth_version=True) + changes.update(code_value=new_code, auth_version=True) if changes: try: # 条件写入把已用和预占次数与配置修改放入同一语句,避免并发上传越过新额度。 @@ -408,7 +160,7 @@ async def update_code(code_id, data): assignments += ", auth_version = auth_version + 1" changed, _ = await conn.execute_query( f"UPDATE deliverycode SET {assignments} WHERE id = {placeholders[-2]} " - f"AND owner_id = 'admin' AND deleted = 0 " + 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], @@ -421,47 +173,55 @@ async def update_code(code_id, data): return await code_summary(record) -async def list_codes(*, keyword="", status="all", storage_type="all", tag="", sort_by="created_at", sort_order="desc"): - """在服务层统一筛选与排序,分页前得到的 total 与列表结果保持一致。""" +async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_type="all", tag="", sort_by="created_at", sort_order="desc"): + """筛选、计数、排序和分页全部在数据库执行,列表不返回口令原文。""" if status not in {"all", "active", "disabled", "expired", "exhausted"}: raise HTTPException(400, "不支持的寄件码状态筛选") if storage_type not in {"all", "system", "local", "s3", "webdav"}: raise HTTPException(400, "不支持的存储类型筛选") - if sort_by not in {"created_at", "expires_at", "name", "code", "used_count", "max_uploads"} or sort_order not in {"asc", "desc"}: + if sort_by not in {"created_at", "expires_at", "name", "used_count", "max_uploads"} or sort_order not in {"asc", "desc"}: raise HTTPException(400, "不支持的排序方式") - items = [await code_summary(record) for record in await DeliveryCode.filter(owner_id="admin", deleted=False)] - keyword, tag = keyword.strip().lower(), tag.strip().lower() - def matched(item): - if status != "all" and item["status"] != status: - return False - if storage_type != "all" and item["storage_type"] != storage_type: - return False - if tag and tag not in {str(value).lower() for value in item["tags"]}: - return False - values = [item["name"], item["code"], item["note"], *item["tags"]] - return not keyword or any(keyword in str(value).lower() for value in values if value) - items = [item for item in items if matched(item)] - def sort_value(item): - value = item.get(sort_by) - if sort_by in {"created_at", "expires_at"}: - primary = value.timestamp() if value is not None else float("-inf") - elif sort_by in {"used_count", "max_uploads"}: - primary = int(value or 0) - else: - primary = str(value or "").casefold() - return primary, item["id"] - items.sort(key=sort_value, reverse=sort_order == "desc") - return items + 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 storage_type != "all": + query = query.filter(storage_type=storage_type) + 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", "storage_type", "target_path", "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, owner_id="admin", deleted=False).using_db(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)) + 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: @@ -471,7 +231,7 @@ async def batch_codes(data): if invalid: raise HTTPException(409, "上传次数不能小于已使用和上传中占用次数,受影响寄件码:" + "、".join(invalid)) if data.action == "delete": - await DeliveryCode.filter(id__in=data.ids, owner_id="admin").using_db(conn).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": @@ -480,19 +240,22 @@ async def batch_codes(data): if data.max_uploads is not None: changes["max_uploads"] = data.max_uploads # 批量条件写入必须覆盖全部记录,防止并发上传使其中一个新额度失效。 - placeholders = _sql_placeholders(len(changes) + len(data.ids)) + placeholders = _sql_placeholders(len(changes) + len(data.ids) + 1) assignments = ", ".join(f"{field} = {placeholders[index]}" for index, field in enumerate(changes)) - id_placeholders = ", ".join(placeholders[len(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[list(changes).index('max_uploads')]}" + 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 owner_id = 'admin' AND deleted = 0{condition}", values, + f"AND deleted = 0{condition}", values, ) if changed != len(data.ids): raise HTTPException(409, "寄件码状态已变化,请刷新后重试") - result = await DeliveryCode.filter(id__in=data.ids, owner_id="admin", deleted=False).using_db(conn) + 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/static/admin.html b/apps/delivery/static/admin.html deleted file mode 100644 index f793ba345..000000000 --- a/apps/delivery/static/admin.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - 寄件管理 · 文件驿站 - - - - - -
文件快递柜 FileCodeBox
-
-

管理员工作台

寄件管理

用一枚口令,收齐一份资料。控制有效期、投递次数与收件目录。

- -

- -
- - diff --git a/apps/delivery/static/admin.js b/apps/delivery/static/admin.js deleted file mode 100644 index 6cf5a6100..000000000 --- a/apps/delivery/static/admin.js +++ /dev/null @@ -1,150 +0,0 @@ -import {$, api, bytes, date, cell, emptyRow, errorText, message} from './common.js'; - -// 管理凭证只存在当前标签页会话中;不与访客寄件凭证混用。 -// 使用项目统一的会话键名,旧版独立页面用户需要重新登录。 -const tokenKey = 'filecodebox_delivery_admin_token'; -let token = sessionStorage.getItem(tokenKey) || ''; -let page = 1, filePage = 1, selected = null, listSequence = 0, fileSequence = 0; -const stateNames = {active:'可投递', disabled:'已禁用', expired:'已过期', exhausted:'次数耗尽', deleted:'已删除', pending:'上传中', stored:'已收到', cleanup:'等待清理'}; -function authScreen(loggedIn) { - $('workspace').hidden = !loggedIn; $('login-panel').hidden = loggedIn; $('logout').hidden = !loggedIn; - if (!loggedIn) { $('codes').replaceChildren(); $('received-files').replaceChildren(); $('files-panel').hidden = true; $('created').hidden = true; $('created-code').textContent = ''; } -} -function report(error) { - if (error.status === 401) { token = ''; sessionStorage.removeItem(tokenKey); authScreen(false); } - message($('message'), error.message, true); -} -const request = (path, options = {}) => api('/admin/delivery' + path, {...options, token}); -function action(container, label, run, danger = false) { - const button = document.createElement('button'); button.textContent = label; button.className = `quiet${danger ? ' danger' : ''}`; - button.type = 'button'; container.append(button); - button.addEventListener('click', async () => { button.disabled = true; try { await run(); } catch (error) { report(error); } finally { button.disabled = false; } }); -} -function pagination(total, current, previous, next, info) { - const pages = Math.max(1, Math.ceil(total / 20)); $(previous).disabled = current <= 1; $(next).disabled = current >= pages; - $(info).textContent = `${current} / ${pages} 页 · 共 ${total} 条`; -} -async function loadCodes() { - const sequence = ++listSequence; - // 已删除寄件码不再作为可筛选的历史记录保留。 - const result = await request(`/codes?page=${page}`); - if (sequence !== listSequence) return; - if (!result.items.length && page > 1) { page--; return loadCodes(); } - $('codes').replaceChildren(); - if (!result.items.length) emptyRow($('codes'), 6, '还没有寄件码。创建后即可邀请对方投递。'); - for (const item of result.items) { - const row = $('codes').insertRow(); - cell(row, `${item.name} #${item.id}`); cell(row, item.storage_type === 'system' ? '跟随系统设置' : `${item.storage_type} · ${item.target_path}`); cell(row, date(item.expires_at)); - const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = stateNames[item.status] || item.status; row.insertCell().append(badge); - cell(row, `${item.used_count} / ${item.reserved_count} / ${item.max_uploads}`); - const actions = row.insertCell(); actions.className = 'actions'; - action(actions, '查看收件', async () => { selected = item; filePage = 1; await loadFiles(); $('files-panel').scrollIntoView({behavior:'smooth', block:'start'}); }); - if (!item.deleted) { - action(actions, item.enabled ? '禁用' : '启用', async () => { await request(`/codes/${item.id}`, {method:'PATCH', json:{enabled:!item.enabled}}); await loadCodes(); }); - action(actions, '删除', async () => { - if (!window.confirm(`删除“${item.name}”的寄件码?会撤销投递权限,但保留已收到的文件。`)) return; - await request(`/codes/${item.id}`, {method:'DELETE'}); message($('message'), '寄件码已永久删除,已生成的取件码仍可正常使用。'); await loadCodes(); - }, true); - } - } - pagination(result.total, page, 'previous', 'next', 'page-info'); -} -async function download(item) { - // 支持文件系统接口的浏览器直接流式写盘,其他浏览器回退到 Blob 下载。 - let handle; - if ('showSaveFilePicker' in window) { - try { handle = await window.showSaveFilePicker({suggestedName:item.filename}); } - catch (error) { if (error.name === 'AbortError') return; throw error; } - } - const response = await fetch(`/admin/delivery/files/${item.id}/download`, {headers:{Authorization:`Bearer ${token}`}, cache:'no-store'}); - if (!response.ok) { const error = new Error(errorText(await response.json().catch(() => ({})))); error.status=response.status; throw error; } - if (handle) { const writable = await handle.createWritable(); await response.body.pipeTo(writable); return; } - const url = URL.createObjectURL(await response.blob()); - const link = document.createElement('a'); link.href=url; link.download=item.filename; document.body.append(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60000); -} -async function loadFiles() { - if (!selected) return; - const selection = selected; const sequence = ++fileSequence; - const result = await request(`/codes/${selection.id}/files?page=${filePage}`); - if (sequence !== fileSequence || selected.id !== selection.id) return; - if (!result.items.length && filePage > 1) { filePage--; return loadFiles(); } - $('files-panel').hidden=false; $('files-title').textContent=`${selection.name} · 收到的文件`; - $('received-files').replaceChildren(); - if (!result.items.length) emptyRow($('received-files'), 5, '尚未收到文件。'); - for (const item of result.items) { - const row=$('received-files').insertRow(); cell(row, item.filename || '正在接收'); cell(row, bytes(item.size)); cell(row, date(item.created_at)); cell(row, stateNames[item.status] || item.status); - const actions=row.insertCell(); actions.className='actions'; - if (item.status === 'stored') action(actions, '下载', () => download(item)); - if (item.status !== 'pending') action(actions, '删除文件', async () => { - if (!window.confirm(`确定永久删除文件“${item.filename}”?此操作不能撤销。`)) return; - await request(`/files/${item.id}`, {method:'DELETE'}); await loadFiles(); - }, true); - } - pagination(result.total, filePage, 'files-previous', 'files-next', 'files-page-info'); -} -function eventAction(id, event, handler) { $(id).addEventListener(event, () => Promise.resolve().then(handler).catch(report)); } -$('login-form').addEventListener('submit', async event => { - event.preventDefault(); const button=event.submitter; button.disabled=true; - try { const session=await api('/admin/login', {method:'POST', json:{password:$('password').value}}); token=session.token; sessionStorage.setItem(tokenKey,token); authScreen(true); message($('message'),'登录成功'); await loadCodes(); } - catch(error) { report(error); } - finally { button.disabled=false; $('password').value=''; } -}); -$('create-form').addEventListener('submit', async event => { - event.preventDefault(); const button=event.submitter; button.disabled=true; $('created').hidden=true; - try { - const result=await request('/codes', {method:'POST', json:{name:$('name').value, code:$('new-code').value.trim(), storage_type:$('storage').value, target_path:$('storage').value === 'system' ? '' : $('target').value, expires_at:new Date($('expires').value).toISOString(), max_uploads:Number($('maximum').value)}}); - $('created-code').textContent=result.code; $('created').hidden=false; $('copy-message').textContent=''; $('new-code').value=''; - page=1; message($('message'),'寄件码已创建,请保存下方口令并发给投递人。'); await loadCodes(); - } catch(error) { report(error); } finally { button.disabled=false; } -}); -// HTTP 下的兼容复制必须直接在点击回调中触发,避免额外异步调度丢失用户手势。 -$('copy-code').addEventListener('click', copyCreatedCode); -eventAction('logout','click',() => { token=''; sessionStorage.removeItem(tokenKey); authScreen(false); message($('message'),'已退出当前寄件管理会话。'); }); -eventAction('refresh','click',loadCodes); -eventAction('previous','click',() => {page=Math.max(1,page-1); return loadCodes();}); eventAction('next','click',() => {page++; return loadCodes();}); -eventAction('refresh-files','click',loadFiles); eventAction('files-previous','click',() => {filePage=Math.max(1,filePage-1); return loadFiles();}); eventAction('files-next','click',() => {filePage++; return loadFiles();}); -// datetime-local 使用本机时区显示,提交时转成带时区的 ISO 日期。 -const nextWeek=new Date(Date.now()+7*86400000); nextWeek.setMinutes(nextWeek.getMinutes()-nextWeek.getTimezoneOffset()); $('expires').value=nextWeek.toISOString().slice(0,16); -authScreen(false); -if (token) { api('/admin/verify',{token}).then(async () => {authScreen(true); await loadCodes();}).catch(report); } - -async function copyCreatedCode() { - const source = $('created-code'); - const text = source.textContent.trim(); - const notice = $('copy-message'); - if (!text) { notice.textContent = '请先创建寄件码。'; return; } - // 新版剪贴板 API 仅在 HTTPS 等安全上下文可用;权限拒绝时继续尝试兼容路径。 - if (window.isSecureContext && navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text); - notice.textContent = '已复制,可发给投递人。'; - return; - } catch { /* 浏览器或嵌入页面可能拒绝权限,保留下方手动复制能力。 */ } - } - let selected = false; - try { - // 直接选中页面上的口令,不创建隐藏输入框,也不复制其他页面内容。 - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(source); - selection.removeAllRanges(); - selection.addRange(range); - selected = selection.toString() === text; - // 旧接口虽已弃用,但仍可为部分 HTTP 浏览器提供降级支持;不能保证所有浏览器允许。 - if (selected && document.execCommand('copy')) { - notice.textContent = '已复制,可发给投递人。'; - return; - } - } catch { /* 禁止脚本复制时仍显示明确的手动操作说明,不误报成功。 */ } - notice.textContent = selected - ? '已选中寄件码,请按 Ctrl+C(Mac 为 ⌘C),或长按选中文字后复制。' - : '请选中上方寄件码,按 Ctrl+C(Mac 为 ⌘C),或长按文字复制。'; -} - -// 跟随系统模式隐藏目录输入;切回自定义后恢复必填,避免隐藏字段阻止提交。 -$('storage').addEventListener('change', () => { - const custom = $('storage').value !== 'system'; - $('target-label').hidden = !custom; - $('target').required = custom; -}); diff --git a/apps/delivery/static/common.js b/apps/delivery/static/common.js deleted file mode 100644 index f2c7dd414..000000000 --- a/apps/delivery/static/common.js +++ /dev/null @@ -1,21 +0,0 @@ -// 所有服务端/文件名文本都通过 textContent 渲染,禁止将不可信内容拼接为 HTML。 -export const $ = (id) => document.getElementById(id); -export function message(node, text, error = false) { node.textContent = text; node.className = `message ${error ? 'error' : 'success'}`; } -export function bytes(size) { if (size < 1024) return `${size} B`; const units = ['KiB','MiB','GiB']; let n = size / 1024, i = 0; while (n >= 1024 && i < 2) { n /= 1024; i++; } return `${n.toFixed(1)} ${units[i]}`; } -export function date(value) { return value ? new Date(value).toLocaleString() : '—'; } -export function errorText(body) { - const detail = body?.detail; - if (Array.isArray(detail)) return detail.map(item => item.msg).join(';'); - return typeof detail === 'string' ? detail : body?.message || '请求失败,请稍后重试'; -} -export async function api(path, {token, json, ...options} = {}) { - const headers = new Headers(options.headers); - if (token) headers.set('Authorization', `Bearer ${token}`); - if (json !== undefined) { headers.set('Content-Type', 'application/json'); options.body = JSON.stringify(json); } - const response = await fetch(path, {...options, headers, cache: 'no-store'}); - const body = await response.json().catch(() => ({})); - if (!response.ok) { const error = new Error(errorText(body)); error.status = response.status; throw error; } - return body.detail; -} -export function cell(row, text) { const node = row.insertCell(); node.textContent = text; return node; } -export function emptyRow(body, count, text) { body.replaceChildren(); const node = body.insertRow().insertCell(); node.colSpan = count; node.className = 'empty'; node.textContent = text; } diff --git a/apps/delivery/static/delivery.css b/apps/delivery/static/delivery.css deleted file mode 100644 index bff84a1ad..000000000 --- a/apps/delivery/static/delivery.css +++ /dev/null @@ -1,5 +0,0 @@ -/* 新功能保持轻量且独立于上游主题,使用相同的绿色强调色和清晰表单层级。 */ -:root{color-scheme:light;--ink:#1c322a;--muted:#61736b;--green:#175c48;--line:#dbe5df;--bg:#f4f7f3} -*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 system-ui,-apple-system,"Microsoft YaHei",sans-serif}a{color:var(--green);text-underline-offset:4px}button,input,select{font:inherit}button{background:var(--green);color:#fff;border:1px solid var(--green);border-radius:9px;padding:9px 17px;cursor:pointer;white-space:nowrap}button:disabled{opacity:.5;cursor:not-allowed}button.quiet{background:white;color:var(--green);border-color:var(--line)}button.danger{color:#a33128}button:hover:not(:disabled){filter:brightness(.94)}:focus-visible{outline:3px solid #86bba6;outline-offset:3px}[hidden]{display:none!important}.shell{width:min(1160px,calc(100% - 48px));margin:auto}.header{min-height:88px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-bottom:1px solid var(--line)}.brand{font-size:21px;font-weight:750;color:var(--ink);text-decoration:none}.brand span{font-size:13px;font-weight:500;margin-left:8px;color:var(--muted)}nav{display:flex;gap:20px;align-items:center}main{padding:36px 0 60px}.visitor{max-width:780px}.eyebrow{color:var(--green);font-size:12px;letter-spacing:2px;margin:4px 0 8px}h1{font-size:clamp(28px,4vw,38px);line-height:1.3;letter-spacing:-1px;margin:0 0 16px}h2{font-size:19px;margin:0 0 20px}.intro{color:var(--muted);margin:0 0 28px;max-width:680px}.card{background:white;border:1px solid var(--line);border-radius:16px;padding:26px;margin-bottom:22px;box-shadow:0 3px 10px #203a2a03}.row{display:flex;gap:14px;align-items:end}.grow{flex:1}.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px 24px}.full{grid-column:1/-1}label{display:block;font-size:14px;font-weight:600}input:not([type=checkbox]),select{display:block;width:100%;border:1px solid #cbd7d0;border-radius:8px;background:#fff;color:var(--ink);padding:10px 12px;margin-top:7px;min-height:44px}input[type=file]{border:0;background:transparent;padding:8px 0}input[type=checkbox]{accent-color:var(--green)}.muted,.footnote{color:var(--muted);font-size:13px}.footnote{text-align:center}.message{min-height:0;margin:14px 0 0;overflow-wrap:anywhere}.message:empty{display:none}.error{color:#aa3129}.success{color:#176647}.step{display:inline-grid;place-items:center;border-radius:50%;background:#eaf3ee;color:var(--green);width:30px;height:30px;font-size:14px;margin-right:8px}.file-picker{border:1px dashed #9eb5a8;border-radius:12px;padding:22px;background:#f9fbf8}.file-picker span{display:block;font-weight:400;font-size:13px;color:var(--muted)}progress{display:block;width:100%;height:14px;margin:10px 0;accent-color:var(--green)}#progress-box{margin-top:24px}.results{padding:0;list-style:none}.results li{padding:10px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.login{max-width:480px}.login button{margin-top:18px}.section-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:16px}.section-head h2{margin:0}.inline{display:flex;align-items:center;gap:7px;font-weight:400}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px;text-align:left}th{color:var(--muted);font-weight:500;background:#f8faf7;white-space:nowrap}th,td{padding:13px 12px;border-bottom:1px solid var(--line);vertical-align:top}td{overflow-wrap:anywhere;max-width:280px}.actions{display:flex;gap:7px;flex-wrap:wrap}.actions button{font-size:12px;padding:5px 9px}.badge{display:inline-block;background:#edf4ef;padding:2px 8px;border-radius:6px;white-space:nowrap}.pagination{display:flex;justify-content:flex-end;align-items:center;gap:14px;margin-top:18px;font-size:13px}.notice{padding:18px;background:#edf6ee;border-radius:10px;margin-top:20px}code{font-family:ui-monospace,monospace;font-size:20px;letter-spacing:1px;overflow-wrap:anywhere}.empty{text-align:center;color:var(--muted);padding:28px} -/* 小屏表单按一列排列,表格保留横向滚动而不截断管理操作。 */ -@media(max-width:700px){.shell{width:calc(100% - 28px)}.header{min-height:78px;align-items:flex-start;padding:20px 0;flex-direction:column;gap:10px}nav{flex-wrap:wrap;font-size:13px;gap:14px}main{padding-top:26px}.card{padding:20px 16px}.grid{grid-template-columns:1fr}.row,.section-head{align-items:stretch;flex-direction:column}.section-head .row{flex-direction:row;align-items:center;justify-content:space-between}.pagination{justify-content:center}.brand span{margin-left:4px}} diff --git a/apps/delivery/static/delivery.html b/apps/delivery/static/delivery.html deleted file mode 100644 index d73e48f23..000000000 --- a/apps/delivery/static/delivery.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - 凭码寄件 · 文件驿站 - - - - - -
文件快递柜 FileCodeBox返回取件首页
-
-

文件投递

把文件,安心交给对方。

-

输入对方给你的寄件码,即可上传资料。无需注册,收到的文件仅由管理员管理。

-
-

1 验证寄件码

-
- - -
-

-
- -

寄件码只用于上传。投递成功后如需取回文件,请联系接收方。

-
- - diff --git a/apps/delivery/static/delivery.js b/apps/delivery/static/delivery.js deleted file mode 100644 index 490568fc1..000000000 --- a/apps/delivery/static/delivery.js +++ /dev/null @@ -1,63 +0,0 @@ -import {$, api, bytes, date, errorText, message} from './common.js'; - -// 访客凭证只保存在当前页面内存,不进入 URL、Cookie 或浏览器持久化存储。 -let session = null; -let busy = false; -function describe() { $('delivery-info').textContent = `${session.name} · 剩余 ${session.remaining} 次 · 单文件上限 ${bytes(session.upload_size)} · 有效期至 ${date(session.expires_at)}`; } -$('verify-form').addEventListener('submit', async (event) => { - event.preventDefault(); if (busy) return; - $('verify-button').disabled = true; - session = null; $('upload-section').hidden = true; - try { - session = await api('/api/delivery/verify', {method:'POST', json:{code:$('code').value.trim()}}); - describe(); $('upload-section').hidden = false; - message($('verify-message'), '验证通过,可以投递文件。'); - } catch (error) { message($('verify-message'), error.message, true); } - finally { $('verify-button').disabled = false; } -}); -$('files').addEventListener('change', () => { - const files = [...$('files').files]; - $('selection-info').textContent = `已选择 ${files.length} 个文件,共 ${bytes(files.reduce((total,file) => total+file.size,0))}`; -}); -function upload(file) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/delivery/upload'); - xhr.setRequestHeader('Authorization', `Bearer ${session.token}`); - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) $('progress').value = event.loaded / event.total * 100; - $('progress-text').textContent = `${file.name} · ${Math.round($('progress').value)}%${$('progress').value >= 100 ? ',正在保存' : ''}`; - }; - xhr.onload = () => { - let data; try { data = JSON.parse(xhr.responseText); } catch { data = {}; } - if (xhr.status >= 200 && xhr.status < 300) resolve(data.detail); - else reject(new Error(errorText(data))); - }; - xhr.onerror = () => reject(new Error('网络连接中断,结果尚未确认。请联系接收方或重新验证剩余次数,勿直接重复投递。')); - const form = new FormData(); form.append('file', file); xhr.send(form); - }); -} -$('upload-form').addEventListener('submit', async (event) => { - event.preventDefault(); if (!session || busy) return; - const files = [...$('files').files]; - if (!files.length) return; - if (files.length > session.remaining) { message($('upload-message'), '所选文件数超过剩余次数,请减少文件或联系管理员。', true); return; } - if (files.some(file => file.size > session.upload_size)) { message($('upload-message'), '所选文件中有文件超过单文件大小限制。', true); return; } - busy = true; $('upload-button').disabled = true; $('verify-button').disabled = true; $('files').disabled = true; - $('progress-box').hidden = false; $('results').replaceChildren(); message($('upload-message'), '正在投递,请保持页面打开。'); - let failed = false; - try { - for (const file of files) { - $('progress').value = 0; - const result = document.createElement('li'); $('results').append(result); - result.textContent = `${file.name} · 正在上传`; - try { await upload(file); session.remaining--; describe(); result.textContent = `${file.name} · 投递成功`; result.className = 'success'; } - catch (error) { result.textContent = `${file.name} · ${error.message}`; result.className = 'error'; failed = true; break; } - } - message($('upload-message'), failed ? '后续文件已停止上传。成功的文件无需重传;请重新选择未成功的文件。' : '全部文件投递成功,接收方可以在后台查看。', failed); - // 每次批次后清空选择,避免再次点击时把已成功文件重复提交。 - $('files').value = ''; $('selection-info').textContent = ''; - } finally { - busy = false; $('upload-button').disabled = false; $('verify-button').disabled = false; $('files').disabled = false; $('progress-box').hidden = true; - } -}); diff --git a/apps/delivery/static/entry.css b/apps/delivery/static/entry.css deleted file mode 100644 index 210c0c004..000000000 --- a/apps/delivery/static/entry.css +++ /dev/null @@ -1,2 +0,0 @@ -/* 类名前缀隔离上游主题样式;不覆盖其导航和表单组件。 */ -.filecodebox-delivery-entry{position:fixed;right:20px;bottom:24px;z-index:9999;display:flex;gap:8px;font:14px/1.4 system-ui,sans-serif}.filecodebox-delivery-entry a{display:block;padding:10px 18px;border-radius:24px;background:#175c48;color:#fff;text-decoration:none;box-shadow:0 2px 8px #0002}.filecodebox-delivery-entry a[hidden]{display:none}.filecodebox-delivery-entry a:focus-visible{outline:3px solid #86bba6;outline-offset:3px}@media(max-width:600px){.filecodebox-delivery-entry{right:12px;bottom:12px}.filecodebox-delivery-entry a{padding:9px 13px}} diff --git a/apps/delivery/static/entry.js b/apps/delivery/static/entry.js deleted file mode 100644 index 7ad7da12e..000000000 --- a/apps/delivery/static/entry.js +++ /dev/null @@ -1,9 +0,0 @@ -// 上游主题仍单独构建;使用小型入口桥接其 hash 路由,后台页面可直接进入寄件管理。 -(() => { - const nav = document.createElement('nav'); - nav.className = 'filecodebox-delivery-entry'; nav.setAttribute('aria-label', '寄件功能'); - const send = document.createElement('a'); send.href = '/delivery'; send.textContent = '凭码寄件'; nav.append(send); - const manage = document.createElement('a'); manage.href = '/delivery/admin'; manage.textContent = '寄件管理'; nav.append(manage); - const update = () => { manage.hidden = !window.location.hash.startsWith('#/admin'); }; - window.addEventListener('hashchange', update); update(); document.body.append(nav); -})(); diff --git a/apps/delivery/static/logo.svg b/apps/delivery/static/logo.svg deleted file mode 100644 index cf13c3c1a..000000000 --- a/apps/delivery/static/logo.svg +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/apps/delivery/storage.py b/apps/delivery/storage.py index 53a27015d..026285ae6 100644 --- a/apps/delivery/storage.py +++ b/apps/delivery/storage.py @@ -1,28 +1,20 @@ -"""寄件专用存储边界:创建前检查配置,OneDrive 使用精确对象路径和私有下载。""" - -import asyncio -import importlib.util -import shutil -import tempfile -from pathlib import Path, PurePosixPath -from urllib.parse import quote, urlparse +"""寄件仅验证已配置的存储类型,文件读写直接复用上游驱动。""" +from urllib.parse import urlparse from fastapi import HTTPException -from starlette.background import BackgroundTask - from core.settings import settings -from core.storage import OneDriveFileStorage, StoredDownload, storages +from core.storage import storages def validate_storage_config(kind): - """检查本机已有配置和可选依赖,不把缺少配置的后端包装成可用寄件码。""" + """复用站点存储配置,缺少必要配置时不发放新的寄件授权。""" required = { "local": [], "s3": ["s3_access_key_id", "s3_secret_access_key", "s3_bucket_name"], "webdav": ["webdav_url"], - "onedrive": ["onedrive_domain", "onedrive_client_id", "onedrive_username", "onedrive_password", "onedrive_root_path"], - "opendal": ["opendal_scheme"], } + if kind not in {"local", "s3", "webdav"}: + raise HTTPException(422, "寄件仅支持本地、S3 和 WebDAV 存储") missing = [key for key in required[kind] if not str(getattr(settings, key, "") or "").strip()] if missing: raise HTTPException(422, f"请先在站点后台配置 {kind}:" + "、".join(missing)) @@ -31,87 +23,8 @@ def validate_storage_config(kind): parsed = urlparse(url) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise HTTPException(422, f"请先配置有效的 {kind} 服务地址") - modules = {"onedrive": ["msal", "office365"], "opendal": ["opendal"]} - if any(importlib.util.find_spec(module) is None for module in modules.get(kind, [])): - packages = "msal 和 Office365-REST-Python-Client" if kind == "onedrive" else "opendal" - raise HTTPException(422, "服务器缺少可选存储依赖,请先安装 " + packages) - if kind == "opendal": - prefix = "opendal_" + str(settings.opendal_scheme) + "_" - if settings.opendal_scheme == "memory" or not any(key.startswith(prefix) and value for key, value in settings.items()): - raise HTTPException(422, "请先配置 OpenDAL 持久化存储参数,寄件不支持 memory 后端") async def get_storage(kind): - # OneDrive 构造器会请求 Graph,放入线程以免阻塞其他用户的上传与租约心跳。 - if kind == "onedrive": - return await asyncio.to_thread(DeliveryOneDriveStorage) + """不包装或重写驱动行为;历史记录读取也交由已有驱动处理。""" return storages[kind]() - - -class DeliveryOneDriveStorage(OneDriveFileStorage): - """独立适配已有 SDK,避免旧分享目录约定及匿名分享链接影响私有寄件。""" - - async def save_file(self, stream, save_path, content_type=None): - # SDK 的 upload_file 同时支持小文件和大文件上传会话,调用对象必须是父目录。 - def upload(): - path = PurePosixPath(save_path) - folder = self.root_path - for part in path.parent.parts: - try: - folder = folder.get_by_path(part).get().execute_query() - except self._ClientRequestException as exc: - if exc.code != "itemNotFound": - raise - parent = folder - try: - parent.create_folder(part).execute_query() - except self._ClientRequestException as conflict: - if conflict.code not in {"nameAlreadyExists", "itemAlreadyExists"}: - raise - # SDK 可能对同名目录自动改名;重新按指定路径读取,避免文件落到别处。 - folder = parent.get_by_path(part).get().execute_query() - with tempfile.TemporaryDirectory(prefix="fr-") as temporary: - local = Path(temporary) / path.name - with local.open("wb") as target: - shutil.copyfileobj(stream, target, length=256 * 1024) - folder.upload_file(str(local)).execute_query() - await asyncio.to_thread(upload) - - def _delete(self, save_path): - # 删除完整文件对象,不截去扩展名,也不递归删除存放其他寄件的目标目录。 - try: - self.root_path.get_by_path(str(save_path).replace("\\", "/")).delete_object().execute_query() - except self._ClientRequestException as exc: - if exc.code != "itemNotFound": - raise - - async def get_file_response(self, file_code): - # 通过已认证 Graph 会话下载到临时文件,不创建 anonymous 分享权限。 - temporary = tempfile.TemporaryFile(mode="w+b") - def fetch(): - self.root_path.get_by_path(file_code.get_file_path()).download_session(temporary).execute_query() - size = temporary.tell() - temporary.seek(0) - return size - downloading = asyncio.create_task(asyncio.to_thread(fetch)) - try: - size = await asyncio.shield(downloading) - except BaseException: - # 线程操作结束后再关闭句柄,避免断线导致后台线程写入已关闭文件。 - try: - await downloading - finally: - temporary.close() - raise - async def stream(): - try: - while chunk := await asyncio.to_thread(temporary.read, 256 * 1024): - yield chunk - finally: - temporary.close() - name = file_code.prefix + file_code.suffix - return StoredDownload( - filename=name, - headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name, safe='')}", "Content-Length": str(size)}, - stream_factory=stream, background=BackgroundTask(temporary.close), - ) diff --git a/apps/delivery/views.py b/apps/delivery/views.py index 684342f1f..d7d5c1656 100644 --- a/apps/delivery/views.py +++ b/apps/delivery/views.py @@ -1,36 +1,27 @@ -"""寄件 HTTP 边界:鉴权先于读取上传体,管理接口不接受寄件凭证。""" - -import asyncio -from pathlib import Path +"""寄件只提供授权和后台配置,文件上传及管理复用普通文件接口。""" from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response -from fastapi.responses import FileResponse, RedirectResponse -from starlette.datastructures import UploadFile -from pydantic import ValidationError +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, DeliveryFile, FileCodes +from apps.base.models import DeliveryCode from apps.base.pages import theme_has_delivery_ui -from apps.base.services import response_from_download from apps.base.utils import ip_limit from apps.delivery import services -from apps.delivery.schemas import BatchDeliveryCodes, CreateDeliveryCode, SetDeliveryEnabled, UpdateDeliveryCode, VerifyDeliveryCode, DeliveryShareOptions -from apps.delivery.storage import get_storage -from core.errors import StorageError -from core.logger import logger +from apps.delivery.schemas import BatchDeliveryCodes, CreateDeliveryCode, SetDeliveryEnabled, UpdateDeliveryCode, VerifyDeliveryCode from core.response import APIResponse -from core.settings import settings -def private_admin_response(response: Response): - # 管理列表现在含口令原文,禁止浏览器或中间代理缓存响应。 + +def private_response(response: Response): + # 授权响应和后台凭证都不得进入浏览器或中间代理缓存。 response.headers["Cache-Control"] = "no-store" -public_api = APIRouter(prefix="/api/delivery", tags=["寄件投递"]) -admin_api = APIRouter(prefix="/admin/delivery", tags=["寄件管理"], dependencies=[Depends(get_admin_session), Depends(private_admin_response)]) +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() -STATIC = Path(__file__).parent / "static" @public_api.post("/verify") @@ -47,52 +38,10 @@ async def verify(data: VerifyDeliveryCode, request: Request): return APIResponse(detail=result) -@public_api.post("/upload") -async def upload(request: Request, authorization: str | None = Header(default=None)): - code_id = await services.upload_identity(authorization) - await services.active_code(code_id) - ip = ip_limit["upload"](request) - ip_limit["upload"].add_ip(ip) - record = await services.reserve_slot(code_id) - heartbeat = asyncio.create_task(services.heartbeat(record)) - try: - # 手动读取 multipart,使无权限请求在磁盘缓冲之前被拒绝;流式计算真实请求体上限。 - limit = max(0, int(settings.upload_size)) + 1024 * 1024 - received = 0 - - async def limited_receive(): - nonlocal received - message = await request.receive() - received += len(message.get("body", b"")) - if received > limit: - raise HTTPException(413, "上传请求超过站点大小限制") - return message - - bounded = Request(request.scope, receive=limited_receive) - async with bounded.form(max_files=1, max_fields=2) as form: - file = form.get("file") - if (not isinstance(file, UploadFile) or len(form.multi_items()) != len(form) - or set(form) - {"file", "expire_style", "expire_value"}): - raise HTTPException(422, "请求只能包含一个文件及过期参数") - options = {} - if "expire_style" in form or "expire_value" in form: - try: - options = DeliveryShareOptions.model_validate({ - "expire_style": form.get("expire_style"), "expire_value": form.get("expire_value", 1) - }).model_dump() - except ValidationError: - raise HTTPException(422, "请选择有效的过期方式和正整数期限") from None - result = await services.store_upload(record, file, **options) - return APIResponse(detail=result) - except StorageError: - # 存储错误可能含目标 URL 或远端信息,不向访客返回原始异常。 - logger.warning("寄件存储失败 id=%s", record.id, exc_info=True) - raise HTTPException(503, "文件保存失败,请稍后重试或联系管理员") from None - finally: - heartbeat.cancel() - await asyncio.gather(heartbeat, return_exceptions=True) - # 无论 HTTP 异常、断线或写入失败,都释放未完成的次数并跟踪文件清理。 - await asyncio.shield(services.abort_upload(record.id)) +@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") @@ -100,10 +49,10 @@ async def list_codes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str = "", status: str = "all", storage_type: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc", ): - # 在切片前统一完成筛选排序,total 与当前查询条件下的项目数一致。 - items = await services.list_codes(keyword=keyword, status=status, storage_type=storage_type, tag=tag, sort_by=sort_by, sort_order=sort_order) - start = (page - 1) * page_size - return APIResponse(detail={"items": items[start:start + page_size], "total": len(items)}) + return APIResponse(detail=await services.list_codes( + page=page, page_size=page_size, keyword=keyword, status=status, storage_type=storage_type, + tag=tag, sort_by=sort_by, sort_order=sort_order, + )) @admin_api.post("/codes", status_code=201) @@ -113,7 +62,11 @@ async def create(data: CreateDeliveryCode): @admin_api.patch("/codes/{code_id}") async def toggle(code_id: int, data: SetDeliveryEnabled): - changed = await DeliveryCode.filter(id=code_id, owner_id="admin", deleted=False).update(enabled=data.enabled) + # 缺少原文的历史码必须先重新设码,不能仅通过启用按钮恢复旧凭证。 + 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))) @@ -121,111 +74,50 @@ async def toggle(code_id: int, data: SetDeliveryEnabled): @admin_api.delete("/codes/{code_id}") async def delete_code(code_id: int): - # 寄件码物理删除;收件记录保留存储映射,已生成的普通取件码继续独立有效。 - changed = await DeliveryCode.filter(id=code_id, owner_id="admin").delete() + # 软删除保留收件关联;普通取件码生命周期独立,不撤销已完成文件。 + 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}/files") -async def list_files(code_id: int, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), unshared_only: bool = False): - if not await DeliveryCode.filter(id=code_id, owner_id="admin").exists(): - raise HTTPException(404, "寄件码不存在") - query = DeliveryFile.filter(delivery_id=code_id, owner_id="admin").exclude(status="deleted") - # 已生成分享的收件使用文件管理列表;兼容区仅保留私有收件和上传/清理中的记录。 - if unshared_only: - query = query.exclude(status="shared") - total = await query.count() - items = await query.order_by("-id").offset((page - 1) * page_size).limit(page_size).values( - "id", "filename", "size", "status", "storage_type", "created_at", "share_id" - ) - # 使用当前分享信息,原后台修改取件码或有效期后这里同步显示。 - shares = await FileCodes.filter(id__in=[item["share_id"] for item in items if item["share_id"] is not None]) - by_id = {share.id: share for share in shares} - for item in items: - share = by_id.get(item["share_id"]) - item["retrieval_code"] = share.code if share else None - item["expired_at"] = share.expired_at if share else None - item["expired_count"] = share.expired_count if share else None - if share is not None and await share.is_expired(): - item["status"] = "expired" - return APIResponse(detail={"items": items, "total": total}) - - -@admin_api.get("/files/{file_id}/download") -async def download(file_id: int): - record = await DeliveryFile.filter(id=file_id, owner_id="admin", status__in=["stored", "shared"]).first() - if not record: - raise HTTPException(404, "收件文件不存在或尚未完成") - # 文本寄件直接复用普通文本分享,不尝试从存储读取不存在的文件对象。 - if record.share_id is not None: - share = await FileCodes.filter(id=record.share_id).first() - if share is not None and share.text is not None: - return Response(content=share.text, media_type="text/plain", headers={ - "Cache-Control": "no-store", "Content-Disposition": 'attachment; filename="Text.txt"', - "X-Content-Type-Options": "nosniff", - }) - storage = await get_storage(record.storage_type) - response = response_from_download(await storage.get_file_response(services.stored_file(record))) - response.headers["Cache-Control"] = "no-store" - response.headers["X-Content-Type-Options"] = "nosniff" - return response - - -@admin_api.delete("/files/{file_id}") -async def delete_file(file_id: int): - record = await DeliveryFile.filter(id=file_id, owner_id="admin").first() - if not record or record.status == "deleted": - raise HTTPException(404, "收件文件不存在") - if record.status in {"pending", "finalizing"}: - raise HTTPException(409, "该文件正在上传,请先禁用寄件码并等待上传结束") - await services.request_file_removal(file_id) - # 文件和收件记录均删除才算完成;清理失败时记录仍在,后台会继续重试。 - if await DeliveryFile.filter(id=file_id).exists(): - raise HTTPException(503, "存储暂时不可用,已排队自动重试删除") - return APIResponse(detail={"message": "文件已删除"}) - - -def page_response(filename): - """页面无外部依赖;禁缓存和同源 CSP 防止口令、管理员凭证泄露。""" - return FileResponse(STATIC / filename, headers={ - "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", - }) + 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(): - # 旧地址继续可用,安装原生寄件主题后跳转至其 Vue 路由。 if theme_has_delivery_ui(): return RedirectResponse('/#/delivery', status_code=307) - return page_response("delivery.html") + 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 page_response("admin.html") + return upgrade_notice() -@pages.get("/delivery-assets/{filename}", include_in_schema=False) -async def asset(filename: str): - if filename not in {"delivery.css", "delivery.js", "admin.js", "common.js", "entry.js", "entry.css", "logo.svg"}: - raise HTTPException(404, "资源不存在") - return FileResponse(STATIC / filename, headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"}) - - -@admin_api.put("/codes/{code_id}") -async def update_config(code_id: int, data: UpdateDeliveryCode): - """管理员编辑配置,启停操作仍使用兼容的 PATCH 接口。""" - 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)) +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/core/storage.py b/core/storage.py index 2be47309e..c5ab29066 100644 --- a/core/storage.py +++ b/core/storage.py @@ -206,8 +206,9 @@ async def save_file( filename = await sanitize_filename(path_obj.name) # 构建安全的完整保存路径 safe_save_path = self._resolve_safe_path(f"{directory}/{filename}" if directory not in {"", "."} else filename) - # 多个寄件可同时写入同一目标目录,幂等创建避免“先检查再创建”的并发冲突。 - safe_save_path.parent.mkdir(parents=True, exist_ok=True) + # 确保目录存在 + if not safe_save_path.parent.exists(): + safe_save_path.parent.mkdir(parents=True) await asyncio.to_thread(self._save, stream, safe_save_path) async def delete_file(self, file_code: StoredFile): diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md index d6a78ab3b..fbc375cd0 100644 --- a/docs/guide/delivery.md +++ b/docs/guide/delivery.md @@ -1,109 +1,49 @@ # 寄件码 -寄件码是管理员发放的受限上传授权。关闭游客自由上传后,访客仍可凭寄件码进入上传页,选择文件过期方式;上传成功后获得独立的普通取件码,接收者在首页取件,无需进入管理后台。寄件码本身不授予管理员或下载权限。 +寄件码是管理员发放的受限上传授权。即使关闭游客上传,访客仍可凭有效寄件码投递到指定存储目录。成功上传返回普通取件码,接收者无需登录即可按取件期限下载。寄件码本身不能下载文件,也不能访问管理接口。 -## 使用方法 +## 使用与主题 -1. 访问 `/delivery/admin`,用本站管理员密码登录。新版 2024 主题会进入原后台侧栏的“寄件管理”,复用管理员会话;未更新的主题仍进入独立管理页。 -2. 填写用途名称、有效期和最大文件数,创建寄件码。默认跟随系统设置中的存储方式和文件路径;需要单独指定时勾选“自定义存储位置”,填写存储类型和目标目录。口令留空时自动生成 16 位随机口令;自定义时为 8 至 32 位字母、数字、下划线或短横线(历史长码仍可验证),区分大小写。 -3. 复制创建结果中的口令,也可以随时在 2024 主题管理列表的“寄件码”列查看和复制。 -4. 创建成功窗口展示用途、有效期、剩余次数、口令和二维码。可复制寄件链接发给投递人,或点击“前往寄件”在新标签页打开上传页;链接自动验证寄件码,无效时显示原因。也可继续分享 `/delivery` 地址和口令。 -5. 访客验证后选择文件及过期时间/领取次数,上传成功后复制取件码或打开取件链接。接收者使用首页取件流程下载。 -6. 新上传会同时出现在普通文件管理与对应寄件码的收件列表。“查看收件”复用文件管理列表与详情、下载、编辑和删除操作,分页只统计当前寄件码的关联分享;旧私有收件及处理中记录单独保留。 +1. 安装配套 2024 主题,在原后台“寄件管理”创建口令、有效期和最大上传次数。 +2. 默认跟随系统存储;如需指定目录,选择自定义存储并填写相对路径。 +3. 创建结果提供口令、链接和二维码。列表不返回口令原文,点击“查看 / 复制”才通过管理员专用接口读取,响应禁止缓存。 +4. 访客进入寄件链接,验证后复用普通发送页面。文件、文本以及一个多文件 ZIP 都按一次成功分享计次。 +5. 管理员点击“查看收件”,使用原文件管理的详情、下载、编辑和删除功能。 -2024 主题寄件管理的“编辑”可修改用途名称、有效期、上传总次数、存储类型和目录。已使用次数保持不变,总次数不能小于已使用与上传中占用次数之和。寄件码可以修改,新码最长 32 位;未修改时保留原码。改码会撤销旧码及旧上传凭证,已生成的取件码不受影响。存储修改只影响随后开始的上传,已收文件和上传中会话仍使用原来的存储位置。 +原生寄件界面仅支持 2024 主题。2023 或尚未更新的主题访问 `/delivery`、`/delivery/admin` 时提示切换主题,不注入脚本,也不提供独立 HTML 上传或管理页面。旧 `/api/delivery/upload` 接口已移除。 -关闭原后台的“允许游客上传”不会影响有效寄件码。无需为了临时收件重新开放自由上传。 +新口令自动生成 16 位随机字符,也可自定义 8~32 位字母、数字、下划线或短横线。历史长口令仍兼容验证。编辑时口令留空表示保持原值;改码、手动启停及删除均撤销旧令牌。 -## 次数与文件 +## 次数、文件与清理 -- 验证寄件码后直接使用普通发送页:支持文件、文本、拖放与粘贴、多文件打包、发件记录。每成功创建一份分享扣一次;多文件与普通发送一致先打成 ZIP,整个压缩包扣一次。 -- 分片开关沿用站点设置:开启时使用普通分片初始化、上传、续传和合并接口;关闭时复用普通预签名/代理上传。每一步都携带寄件凭证并校验会话归属,其他寄件码、游客或管理员令牌不能冒用该上传会话。 -- 服务器先原子预占次数,成功后转为已使用;失败不扣成功次数。列表的“占用”表示尚未结束的上传。 -- 寄件码过期、禁用、删除时不能新上传;在最终保存确认之前失效的上传也会失败并进行清理。 -- 同名文件保留各自内容,实际存储名增加唯一前缀,后台下载时仍使用原显示名。 -- 网络中断时可能出现服务器已保存但客户端没有收到结果,先查看后台收件或重新校验剩余次数,不自动重传。 -- 寄件码有效期只限制上传授权;文件有效期由上传时的选择决定,并受全站上限和过期方式白名单约束。 -- 停用或删除寄件码会撤销后续上传权限,但不会撤销已经生成的取件码。文件过期、次数耗尽或管理员删除文件时,取件码失效,文件进入清理。 -- 删除文件不恢复已经用过的上传次数。两个管理入口及自动过期任务使用相同的关联清理流程,失败残留持续计入容量,稍后自动重试。 -- 文件到期或领取次数耗尽后,沿用普通文件管理每 10 分钟运行的过期清理任务;实际文件清理成功后物理删除收件记录,失败时保留记录重试。历史已清理且大小归零的收件空记录由寄件清理任务分批移除。 -- 寄件码手动删除时物理删除记录;上传成功且额度耗尽、没有其他上传占用时自动清除寄件码。剩余额度仍可用的多次寄件码继续保留。已生成的普通取件码及文件不会随寄件码删除,仍按自己的期限或领取次数清理。旧撤销记录由清理任务分批回收,不再提供历史筛选入口。 -- 历史私有收件保持私有,不自动生成取件码。旧客户端若未提交过期参数,也沿用旧私有投递语义;新 2024 页面始终显式提交分享过期策略。 -- 文件大小和类型沿用站点设置;已收文件和清理中的残留文件计入全站容量限制。 +- 上传使用 `/share/*`、`/chunk/*` 和 `/presign/*` 原有接口。寄件的非分片上传使用已有代理模式,按实际大小核验授权配额;不向寄件者发放未约束大小的 S3 直传 URL。普通上传的直传行为不变。 +- 每次上传先在数据库中原子预占次数。提交成功时,同事务增加已用次数、创建 `FileCodes` 并删除预留。失败不增加成功次数。 +- 仅保留 `DeliveryCode` 授权表,成功文件通过 `FileCodes.delivery_id` 关联。上传期间的归属、路径及容量复用 `StorageReservation`,不再维护第二份成功收件记录。 +- 额度耗尽后自动停用并保留寄件码,仍可按码查看收件。管理员增加额度后可重新启用。删除采用软删除,保留历史关联,已生成取件码继续独立有效。 +- 删除文件不退还已经使用的寄件次数。普通文件清理负责成功收件;未完成上传清理失败时保留容量预留计费,后台重试成功后才释放。 +- 寄件文件保存到授权目录下的独立上传子目录,复用原存储驱动并避免同名覆盖。寄件预留和成功文件保留授权后端;普通文件与普通上传会话不新增通用快照能力。 +- 寄件授权与文件领取期限独立。到期、改码或手动停用后,未提交的上传不能完成。 -## 存储配置 +## 授权与续期 -新建和编辑的自定义存储选项与系统设置统一为 `local`、`s3`、`webdav`;`system` 表示跟随系统设置,不是额外的存储后端。前端两个页面共用选项和名称,寄件配置接口同步限制。历史 OneDrive/OpenDAL 记录的读取、上传及清理适配保留,不自动迁移文件;编辑这些旧码时需主动改选受支持的存储或跟随系统。创建时检查所选后端的必要配置,缺失时拒绝发码;这不替代远端连通性或凭据有效性检查。收件记录保存存储类型,后续切换默认存储不会把其改成另一种后端。 +上传凭证有效期为 15 分钟,只包含寄件身份和授权版本。前端在到期前通过 `/api/delivery/refresh` 续期;每个上传请求前也检查有效期,并发分片共用一次刷新,避免后台标签页计时器暂停导致中断。令牌已经过期时,仅使用当前页面内存中的原寄件码重新验证;口令和令牌不保存到本地存储。 -OneDrive 寄件使用精确文件对象路径,避免旧分享目录约定截去扩展名;管理员下载通过认证的 Graph 会话流式写入临时文件,不创建匿名分享链接。大文件上传使用 SDK 的上传会话能力。OpenDAL 必须配置持久化后端,不接受每次实例化都会丢失内容的 memory 存储。 +每个上传会话校验归属。其他寄件码、游客和管理员令牌不能冒用寄件上传会话。完成响应丢失时,分片/预签名完成接口可根据普通文件表的 `upload_id` 返回既有结果,避免重复扣次。 -目标是对应存储根目录下的相对路径,例如 `inbox/project-a`。本地文件实际保存在 `data/inbox/project-a`,WebDAV/云存储使用其配置根目录或对象键前缀。禁止绝对路径、`..`、URL 转义路径及 Windows 保留设备名。访客不能指定目标路径,也不会收到真实路径。 +后台列表在数据库筛选、计数、排序和分页;关键字查询名称与备注,标签独立精确筛选。凭证不进入列表响应。口令仅保存一份具有唯一约束的原文,不再同时保存 HMAC 摘要;原文必须与管理员配置数据同等保护。 -存储凭据不复制到寄件记录;如果变更同一后端的账号、桶名、根目录或服务地址,需先迁移旧文件,否则旧记录可能无法下载。远端桶和目录应保持私有,应用中的管理员权限无法覆盖第三方自行开放的公开访问设置。 +## 存储 -寄件功能不引入新的账号体系。`owner_id` 固定为服务端的管理员身份,客户端不能指定;将来加入子用户时需要同时补充其认证、归属及目录隔离授权。 +自定义存储支持 `local`、`s3`、`webdav`,`system` 表示跟随当前设置。目标路径相对于对应后端的根目录,禁止绝对路径、路径穿越、转义路径和 Windows 保留设备名。访客不能指定目标路径,响应也不返回该路径。 -## API +本功能直接调用站点已有的本地、S3、WebDAV 驱动,不新增或修改公共存储驱动。寄件关联只保存授权时选择的后端类型和对象位置,不复制密钥;同一后端更换账号、桶或根目录时需按原系统方式迁移文件。 -所有路径都相对于站点根地址,JSON 响应内容位于 `detail`。 +## 升级 -| 方法与路径 | 权限 | 用途 | -| --- | --- | --- | -| POST `/api/delivery/verify` | 寄件口令 | JSON `{code}`,获得短期上传凭证 | -| POST `/api/delivery/upload` | 寄件 Bearer Token | multipart 包含一个 `file`、`expire_style`、`expire_value`,成功返回 `code` 取件码 | -| PUT `/admin/delivery/codes/{id}` | 管理员 | 按提交字段修改口令、名称、有效期、次数、存储及备注标签,保留计数 | -| GET `/admin/delivery/codes` | 管理员 | 按关键字、状态、存储、标签筛选并排序分页 | -| POST `/admin/delivery/codes/batch` | 管理员 | 批量启用、停用、删除或修改期限和次数;同一事务校验 | -| POST `/admin/delivery/codes` | 管理员 | 创建口令 | -| PATCH `/admin/delivery/codes/{id}` | 管理员 | JSON `{enabled}` 启用/禁用 | -| DELETE `/admin/delivery/codes/{id}` | 管理员 | 物理删除口令,保留已生成的分享 | -| GET `/admin/delivery/codes/{id}/files` | 管理员 | 分页收件列表 | -| GET `/admin/delivery/files/{id}/download` | 管理员 | 下载私有收件 | -| DELETE `/admin/delivery/files/{id}` | 管理员 | 删除收件文件 | +启动依次执行迁移 008~014。升级保留既有数据:013 将成功分享关联迁入 `FileCodes`,旧私有文件转入同表但标记 `is_private`,公共取件接口拒绝访问;后台仍能管理和下载。旧未完成上传取消并转为待清理预留,不跨版本继续提交。 -创建参数包括 `name`、可选的 `code`、`storage_type`、`target_path`、带时区的 `expires_at`、`max_uploads`。省略口令时由服务端生成。分页使用 `page`、`page_size`,默认 20 条、上限 100 条。 +旧版本已物理删除的寄件码,会为其尚存收件补充停用的历史记录。所有转移完成后删除旧 `deliveryfile` 表。迁移使用事务并支持重试,不修改实际存储文件。 -寄件凭证有效期 15 分钟,只能用于该寄件码上传。每次上传仍检查口令状态。口令摘要使用站点签名密钥,轮换 `jwt_secret` 会同时使原寄件码失效,轮换前需计划重新发码。 +普通上传的参数模型、分片规则、默认存储选择以及驱动实现与主干保持一致。寄件额外校验仅作用于持有寄件授权的请求,保障其次数与容量预占不被绕过。旧版本曾添加到普通上传会话中的快照列可留在数据库中,但运行时代码不再读取或写入。 -管理员列表通过 `code` 字段返回已保存的口令原文,响应禁止缓存,访客接口不会返回该字段。迁移 009 前的旧记录只有不可逆摘要,显示“旧寄件码未保存原文”,不会自动换码。持有者下次成功验证旧码后自动补存原文;没有原码且无人能验证时,不能直接还原,只能由管理员另建寄件码。 - -## 部署和恢复 - -安装后端依赖并准备对应主题的构建产物后,在仓库目录启动服务,首次访问时完成管理员初始化: - -```powershell -# 仅监听本机,开发预览不开放到公网。 -python -m uvicorn main:app --host 127.0.0.1 --port 12345 -``` - -初始化入口为 `http://127.0.0.1:12345/setup`,寄件管理为 `http://127.0.0.1:12345/delivery/admin`。 - -- 代码启动时自动执行 `migrations_008.py`,增加 `deliverycode` 和 `deliveryfile` 两张表,保留原分享数据。 -- `migrations_009.py` 为寄件码增加可空的 `code_value` 原文字段,保留旧摘要和已分发口令的有效性。数据库及备份包含寄件码原文,应继续使用原有权限保护。 -- `migrations_010.py` 增加 `deliveryfile.share_id` 关联普通分享。旧行保持 NULL;新分享容量由 FileCodes 计算,私有收件及清理残留另计,避免同一文件重复占用配额。 -- 2024 原生寄件页面在独立前端仓库,构建后更新 `themes/2024`。通过构建标记识别新页面,旧 `/delivery` 和 `/delivery/admin` 分别跳转到 `/#/delivery` 与 `/#/admin/delivery`。 -- `apps/delivery/static` 保留旧主题回退页面。2023 版前端源码未修改;未包含原生寄件的主题继续使用该回退。 -- 原主题仍由原 Docker 构建流程生成。纯源码开发时,寄件页可独立使用;原取件页需提供 `themes/2024` 或 `themes/2023` 构建产物。 -- 新清理任务每分钟检查一次;心跳停止超过两小时的上传视为异常遗留,释放次数并清理文件。删除失败的记录保留为“等待清理”,继续计入容量并自动重试。 -- 已存文件的后台下载优先使用浏览器文件系统接口流式保存;不支持的浏览器回退为 Blob,大文件下载的内存占用较高。 -- SQLite 数据库与上传文件需要一起备份。降级前保留完整数据库;旧版不认识寄件表和寄件容量规则,不能把仅回退代码视为完整回滚。 - -新版 2024 页面直接复用普通上传服务:`/share/text/`、`/share/file/`、`/chunk/upload/*`、`/presign/upload/*` 接受有效寄件 Bearer Token。寄件授权只改变权限、存储目录与次数归属,生成的取件码、过期策略和下载流程仍为普通分享。分片会话跨请求持久化,重新验证同一寄件码并选择同一文件可续传;令牌过期须重新验证。取消或空闲超过两小时的寄件上传释放次数并清理残留,清理失败保留记录重试。 - -旧 `/api/delivery/upload` 保留为旧客户端兼容接口;未传过期参数的旧私有收件语义不变。新页面不再调用这一独立上传实现。 - -### 跟随系统存储设置 - -新建寄件码默认 `storage_type=system`,`target_path` 留空。每次新上传开始时读取当前 `file_storage`,复用普通上传的 `storage_path/share/data/年/月/日/唯一标识` 路径规则,并把实际存储类型和目录保存到收件记录。续传、下载和清理使用已保存的位置,不会因寄件码切换模式而重新定位。 - -已有寄件码保留原存储类型和目录,无需数据库迁移;编辑时可以取消“自定义存储位置”切换为跟随系统。该模式只跟随存储方式和路径规则,其他上传限制仍沿用现有逻辑。 - -## 管理一致性与存储快照 - -寄件列表提供关键字搜索(名称、口令、备注和标签)、状态/存储/标签筛选、排序及本页选择。批量操作包含启用、停用、删除、修改有效期或可寄件次数;删除仅撤销上传授权,不删除关联文件。备注最多 2000 字,标签最多 12 个、每个 24 字,去空白和忽略大小写去重规则与文件管理共用。过期记录可以只维护备注,不必续期。 - -“最多可寄件次数”按每次成功分享扣减,多文件打成一个 ZIP 算一次,文本也算一次。寄件码的 expires_at 仅约束上传授权;对应取件码仍按自己的 expired_at/expired_count 生效,两者不联动。 - -迁移 011 为普通文件、分片和预签名会话补充 storage_type 快照,后续切换全站存储时下载、续传及清理使用记录中的后端。已有寄件关联可可靠补全;没有来源证据的历史普通文件保持未知,详情不再把当前设置显示为原始后端,但读写兼容路径仍需使用当前设置。此迁移不搬运用户文件,也不能自动恢复曾经切换存储后失联的文件。 - -迁移 012 为寄件码增加备注、标签和授权版本;历史口令原文和摘要保持不变。系统路径前缀与寄件自定义目录共用相对路径校验,系统仍生成日期和唯一标识子目录,寄件自定义仍使用指定目录。更新同一后端的账号、桶或根目录仍需单独迁移文件,存储类型快照不保存凭据副本。 +迁移 014 移除旧摘要和未使用的账号归属列。有原文的寄件码保持原口令和授权状态;缺少原文的历史码保留 ID、次数和收件关系,停用并递增授权版本。管理员必须先编辑设置新口令,再显式启用;不能依靠旧口令或旧令牌恢复授权。 diff --git a/main.py b/main.py index 3e9f12ea2..e9debb7f8 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ delete_expire_files, ) from apps.base.views import share_api, chunk_api, presign_api -from apps.delivery.services import cleanup_loop as delivery_cleanup_loop +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 diff --git a/readme.md b/readme.md index d6f2fdf29..ac92322d1 100644 --- a/readme.md +++ b/readme.md @@ -27,7 +27,7 @@ - 访客投递:`/delivery` - 寄件管理:`/delivery/admin`(使用本站管理员密码) -- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–012。2023 前端源码未修改,未包含原生寄件功能的主题使用后端提供的独立页面。 +- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–014。2023 前端源码未修改,旧主题访问寄件入口时提示更新并切换到 2024 主题。 ## 一条命令开始 diff --git a/tests/test_delivery_authorization.py b/tests/test_delivery_authorization.py new file mode 100644 index 000000000..db440b5bc --- /dev/null +++ b/tests/test_delivery_authorization.py @@ -0,0 +1,377 @@ +"""寄件授权回归:游客关闭、身份隔离、并发配额、续期及迁移不公开私有文件。""" + +import asyncio +import importlib +import unittest +from datetime import timedelta +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from fastapi import FastAPI, HTTPException +from httpx import ASGITransport, AsyncClient +from tortoise import connections + +from apps.admin.dependencies import create_token, verify_token +from apps.base.models import DeliveryCode, FileCodes, StorageReservation +from apps.base.quota import get_storage_usage, reserve_storage +from apps.base.upload_access import UploadAccess, prepare_upload +from apps.base.upload_sessions import abort_upload, commit_delivery +from apps.base.views import share_api, chunk_api, presign_api, get_code_file_by_code +from apps.delivery.schemas import CreateDeliveryCode, UpdateDeliveryCode, BatchDeliveryCodes +from apps.delivery.services import create_code, verify_code, refresh_session, update_code, list_codes, batch_codes +from apps.delivery.views import admin_api, public_api +from core.settings import settings +from core.utils import get_now +from tests.helpers import init_memory_db, close_db + + +class DeliveryAuthorizationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.original = dict(settings.user_config) + settings.open_upload = 0 + settings.jwt_secret = "delivery-regression-test-secret" + settings.file_storage = "local" + settings.storage_limit = 100 + settings.upload_size = 1024 + settings.expire_style = ["day", "forever"] + self.directory = TemporaryDirectory() + self.storage_patch = patch("core.storage.data_root", Path(self.directory.name)) + self.storage_patch.start() + await init_memory_db() + # 限流器是进程级对象,每个用例使用全新访问窗口,避免前一用例消耗本例额度。 + from apps.base.utils import ip_limit + for limiter in ip_limit.values(): + limiter.ips.clear() + app = FastAPI() + for router in (share_api, chunk_api, presign_api, admin_api, public_api): + app.include_router(router) + self.client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + async def asyncTearDown(self): + await self.client.aclose() + await close_db() + self.storage_patch.stop() + self.directory.cleanup() + settings.user_config = self.original + + async def make_code(self, code="DeliveryTest1", max_uploads=2, **extra): + result = await create_code(CreateDeliveryCode( + name="测试收件", code=code, storage_type="local", target_path="inbox", + expires_at=await get_now() + timedelta(days=1), max_uploads=max_uploads, **extra, + )) + session = await verify_code(code) + return result["item"]["id"], {"Authorization": "Bearer " + session["token"]} + + async def test_guest_closed_delivery_works_and_admin_isolation(self): + code_id, headers = await self.make_code() + denied = await self.client.post("/share/text/", data={"text": "hello"}) + self.assertEqual(denied.status_code, 403) + sent = await self.client.post("/share/text/", data={"text": "hello"}, headers=headers) + self.assertEqual(sent.status_code, 200, sent.text) + share = await FileCodes.get(delivery_id=code_id) + self.assertEqual(sent.json()["detail"]["code"], share.code) + self.assertEqual(await StorageReservation.all().count(), 0) + admin = await self.client.get("/admin/delivery/codes", headers=headers) + self.assertEqual(admin.status_code, 401) + + async def test_final_slot_atomic_and_exhausted_code_keeps_files(self): + code_id, headers = await self.make_code(max_uploads=1) + results = await asyncio.gather(*[ + self.client.post("/share/text/", data={"text": "hello"}, headers=headers) + for _ in range(4) + ]) + self.assertEqual(sum(r.status_code == 200 for r in results), 1) + record = await DeliveryCode.get(id=code_id) + self.assertEqual(record.used_count, 1) + self.assertEqual(record.reserved_count, 0) + self.assertFalse(record.enabled) + self.assertEqual(await FileCodes.filter(delivery_id=code_id).count(), 1) + + async def test_capacity_reservation_is_single_and_cleanup_failure_stays_charged(self): + code_id, _ = await self.make_code() + access = UploadAccess(code_id=code_id) + token, _ = await prepare_upload(access, "sample.txt", 70, "quota-session") + await reserve_storage("chunk:" + token, 70, 300) + await reserve_storage("chunk:" + token, 70, 300) + self.assertEqual((await get_storage_usage())["reserved"], 70) + self.assertEqual(await StorageReservation.all().count(), 1) + with self.assertRaises(HTTPException): + await reserve_storage("other", 31, 300) + with patch("apps.base.upload_sessions.get_storage", side_effect=RuntimeError("offline")): + await abort_upload(access.record.id) + self.assertEqual((await get_storage_usage())["reserved"], 70) + self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 0) + await abort_upload(access.record.id) + self.assertEqual((await get_storage_usage())["reserved"], 0) + + async def test_cross_code_and_guest_cannot_access_chunk_session(self): + code_id, headers = await self.make_code() + _, other_headers = await self.make_code("DeliveryTest2") + init = await self.client.post("/chunk/upload/init/", headers=headers, json={ + "file_name": "sample.txt", "file_size": 5, "chunk_size": 5, "file_hash": "hash", + }) + self.assertEqual(init.status_code, 200, init.text) + token = init.json()["detail"]["upload_id"] + for identity in (other_headers, {}): + settings.open_upload = 1 + response = await self.client.delete("/chunk/upload/" + token, headers=identity) + self.assertEqual(response.status_code, 404, response.text) + self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 1) + + async def test_refresh_preserves_session_and_revocation_invalidates_old_token(self): + code_id, headers = await self.make_code() + original_payload = verify_token(headers["Authorization"][7:]) + # 模拟传输经过 14 分钟,验证续期真正延长有效期且原会话不丢失。 + with patch("apps.admin.dependencies.time.time", return_value=original_payload["exp"] - 60): + refreshed = await refresh_session(headers["Authorization"]) + self.assertGreater(verify_token(refreshed["token"])["exp"], original_payload["exp"]) + self.assertIn("token", refreshed) + self.assertNotIn("target_path", refreshed) + await update_code(code_id, UpdateDeliveryCode(code="ChangedCode1")) + with self.assertRaises(HTTPException) as error: + await refresh_session(headers["Authorization"]) + self.assertEqual(error.exception.status_code, 401) + # 已过期令牌不能通过 refresh 接口自行复活。 + expired = create_token({"purpose": "delivery", "delivery_id": code_id}, expires_in=-1) + response = await self.client.post("/api/delivery/refresh", headers={"Authorization": "Bearer " + expired}) + self.assertEqual(response.status_code, 401) + + async def test_list_sql_pagination_hides_secret_and_filters_tags(self): + await self.make_code(tags=["客户", "one"]) + await self.make_code("DeliveryTest2", tags=["two"]) + result = await list_codes(page_size=1) + self.assertEqual(result["total"], 2) + self.assertEqual(len(result["items"]), 1) + self.assertNotIn("code", result["items"][0]) + selected = await list_codes(tag="ONE") + self.assertEqual(selected["total"], 1) + self.assertEqual((await list_codes(tag="' OR 1=1 --"))["total"], 0) + + async def test_soft_delete_and_batch_update_keep_association(self): + code_id, headers = await self.make_code() + sent = await self.client.post("/share/text/", data={"text": "hello"}, headers=headers) + self.assertEqual(sent.status_code, 200, sent.text) + await batch_codes(BatchDeliveryCodes(ids=[code_id], action="update", max_uploads=5)) + await batch_codes(BatchDeliveryCodes(ids=[code_id], action="delete")) + record = await DeliveryCode.get(id=code_id) + self.assertTrue(record.deleted) + self.assertEqual(record.max_uploads, 5) + self.assertEqual(await FileCodes.filter(delivery_id=code_id).count(), 1) + + async def test_changed_code_cannot_commit_old_reservation(self): + code_id, _ = await self.make_code() + access = UploadAccess(code_id=code_id) + await prepare_upload(access, "Text", 5, "old-session") + await update_code(code_id, UpdateDeliveryCode(code="ChangedCode1")) + with self.assertRaises(HTTPException): + await commit_delivery(access.record, {"code": "result", "text": "hello", "size": 5}) + self.assertEqual(await FileCodes.all().count(), 0) + self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 0) + + async def test_private_migration_never_creates_public_download(self): + # 在真实旧迁移表上构造历史数据,迁移后再次执行验证幂等性。 + conn = connections.get("default") + await conn.execute_query("DROP TABLE deliverycode") + for number in (8, 9, 10, 12): + await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() + await conn.execute_query( + "INSERT INTO deliveryfile (delivery_id, token, filename, stored_name, file_path, storage_type, size, status) " + "VALUES (77, 'legacy-token', 'private.txt', 'object.txt', 'inbox', 'local', 7, 'stored')" + ) + migration = importlib.import_module("apps.base.migrations.migrations_013") + await migration.migrate() + await migration.migrate() + file = await FileCodes.get(delivery_id=77) + self.assertTrue(file.is_private) + self.assertFalse((await get_code_file_by_code(file.code))[0]) + self.assertFalse(await conn.execute_query_dict("SELECT name FROM sqlite_master WHERE name='deliveryfile'")) + self.assertEqual((await get_storage_usage())["used"], 7) + + async def test_chunk_delivery_completion_retry_does_not_consume_twice(self): + code_id, headers = await self.make_code(max_uploads=1) + result = await self.client.post("/chunk/upload/init/", headers=headers, json={ + "file_name": "sample.txt", "file_size": 5, "chunk_size": 5, "file_hash": "hash", + }) + token = result.json()["detail"]["upload_id"] + part = await self.client.post(f"/chunk/upload/chunk/{token}/0", headers=headers, + files={"chunk": ("part", b"hello", "application/octet-stream")}) + self.assertEqual(part.status_code, 200, part.text) + result = await self.client.post(f"/chunk/upload/complete/{token}", headers=headers, + json={"expire_style": "day", "expire_value": 1}) + self.assertEqual(result.status_code, 200, result.text) + retry = await self.client.post(f"/chunk/upload/complete/{token}", headers=headers, + json={"expire_style": "day", "expire_value": 1}) + self.assertEqual(retry.json()["detail"]["code"], result.json()["detail"]["code"]) + self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 1) + self.assertEqual((await get_storage_usage())["used"], 5) + download = await self.client.get("/share/select/", params={"code": result.json()["detail"]["code"]}) + self.assertEqual(download.content, b"hello") + + async def test_proxy_delivery_rejects_underreported_size(self): + _, headers = await self.make_code() + result = await self.client.post("/presign/upload/init", headers=headers, json={ + "file_name": "sample.txt", "file_size": 5, "expire_style": "day", "expire_value": 1, + }) + self.assertEqual(result.status_code, 200, result.text) + token = result.json()["detail"]["upload_id"] + wrong = await self.client.put(f"/presign/upload/proxy/{token}", headers=headers, + files={"file": ("sample.txt", b"a" * 150, "text/plain")}) + self.assertEqual(wrong.status_code, 400, wrong.text) + self.assertEqual(await FileCodes.all().count(), 0) + valid = await self.client.put(f"/presign/upload/proxy/{token}", headers=headers, + files={"file": ("sample.txt", b"hello", "text/plain")}) + self.assertEqual(valid.status_code, 200, valid.text) + self.assertEqual((await get_storage_usage())["used"], 5) + + async def test_digest_removal_keeps_known_codes_and_disables_unknown_codes(self): + # 使用旧版表验证迁移行为,缺失原文的授权只能停用,不能伪造恢复口令。 + conn = connections.get("default") + await conn.execute_query("DROP TABLE deliverycode") + for number in (8, 9, 10, 12): + await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() + expiry = await get_now() + timedelta(days=1) + await conn.execute_query( + "INSERT INTO deliverycode (id, code_digest, code_value, name, storage_type, target_path, expires_at, max_uploads, auth_version) " + "VALUES (1, 'old-digest-a', 'KeepExistingCode1', 'known', 'local', 'inbox', ?, 5, 3)", [expiry], + ) + await conn.execute_query( + "INSERT INTO deliverycode (id, code_digest, name, storage_type, target_path, expires_at, max_uploads, auth_version) " + "VALUES (2, 'old-digest-b', 'unknown', 'local', 'inbox', ?, 5, 7)", [expiry], + ) + await FileCodes.create(code="existing-share", delivery_id=2, text="private relationship", size=20) + migration = importlib.import_module("apps.base.migrations.migrations_014") + await migration.migrate() + await migration.migrate() + columns = {item["name"] for item in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} + self.assertNotIn("code_digest", columns) + self.assertNotIn("owner_id", columns) + self.assertTrue((await DeliveryCode.get(id=1)).enabled) + self.assertIn("token", await verify_code("KeepExistingCode1")) + unknown = await DeliveryCode.get(id=2) + self.assertFalse(unknown.enabled) + self.assertEqual(unknown.auth_version, 8) + self.assertTrue(await FileCodes.filter(delivery_id=2).exists()) + # 重设口令不会自动扩大权限,管理员还需显式重新启用。 + await update_code(2, UpdateDeliveryCode(code="NewKnownCode2")) + self.assertFalse((await DeliveryCode.get(id=2)).enabled) + + async def test_missing_original_requires_reset_before_enable(self): + record = await DeliveryCode.create( + code_value=None, name="需要重新设码", storage_type="local", target_path="inbox", + expires_at=await get_now() + timedelta(days=1), max_uploads=2, enabled=False, + ) + headers = {"Authorization": "Bearer " + create_token({"is_admin": True})} + response = await self.client.patch(f"/admin/delivery/codes/{record.id}", json={"enabled": True}, headers=headers) + self.assertEqual(response.status_code, 409) + with self.assertRaises(HTTPException): + await batch_codes(BatchDeliveryCodes(ids=[record.id], action="enable")) + await update_code(record.id, UpdateDeliveryCode(code="ResetAndEnable1")) + response = await self.client.patch(f"/admin/delivery/codes/{record.id}", json={"enabled": True}, headers=headers) + self.assertEqual(response.status_code, 200, response.text) + self.assertIn("token", await verify_code("ResetAndEnable1")) + + async def test_digest_migration_rejects_duplicate_originals_without_data_loss(self): + conn = connections.get("default") + await conn.execute_query("DROP TABLE deliverycode") + for number in (8, 9, 10, 12): + await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() + for number in (1, 2): + await conn.execute_query( + "INSERT INTO deliverycode (id, code_digest, code_value, name, storage_type, target_path, expires_at, max_uploads) " + "VALUES (?, ?, 'DuplicatedCode1', 'duplicate', 'local', 'inbox', ?, 2)", + [number, str(number), await get_now() + timedelta(days=1)], + ) + with self.assertRaises(RuntimeError) as error: + await importlib.import_module("apps.base.migrations.migrations_014").migrate() + self.assertNotIn("DuplicatedCode1", str(error.exception)) + columns = {item["name"] for item in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} + self.assertIn("code_digest", columns) + self.assertEqual(len(await conn.execute_query_dict("SELECT id FROM deliverycode")), 2) + + async def test_normal_upload_keeps_upstream_storage_behavior(self): + # 普通游客上传不写寄件归属或公共后端快照,也不改变上游的开放游客鉴权规则。 + settings.open_upload = 1 + response = await self.client.post('/share/text/', data={'text': 'ordinary'}, headers={'Authorization': 'Bearer invalid'}) + self.assertEqual(response.status_code, 200, response.text) + record = await FileCodes.get(code=response.json()['detail']['code']) + self.assertIsNone(record.delivery_id) + self.assertIsNone(record.storage_type) + from apps.base.models import UploadChunk, PresignUploadSession + self.assertNotIn('storage_type', UploadChunk._meta.fields_map) + self.assertNotIn('storage_type', PresignUploadSession._meta.fields_map) + + async def test_chunk_size_extension_is_limited_to_delivery(self): + # 普通分片保留上游按整片容量计算的规则,寄件才按自己的预占容量校验。 + settings.open_upload = 1 + settings.upload_size = 5 + payload = {'file_name': 'partial.txt', 'file_size': 5, 'chunk_size': 4, 'file_hash': 'partial'} + ordinary = await self.client.post('/chunk/upload/init/', json=payload) + self.assertEqual(ordinary.status_code, 403) + _, headers = await self.make_code() + delivery = await self.client.post('/chunk/upload/init/', json=payload, headers=headers) + self.assertEqual(delivery.status_code, 200, delivery.text) + + async def test_normal_file_ignores_legacy_snapshot(self): + # 旧版本曾为普通文件写入的快照不会继续改变上游默认存储选择。 + from apps.base.share_storage import storage_for_share + marker = object() + record = await FileCodes.create(code='ordinary-legacy', storage_type='s3') + with patch.dict('apps.base.share_storage.storages', {'local': lambda: marker}): + self.assertIs(await storage_for_share(record), marker) + + async def test_delivery_s3_uses_existing_proxy_without_exposing_direct_url(self): + # 使用会生成直传地址的驱动替身,确认寄件根本不会请求该 URL;普通上传保持直传。 + class DirectStorage: + calls = 0 + + async def generate_presigned_upload_url(self, path, expires): + self.calls += 1 + return 'https://storage.invalid/presigned-test' + + backend = DirectStorage() + settings.file_storage = 's3' + settings.s3_access_key_id = 'test-key' + settings.s3_secret_access_key = 'test-secret' + settings.s3_bucket_name = 'test-bucket' + settings.s3_endpoint_url = 'http://127.0.0.1:9000' + result = await create_code(CreateDeliveryCode( + name='S3寄件', code='S3ScopedDelivery1', storage_type='s3', target_path='incoming', + expires_at=await get_now() + timedelta(days=1), max_uploads=2, + )) + session = await verify_code('S3ScopedDelivery1') + headers = {'Authorization': 'Bearer ' + session['token']} + payload = {'file_name': 'sample.txt', 'file_size': 5, 'expire_style': 'day'} + with patch.dict('core.storage.storages', {'s3': lambda: backend}): + response = await self.client.post('/presign/upload/init', json=payload, headers=headers) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()['detail']['mode'], 'proxy') + self.assertEqual(backend.calls, 0) + settings.open_upload = 1 + ordinary = await self.client.post('/presign/upload/init', json=payload) + self.assertEqual(ordinary.status_code, 200, ordinary.text) + self.assertEqual(ordinary.json()['detail']['mode'], 'direct') + self.assertEqual(backend.calls, 1) + + async def test_legacy_delivery_direct_session_cannot_bypass_proxy(self): + # 即使升级前数据库中留有直传会话,也不能绕开现在的寄件代理校验。 + from apps.base.models import PresignUploadSession + code_id, headers = await self.make_code(max_uploads=1) + result = await self.client.post('/presign/upload/init', headers=headers, json={ + 'file_name': 'legacy.txt', 'file_size': 5, 'expire_style': 'day', + }) + token = result.json()['detail']['upload_id'] + await PresignUploadSession.filter(upload_id=token).update(mode='direct') + response = await self.client.post('/presign/upload/confirm/' + token, headers=headers) + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(await FileCodes.all().count(), 0) + self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 0) + self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 0) + old_reservation = await StorageReservation.get(token=token) + self.assertEqual(old_reservation.status, 'cleanup') + # 旧签名过期前保留清理记录,避免取消后又写入同一对象造成永久残留。 + self.assertGreater(old_reservation.expires_at, await get_now()) + retry = await self.client.post('/presign/upload/init', headers=headers, json={ + 'file_name': 'retry.txt', 'file_size': 5, 'expire_style': 'day', + }) + self.assertEqual(retry.status_code, 200, retry.text) diff --git a/tests/test_issue_464_admin_file_list.py b/tests/test_issue_464_admin_file_list.py index 6283cb2bc..ec5e09ce8 100644 --- a/tests/test_issue_464_admin_file_list.py +++ b/tests/test_issue_464_admin_file_list.py @@ -26,6 +26,8 @@ def __init__(self): self.file_hash = "a" * 64 self.is_chunked = False self.upload_id = None + # 与新模型保持一致:普通文件可公开,历史私有收件单独标记。 + self.is_private = False async def is_expired(self): return False @@ -58,3 +60,10 @@ def test_build_admin_file_item_marks_expiring_soon(self): self.assertEqual(item["status_insights"]["severity"], "warning") self.assertIn("expires_soon", item["status_insights"]["reasons"]) + + def test_private_receipt_does_not_expose_public_retrieval_code(self): + file_code = FakeFileCode() + file_code.is_private = True + item = asyncio.run(FileService()._build_admin_file_item(file_code)) + self.assertTrue(item["is_private"]) + self.assertEqual(item["code"], "") diff --git a/tests/test_issue_476_theme_assets.py b/tests/test_issue_476_theme_assets.py index d55fe3975..c6facf72f 100644 --- a/tests/test_issue_476_theme_assets.py +++ b/tests/test_issue_476_theme_assets.py @@ -32,9 +32,8 @@ def test_resolves_assets_from_current_theme(self): "assets", self.get_theme_index_asset("themes/2024") ) - # 路径断言统一为 POSIX 表示,避免 Windows 反斜线造成环境性误报。 - self.assertIn("themes/2023/assets", theme_2023_asset.as_posix()) - self.assertIn("themes/2024/assets", theme_2024_asset.as_posix()) + self.assertIn("themes/2023/assets", str(theme_2023_asset)) + self.assertIn("themes/2024/assets", str(theme_2024_asset)) def test_rejects_theme_asset_path_traversal(self): settings.themes_select = "themes/2024" diff --git a/tests/test_security_gaps.py b/tests/test_security_gaps.py index 2eb9e378b..96b0df5b1 100644 --- a/tests/test_security_gaps.py +++ b/tests/test_security_gaps.py @@ -88,10 +88,9 @@ def test_successful_login_does_not_consume_failure_quota(self): class DockerRuntimeUserTests(unittest.TestCase): def test_dockerfile_defaults_to_root_for_volume_compatibility(self): # 默认 root:兼容已有 data 卷权限;如需非 root 可由编排层自行指定 user - # 源文件采用 UTF-8,不依赖 Windows 的 GBK 默认编码。 - text = Path("Dockerfile").read_text(encoding="utf-8") + text = Path("Dockerfile").read_text() self.assertNotIn("USER appuser", text) - compose = Path("docker-compose.yml").read_text(encoding="utf-8") + compose = Path("docker-compose.yml").read_text() self.assertNotIn('user: "1000:1000"', compose) From 6ca8f80d1764e168df63d1430d1f15ae66c104ae Mon Sep 17 00:00:00 2001 From: dawnstamp <310845922@qq.con> Date: Sat, 19 Sep 2026 17:55:11 +0800 Subject: [PATCH 7/8] =?UTF-8?q?chore(=E5=AF=84=E4=BB=B6):=20=E4=BB=8E?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=8C=83=E5=9B=B4=E7=A7=BB=E9=99=A4=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除新增寄件回归脚本,恢复上游原测试文件。文件列表展示兼容未携带寄件字段的原调用对象。 --- apps/admin/services.py | 6 +- tests/test_delivery_authorization.py | 377 ------------------------ tests/test_issue_464_admin_file_list.py | 9 - 3 files changed, 4 insertions(+), 388 deletions(-) delete mode 100644 tests/test_delivery_authorization.py diff --git a/apps/admin/services.py b/apps/admin/services.py index feb451e9d..f3dde0934 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -595,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}" @@ -606,8 +608,8 @@ async def _build_admin_file_item( ) data = { "id": file_code.id, - "code": "" if file_code.is_private else file_code.code, - "is_private": file_code.is_private, + "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, diff --git a/tests/test_delivery_authorization.py b/tests/test_delivery_authorization.py deleted file mode 100644 index db440b5bc..000000000 --- a/tests/test_delivery_authorization.py +++ /dev/null @@ -1,377 +0,0 @@ -"""寄件授权回归:游客关闭、身份隔离、并发配额、续期及迁移不公开私有文件。""" - -import asyncio -import importlib -import unittest -from datetime import timedelta -from pathlib import Path -from tempfile import TemporaryDirectory -from unittest.mock import patch - -from fastapi import FastAPI, HTTPException -from httpx import ASGITransport, AsyncClient -from tortoise import connections - -from apps.admin.dependencies import create_token, verify_token -from apps.base.models import DeliveryCode, FileCodes, StorageReservation -from apps.base.quota import get_storage_usage, reserve_storage -from apps.base.upload_access import UploadAccess, prepare_upload -from apps.base.upload_sessions import abort_upload, commit_delivery -from apps.base.views import share_api, chunk_api, presign_api, get_code_file_by_code -from apps.delivery.schemas import CreateDeliveryCode, UpdateDeliveryCode, BatchDeliveryCodes -from apps.delivery.services import create_code, verify_code, refresh_session, update_code, list_codes, batch_codes -from apps.delivery.views import admin_api, public_api -from core.settings import settings -from core.utils import get_now -from tests.helpers import init_memory_db, close_db - - -class DeliveryAuthorizationTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.original = dict(settings.user_config) - settings.open_upload = 0 - settings.jwt_secret = "delivery-regression-test-secret" - settings.file_storage = "local" - settings.storage_limit = 100 - settings.upload_size = 1024 - settings.expire_style = ["day", "forever"] - self.directory = TemporaryDirectory() - self.storage_patch = patch("core.storage.data_root", Path(self.directory.name)) - self.storage_patch.start() - await init_memory_db() - # 限流器是进程级对象,每个用例使用全新访问窗口,避免前一用例消耗本例额度。 - from apps.base.utils import ip_limit - for limiter in ip_limit.values(): - limiter.ips.clear() - app = FastAPI() - for router in (share_api, chunk_api, presign_api, admin_api, public_api): - app.include_router(router) - self.client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test") - - async def asyncTearDown(self): - await self.client.aclose() - await close_db() - self.storage_patch.stop() - self.directory.cleanup() - settings.user_config = self.original - - async def make_code(self, code="DeliveryTest1", max_uploads=2, **extra): - result = await create_code(CreateDeliveryCode( - name="测试收件", code=code, storage_type="local", target_path="inbox", - expires_at=await get_now() + timedelta(days=1), max_uploads=max_uploads, **extra, - )) - session = await verify_code(code) - return result["item"]["id"], {"Authorization": "Bearer " + session["token"]} - - async def test_guest_closed_delivery_works_and_admin_isolation(self): - code_id, headers = await self.make_code() - denied = await self.client.post("/share/text/", data={"text": "hello"}) - self.assertEqual(denied.status_code, 403) - sent = await self.client.post("/share/text/", data={"text": "hello"}, headers=headers) - self.assertEqual(sent.status_code, 200, sent.text) - share = await FileCodes.get(delivery_id=code_id) - self.assertEqual(sent.json()["detail"]["code"], share.code) - self.assertEqual(await StorageReservation.all().count(), 0) - admin = await self.client.get("/admin/delivery/codes", headers=headers) - self.assertEqual(admin.status_code, 401) - - async def test_final_slot_atomic_and_exhausted_code_keeps_files(self): - code_id, headers = await self.make_code(max_uploads=1) - results = await asyncio.gather(*[ - self.client.post("/share/text/", data={"text": "hello"}, headers=headers) - for _ in range(4) - ]) - self.assertEqual(sum(r.status_code == 200 for r in results), 1) - record = await DeliveryCode.get(id=code_id) - self.assertEqual(record.used_count, 1) - self.assertEqual(record.reserved_count, 0) - self.assertFalse(record.enabled) - self.assertEqual(await FileCodes.filter(delivery_id=code_id).count(), 1) - - async def test_capacity_reservation_is_single_and_cleanup_failure_stays_charged(self): - code_id, _ = await self.make_code() - access = UploadAccess(code_id=code_id) - token, _ = await prepare_upload(access, "sample.txt", 70, "quota-session") - await reserve_storage("chunk:" + token, 70, 300) - await reserve_storage("chunk:" + token, 70, 300) - self.assertEqual((await get_storage_usage())["reserved"], 70) - self.assertEqual(await StorageReservation.all().count(), 1) - with self.assertRaises(HTTPException): - await reserve_storage("other", 31, 300) - with patch("apps.base.upload_sessions.get_storage", side_effect=RuntimeError("offline")): - await abort_upload(access.record.id) - self.assertEqual((await get_storage_usage())["reserved"], 70) - self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 0) - await abort_upload(access.record.id) - self.assertEqual((await get_storage_usage())["reserved"], 0) - - async def test_cross_code_and_guest_cannot_access_chunk_session(self): - code_id, headers = await self.make_code() - _, other_headers = await self.make_code("DeliveryTest2") - init = await self.client.post("/chunk/upload/init/", headers=headers, json={ - "file_name": "sample.txt", "file_size": 5, "chunk_size": 5, "file_hash": "hash", - }) - self.assertEqual(init.status_code, 200, init.text) - token = init.json()["detail"]["upload_id"] - for identity in (other_headers, {}): - settings.open_upload = 1 - response = await self.client.delete("/chunk/upload/" + token, headers=identity) - self.assertEqual(response.status_code, 404, response.text) - self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 1) - - async def test_refresh_preserves_session_and_revocation_invalidates_old_token(self): - code_id, headers = await self.make_code() - original_payload = verify_token(headers["Authorization"][7:]) - # 模拟传输经过 14 分钟,验证续期真正延长有效期且原会话不丢失。 - with patch("apps.admin.dependencies.time.time", return_value=original_payload["exp"] - 60): - refreshed = await refresh_session(headers["Authorization"]) - self.assertGreater(verify_token(refreshed["token"])["exp"], original_payload["exp"]) - self.assertIn("token", refreshed) - self.assertNotIn("target_path", refreshed) - await update_code(code_id, UpdateDeliveryCode(code="ChangedCode1")) - with self.assertRaises(HTTPException) as error: - await refresh_session(headers["Authorization"]) - self.assertEqual(error.exception.status_code, 401) - # 已过期令牌不能通过 refresh 接口自行复活。 - expired = create_token({"purpose": "delivery", "delivery_id": code_id}, expires_in=-1) - response = await self.client.post("/api/delivery/refresh", headers={"Authorization": "Bearer " + expired}) - self.assertEqual(response.status_code, 401) - - async def test_list_sql_pagination_hides_secret_and_filters_tags(self): - await self.make_code(tags=["客户", "one"]) - await self.make_code("DeliveryTest2", tags=["two"]) - result = await list_codes(page_size=1) - self.assertEqual(result["total"], 2) - self.assertEqual(len(result["items"]), 1) - self.assertNotIn("code", result["items"][0]) - selected = await list_codes(tag="ONE") - self.assertEqual(selected["total"], 1) - self.assertEqual((await list_codes(tag="' OR 1=1 --"))["total"], 0) - - async def test_soft_delete_and_batch_update_keep_association(self): - code_id, headers = await self.make_code() - sent = await self.client.post("/share/text/", data={"text": "hello"}, headers=headers) - self.assertEqual(sent.status_code, 200, sent.text) - await batch_codes(BatchDeliveryCodes(ids=[code_id], action="update", max_uploads=5)) - await batch_codes(BatchDeliveryCodes(ids=[code_id], action="delete")) - record = await DeliveryCode.get(id=code_id) - self.assertTrue(record.deleted) - self.assertEqual(record.max_uploads, 5) - self.assertEqual(await FileCodes.filter(delivery_id=code_id).count(), 1) - - async def test_changed_code_cannot_commit_old_reservation(self): - code_id, _ = await self.make_code() - access = UploadAccess(code_id=code_id) - await prepare_upload(access, "Text", 5, "old-session") - await update_code(code_id, UpdateDeliveryCode(code="ChangedCode1")) - with self.assertRaises(HTTPException): - await commit_delivery(access.record, {"code": "result", "text": "hello", "size": 5}) - self.assertEqual(await FileCodes.all().count(), 0) - self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 0) - - async def test_private_migration_never_creates_public_download(self): - # 在真实旧迁移表上构造历史数据,迁移后再次执行验证幂等性。 - conn = connections.get("default") - await conn.execute_query("DROP TABLE deliverycode") - for number in (8, 9, 10, 12): - await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() - await conn.execute_query( - "INSERT INTO deliveryfile (delivery_id, token, filename, stored_name, file_path, storage_type, size, status) " - "VALUES (77, 'legacy-token', 'private.txt', 'object.txt', 'inbox', 'local', 7, 'stored')" - ) - migration = importlib.import_module("apps.base.migrations.migrations_013") - await migration.migrate() - await migration.migrate() - file = await FileCodes.get(delivery_id=77) - self.assertTrue(file.is_private) - self.assertFalse((await get_code_file_by_code(file.code))[0]) - self.assertFalse(await conn.execute_query_dict("SELECT name FROM sqlite_master WHERE name='deliveryfile'")) - self.assertEqual((await get_storage_usage())["used"], 7) - - async def test_chunk_delivery_completion_retry_does_not_consume_twice(self): - code_id, headers = await self.make_code(max_uploads=1) - result = await self.client.post("/chunk/upload/init/", headers=headers, json={ - "file_name": "sample.txt", "file_size": 5, "chunk_size": 5, "file_hash": "hash", - }) - token = result.json()["detail"]["upload_id"] - part = await self.client.post(f"/chunk/upload/chunk/{token}/0", headers=headers, - files={"chunk": ("part", b"hello", "application/octet-stream")}) - self.assertEqual(part.status_code, 200, part.text) - result = await self.client.post(f"/chunk/upload/complete/{token}", headers=headers, - json={"expire_style": "day", "expire_value": 1}) - self.assertEqual(result.status_code, 200, result.text) - retry = await self.client.post(f"/chunk/upload/complete/{token}", headers=headers, - json={"expire_style": "day", "expire_value": 1}) - self.assertEqual(retry.json()["detail"]["code"], result.json()["detail"]["code"]) - self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 1) - self.assertEqual((await get_storage_usage())["used"], 5) - download = await self.client.get("/share/select/", params={"code": result.json()["detail"]["code"]}) - self.assertEqual(download.content, b"hello") - - async def test_proxy_delivery_rejects_underreported_size(self): - _, headers = await self.make_code() - result = await self.client.post("/presign/upload/init", headers=headers, json={ - "file_name": "sample.txt", "file_size": 5, "expire_style": "day", "expire_value": 1, - }) - self.assertEqual(result.status_code, 200, result.text) - token = result.json()["detail"]["upload_id"] - wrong = await self.client.put(f"/presign/upload/proxy/{token}", headers=headers, - files={"file": ("sample.txt", b"a" * 150, "text/plain")}) - self.assertEqual(wrong.status_code, 400, wrong.text) - self.assertEqual(await FileCodes.all().count(), 0) - valid = await self.client.put(f"/presign/upload/proxy/{token}", headers=headers, - files={"file": ("sample.txt", b"hello", "text/plain")}) - self.assertEqual(valid.status_code, 200, valid.text) - self.assertEqual((await get_storage_usage())["used"], 5) - - async def test_digest_removal_keeps_known_codes_and_disables_unknown_codes(self): - # 使用旧版表验证迁移行为,缺失原文的授权只能停用,不能伪造恢复口令。 - conn = connections.get("default") - await conn.execute_query("DROP TABLE deliverycode") - for number in (8, 9, 10, 12): - await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() - expiry = await get_now() + timedelta(days=1) - await conn.execute_query( - "INSERT INTO deliverycode (id, code_digest, code_value, name, storage_type, target_path, expires_at, max_uploads, auth_version) " - "VALUES (1, 'old-digest-a', 'KeepExistingCode1', 'known', 'local', 'inbox', ?, 5, 3)", [expiry], - ) - await conn.execute_query( - "INSERT INTO deliverycode (id, code_digest, name, storage_type, target_path, expires_at, max_uploads, auth_version) " - "VALUES (2, 'old-digest-b', 'unknown', 'local', 'inbox', ?, 5, 7)", [expiry], - ) - await FileCodes.create(code="existing-share", delivery_id=2, text="private relationship", size=20) - migration = importlib.import_module("apps.base.migrations.migrations_014") - await migration.migrate() - await migration.migrate() - columns = {item["name"] for item in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} - self.assertNotIn("code_digest", columns) - self.assertNotIn("owner_id", columns) - self.assertTrue((await DeliveryCode.get(id=1)).enabled) - self.assertIn("token", await verify_code("KeepExistingCode1")) - unknown = await DeliveryCode.get(id=2) - self.assertFalse(unknown.enabled) - self.assertEqual(unknown.auth_version, 8) - self.assertTrue(await FileCodes.filter(delivery_id=2).exists()) - # 重设口令不会自动扩大权限,管理员还需显式重新启用。 - await update_code(2, UpdateDeliveryCode(code="NewKnownCode2")) - self.assertFalse((await DeliveryCode.get(id=2)).enabled) - - async def test_missing_original_requires_reset_before_enable(self): - record = await DeliveryCode.create( - code_value=None, name="需要重新设码", storage_type="local", target_path="inbox", - expires_at=await get_now() + timedelta(days=1), max_uploads=2, enabled=False, - ) - headers = {"Authorization": "Bearer " + create_token({"is_admin": True})} - response = await self.client.patch(f"/admin/delivery/codes/{record.id}", json={"enabled": True}, headers=headers) - self.assertEqual(response.status_code, 409) - with self.assertRaises(HTTPException): - await batch_codes(BatchDeliveryCodes(ids=[record.id], action="enable")) - await update_code(record.id, UpdateDeliveryCode(code="ResetAndEnable1")) - response = await self.client.patch(f"/admin/delivery/codes/{record.id}", json={"enabled": True}, headers=headers) - self.assertEqual(response.status_code, 200, response.text) - self.assertIn("token", await verify_code("ResetAndEnable1")) - - async def test_digest_migration_rejects_duplicate_originals_without_data_loss(self): - conn = connections.get("default") - await conn.execute_query("DROP TABLE deliverycode") - for number in (8, 9, 10, 12): - await importlib.import_module(f"apps.base.migrations.migrations_{number:03d}").migrate() - for number in (1, 2): - await conn.execute_query( - "INSERT INTO deliverycode (id, code_digest, code_value, name, storage_type, target_path, expires_at, max_uploads) " - "VALUES (?, ?, 'DuplicatedCode1', 'duplicate', 'local', 'inbox', ?, 2)", - [number, str(number), await get_now() + timedelta(days=1)], - ) - with self.assertRaises(RuntimeError) as error: - await importlib.import_module("apps.base.migrations.migrations_014").migrate() - self.assertNotIn("DuplicatedCode1", str(error.exception)) - columns = {item["name"] for item in await conn.execute_query_dict("PRAGMA table_info(deliverycode)")} - self.assertIn("code_digest", columns) - self.assertEqual(len(await conn.execute_query_dict("SELECT id FROM deliverycode")), 2) - - async def test_normal_upload_keeps_upstream_storage_behavior(self): - # 普通游客上传不写寄件归属或公共后端快照,也不改变上游的开放游客鉴权规则。 - settings.open_upload = 1 - response = await self.client.post('/share/text/', data={'text': 'ordinary'}, headers={'Authorization': 'Bearer invalid'}) - self.assertEqual(response.status_code, 200, response.text) - record = await FileCodes.get(code=response.json()['detail']['code']) - self.assertIsNone(record.delivery_id) - self.assertIsNone(record.storage_type) - from apps.base.models import UploadChunk, PresignUploadSession - self.assertNotIn('storage_type', UploadChunk._meta.fields_map) - self.assertNotIn('storage_type', PresignUploadSession._meta.fields_map) - - async def test_chunk_size_extension_is_limited_to_delivery(self): - # 普通分片保留上游按整片容量计算的规则,寄件才按自己的预占容量校验。 - settings.open_upload = 1 - settings.upload_size = 5 - payload = {'file_name': 'partial.txt', 'file_size': 5, 'chunk_size': 4, 'file_hash': 'partial'} - ordinary = await self.client.post('/chunk/upload/init/', json=payload) - self.assertEqual(ordinary.status_code, 403) - _, headers = await self.make_code() - delivery = await self.client.post('/chunk/upload/init/', json=payload, headers=headers) - self.assertEqual(delivery.status_code, 200, delivery.text) - - async def test_normal_file_ignores_legacy_snapshot(self): - # 旧版本曾为普通文件写入的快照不会继续改变上游默认存储选择。 - from apps.base.share_storage import storage_for_share - marker = object() - record = await FileCodes.create(code='ordinary-legacy', storage_type='s3') - with patch.dict('apps.base.share_storage.storages', {'local': lambda: marker}): - self.assertIs(await storage_for_share(record), marker) - - async def test_delivery_s3_uses_existing_proxy_without_exposing_direct_url(self): - # 使用会生成直传地址的驱动替身,确认寄件根本不会请求该 URL;普通上传保持直传。 - class DirectStorage: - calls = 0 - - async def generate_presigned_upload_url(self, path, expires): - self.calls += 1 - return 'https://storage.invalid/presigned-test' - - backend = DirectStorage() - settings.file_storage = 's3' - settings.s3_access_key_id = 'test-key' - settings.s3_secret_access_key = 'test-secret' - settings.s3_bucket_name = 'test-bucket' - settings.s3_endpoint_url = 'http://127.0.0.1:9000' - result = await create_code(CreateDeliveryCode( - name='S3寄件', code='S3ScopedDelivery1', storage_type='s3', target_path='incoming', - expires_at=await get_now() + timedelta(days=1), max_uploads=2, - )) - session = await verify_code('S3ScopedDelivery1') - headers = {'Authorization': 'Bearer ' + session['token']} - payload = {'file_name': 'sample.txt', 'file_size': 5, 'expire_style': 'day'} - with patch.dict('core.storage.storages', {'s3': lambda: backend}): - response = await self.client.post('/presign/upload/init', json=payload, headers=headers) - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(response.json()['detail']['mode'], 'proxy') - self.assertEqual(backend.calls, 0) - settings.open_upload = 1 - ordinary = await self.client.post('/presign/upload/init', json=payload) - self.assertEqual(ordinary.status_code, 200, ordinary.text) - self.assertEqual(ordinary.json()['detail']['mode'], 'direct') - self.assertEqual(backend.calls, 1) - - async def test_legacy_delivery_direct_session_cannot_bypass_proxy(self): - # 即使升级前数据库中留有直传会话,也不能绕开现在的寄件代理校验。 - from apps.base.models import PresignUploadSession - code_id, headers = await self.make_code(max_uploads=1) - result = await self.client.post('/presign/upload/init', headers=headers, json={ - 'file_name': 'legacy.txt', 'file_size': 5, 'expire_style': 'day', - }) - token = result.json()['detail']['upload_id'] - await PresignUploadSession.filter(upload_id=token).update(mode='direct') - response = await self.client.post('/presign/upload/confirm/' + token, headers=headers) - self.assertEqual(response.status_code, 409, response.text) - self.assertEqual(await FileCodes.all().count(), 0) - self.assertEqual((await DeliveryCode.get(id=code_id)).used_count, 0) - self.assertEqual((await DeliveryCode.get(id=code_id)).reserved_count, 0) - old_reservation = await StorageReservation.get(token=token) - self.assertEqual(old_reservation.status, 'cleanup') - # 旧签名过期前保留清理记录,避免取消后又写入同一对象造成永久残留。 - self.assertGreater(old_reservation.expires_at, await get_now()) - retry = await self.client.post('/presign/upload/init', headers=headers, json={ - 'file_name': 'retry.txt', 'file_size': 5, 'expire_style': 'day', - }) - self.assertEqual(retry.status_code, 200, retry.text) diff --git a/tests/test_issue_464_admin_file_list.py b/tests/test_issue_464_admin_file_list.py index ec5e09ce8..6283cb2bc 100644 --- a/tests/test_issue_464_admin_file_list.py +++ b/tests/test_issue_464_admin_file_list.py @@ -26,8 +26,6 @@ def __init__(self): self.file_hash = "a" * 64 self.is_chunked = False self.upload_id = None - # 与新模型保持一致:普通文件可公开,历史私有收件单独标记。 - self.is_private = False async def is_expired(self): return False @@ -60,10 +58,3 @@ def test_build_admin_file_item_marks_expiring_soon(self): self.assertEqual(item["status_insights"]["severity"], "warning") self.assertIn("expires_soon", item["status_insights"]["reasons"]) - - def test_private_receipt_does_not_expose_public_retrieval_code(self): - file_code = FakeFileCode() - file_code.is_private = True - item = asyncio.run(FileService()._build_admin_file_item(file_code)) - self.assertTrue(item["is_private"]) - self.assertEqual(item["code"], "") From b99e33b6a5095456ffe1269dafdc808df5377c9e Mon Sep 17 00:00:00 2001 From: dawnstamp <310845922@qq.con> Date: Sat, 19 Sep 2026 18:19:48 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(=E5=AF=84=E4=BB=B6):=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=8B=AC=E7=AB=8B=E5=AD=98=E5=82=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=E7=B3=BB=E7=BB=9F=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 寄件码只保存授权信息,新上传直接使用系统存储及原路径生成器;移除自定义存储目录接口和重复校验。迁移保留授权计数及历史文件、进行中会话的位置。 验证:原有后端测试 114 项通过、2 项跳过;内存数据库检查迁移幂等、系统配置跟随及旧文件定位;未新增测试文件。 --- apps/base/migrations/migrations_015.py | 42 +++++++++++++++++++++++ apps/base/models.py | 3 -- apps/base/upload_access.py | 3 +- apps/base/upload_sessions.py | 24 +++++-------- apps/delivery/schemas.py | 47 ++------------------------ apps/delivery/services.py | 37 ++++++-------------- apps/delivery/storage.py | 30 ---------------- apps/delivery/views.py | 4 +-- core/path_validation.py | 24 ------------- docs/guide/delivery.md | 14 ++++---- readme.md | 4 +-- 11 files changed, 76 insertions(+), 156 deletions(-) create mode 100644 apps/base/migrations/migrations_015.py delete mode 100644 apps/delivery/storage.py delete mode 100644 core/path_validation.py 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 1e4c73128..f9510114d 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -119,9 +119,6 @@ class DeliveryCode(models.Model): name = fields.CharField(max_length=100) note = fields.CharField(max_length=2000, default="") tags = fields.JSONField(default=list) - # system 仅标记寄件码跟随设置,实际收件记录始终保存解析后的存储类型与目录。 - storage_type = fields.CharField(max_length=20) - target_path = fields.CharField(max_length=200) expires_at = fields.DatetimeField() max_uploads = fields.IntField() used_count = fields.IntField(default=0) diff --git a/apps/base/upload_access.py b/apps/base/upload_access.py index 9990de5e7..f5d872b4a 100644 --- a/apps/base/upload_access.py +++ b/apps/base/upload_access.py @@ -12,7 +12,6 @@ from apps.delivery.services import upload_identity, active_code from core.settings import settings from core.storage import storages -from apps.delivery.storage import get_storage from core.utils import get_now @@ -89,7 +88,7 @@ async def prepare_upload(access, file_name, file_size, upload_id): async def upload_storage(access=None): if access is not None and access.record is not None: - return await get_storage(access.record.storage_type) + return storages[access.record.storage_type]() return storages[settings.file_storage]() diff --git a/apps/base/upload_sessions.py b/apps/base/upload_sessions.py index d616b949e..c2c18904d 100644 --- a/apps/base/upload_sessions.py +++ b/apps/base/upload_sessions.py @@ -13,17 +13,14 @@ from apps.base.utils import build_file_path from core.logger import logger from core.settings import settings -from core.storage import StoredFile -from apps.delivery.storage import get_storage, validate_storage_config -from core.utils import get_now, sanitize_filename +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): """次数原子预占与会话创建同事务;实际字节随后由共用配额入口预留。""" - filename = await sanitize_filename((file_name or "file").replace("\\", "/").split("/")[-1]) - filename = filename.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "file" async with in_transaction() as conn: now = await get_now() p = _sql_placeholders(3) @@ -35,18 +32,13 @@ async def reserve_slot(code_id, version, file_name, upload_id): ) if changed != 1: raise HTTPException(409, "寄件码已失效或没有剩余上传次数") - code = await DeliveryCode.get(id=code_id).using_db(conn) - storage_type, path = code.storage_type, code.target_path - if storage_type == "system": - storage_type = settings.file_storage - path, *_ = await build_file_path("delivery", upload_id) - validate_storage_config(storage_type) + # 每次新寄件都沿用原系统的存储设置和路径生成器,不读取寄件码独立配置。 + storage_type = settings.file_storage + path, _, _, stored_name, _ = await build_file_path(file_name or "file", upload_id) token = "d_" + upload_id - if code.storage_type != "system": - path = f"{path}/{token}" return await StorageReservation.create( token=token, size=0, delivery_id=code_id, auth_version=version, - filename=filename, stored_name=token + "_" + filename, + filename=stored_name, stored_name=stored_name, file_path=path, storage_type=storage_type, expires_at=now + timedelta(seconds=STALE_SECONDS), using_db=conn, ) @@ -87,7 +79,7 @@ async def clean_reservation(record_id): if record is None: return try: - storage = await get_storage(record.storage_type) + storage = storages[record.storage_type]() if record.stored_name: path = f"{record.file_path}/{record.stored_name}" await storage.clean_chunks(record.token, path) @@ -131,7 +123,7 @@ async def cleanup_once(): 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 = await get_storage(share.storage_type) + 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: diff --git a/apps/delivery/schemas.py b/apps/delivery/schemas.py index 55d5fc98a..74d661116 100644 --- a/apps/delivery/schemas.py +++ b/apps/delivery/schemas.py @@ -1,4 +1,4 @@ -"""寄件参数校验,所有路径都相对于已配置的存储根目录。""" +"""只校验寄件授权参数,存储和文件规则由系统设置统一控制。""" import re from datetime import datetime, timezone, timedelta @@ -7,34 +7,16 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from apps.base.metadata import normalize_metadata_note, normalize_metadata_tags -from core.path_validation import validate_storage_directory class DeliveryCodeConfig(BaseModel): - """创建和编辑共用配置校验,编辑请求不能改动口令或计数。""" + """寄件授权的公共字段,文件和存储配置不在此模型中。""" # 禁止静默接受 owner_id 等越权字段,未来多用户必须由服务端身份决定归属。 model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) name: str = Field(min_length=1, max_length=100) - # system 表示上传开始时跟随全站配置;旧记录的明确存储值继续保留。 - storage_type: str = "system" - target_path: str = Field(default="", max_length=200) expires_at: datetime max_uploads: int = Field(default=1, ge=1, le=100000) - @field_validator("storage_type") - @classmethod - def validate_storage(cls, value): - # 配置入口与设置页保持一致;历史收件的存储驱动仍保留供读取和清理。 - if value not in {"system", "local", "s3", "webdav"}: - raise ValueError("不支持的存储类型") - return value - - @field_validator("target_path") - @classmethod - def validate_path(cls, value): - # 目录校验与系统设置共用,避免同一路径在两个入口产生不同结果。 - return validate_storage_directory(value, allow_empty=True, max_length=200) - @field_validator("expires_at") @classmethod def validate_expiry(cls, value): @@ -45,15 +27,6 @@ def validate_expiry(cls, value): raise ValueError("有效期必须晚于当前时间") return value - @model_validator(mode="after") - def validate_storage_mode(self): - # 跟随系统时不保存隐藏表单中残留的自定义目录。 - if self.storage_type == "system": - self.target_path = "" - elif not self.target_path: - raise ValueError("自定义存储位置必须填写目标目录") - return self - class CreateDeliveryCode(DeliveryCodeConfig): # 新建自定义寄件码最多 32 位;自动生成仍固定为 16 位。 @@ -85,26 +58,12 @@ class UpdateDeliveryCode(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) name: str | None = Field(default=None, min_length=1, max_length=100) - storage_type: str | None = None - target_path: str | None = Field(default=None, max_length=200) 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("storage_type") - @classmethod - def validate_storage(cls, value): - if value is not None and value not in {"system", "local", "s3", "webdav"}: - raise ValueError("不支持的存储类型") - return value - - @field_validator("target_path") - @classmethod - def validate_path(cls, value): - return None if value is None else validate_storage_directory(value, allow_empty=True, max_length=200) - @field_validator("code") @classmethod def validate_code(cls, value): @@ -131,7 +90,7 @@ def normalize_tags(cls, value): @model_validator(mode="after") def reject_explicit_null(self): # 可选字段表示可以省略,不能用 null 意外清空已有配置。 - forbidden = {"name", "storage_type", "target_path", "expires_at", "max_uploads", "note", "tags"} + 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 diff --git a/apps/delivery/services.py b/apps/delivery/services.py index d3c5082fa..557eca866 100644 --- a/apps/delivery/services.py +++ b/apps/delivery/services.py @@ -11,8 +11,7 @@ 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.delivery.storage import validate_storage_config -from core.settings import settings +from apps.base.setup_wizard import build_public_config from core.utils import get_now TOKEN_TTL = 900 @@ -46,12 +45,10 @@ async def active_code(code_id: int) -> DeliveryCode: async def create_code(data): """创建时保存原文,便于管理员后续查看;访客响应仍不提供任何口令列表。""" - validate_storage_config(settings.file_storage if data.storage_type == "system" else data.storage_type) code = data.code or "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16)) try: record = await DeliveryCode.create( code_value=code, name=data.name, - storage_type=data.storage_type, target_path=data.target_path, expires_at=data.expires_at, max_uploads=data.max_uploads, note=data.note, tags=data.tags, ) @@ -73,10 +70,10 @@ async def code_summary(record, *, include_code=False): elif record.expires_at <= now: state = "expired" return { - "id": record.id, "name": record.name, "storage_type": record.storage_type, + "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 [], - "target_path": record.target_path, "expires_at": record.expires_at, + "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, @@ -98,14 +95,16 @@ async def verify_code(code: str): 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, "upload_size": settings.upload_size, - "allowed_file_types": settings.allowed_file_types, "expire_style": settings.expire_style, - "max_save_seconds": settings.max_save_seconds, "enable_chunk": settings.enable_chunk, + "expires_at": record.expires_at, + **{key: config[key] for key in upload_keys}, } @@ -135,18 +134,6 @@ async def update_code(code_id, data): max_uploads = changes.get("max_uploads", record.max_uploads) if record.used_count + record.reserved_count > max_uploads: raise HTTPException(409, "上传总次数不能小于已使用次数与上传中占用次数之和,请刷新后重试") - storage_requested = "storage_type" in changes or "target_path" in changes - storage_type = changes.get("storage_type", record.storage_type) - target_path = changes.get("target_path", record.target_path) - if storage_type == "system": - target_path = "" - elif not target_path: - raise HTTPException(400, "自定义存储位置必须填写目标目录") - # 整理名称或元数据时不依赖当前后端配置;仅实际改存储设置才重新校验。 - if storage_requested and (storage_type != record.storage_type or target_path != record.target_path): - validate_storage_config(settings.file_storage if storage_type == "system" else storage_type) - changes["storage_type"] = storage_type - changes["target_path"] = target_path if new_code and new_code != record.code_value: # 版本由数据库递增,两个改码请求并发时任一旧令牌都不会被错误复用。 changes.update(code_value=new_code, auth_version=True) @@ -173,12 +160,10 @@ async def update_code(code_id, data): return await code_summary(record) -async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_type="all", tag="", sort_by="created_at", sort_order="desc"): +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 storage_type not in {"all", "system", "local", "s3", "webdav"}: - 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) @@ -191,8 +176,6 @@ async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_ 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 storage_type != "all": - query = query.filter(storage_type=storage_type) if keyword.strip(): query = query.filter(Q(name__icontains=keyword.strip()) | Q(note__icontains=keyword.strip())) if tag.strip(): @@ -206,7 +189,7 @@ async def list_codes(*, page=1, page_size=20, keyword="", status="all", storage_ 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", "storage_type", "target_path", "expires_at", + "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} diff --git a/apps/delivery/storage.py b/apps/delivery/storage.py deleted file mode 100644 index 026285ae6..000000000 --- a/apps/delivery/storage.py +++ /dev/null @@ -1,30 +0,0 @@ -"""寄件仅验证已配置的存储类型,文件读写直接复用上游驱动。""" - -from urllib.parse import urlparse -from fastapi import HTTPException -from core.settings import settings -from core.storage import storages - - -def validate_storage_config(kind): - """复用站点存储配置,缺少必要配置时不发放新的寄件授权。""" - required = { - "local": [], - "s3": ["s3_access_key_id", "s3_secret_access_key", "s3_bucket_name"], - "webdav": ["webdav_url"], - } - if kind not in {"local", "s3", "webdav"}: - raise HTTPException(422, "寄件仅支持本地、S3 和 WebDAV 存储") - missing = [key for key in required[kind] if not str(getattr(settings, key, "") or "").strip()] - if missing: - raise HTTPException(422, f"请先在站点后台配置 {kind}:" + "、".join(missing)) - if kind in {"s3", "webdav"}: - url = settings.webdav_url if kind == "webdav" else (settings.s3_endpoint_url or f"https://{settings.s3_hostname}") - parsed = urlparse(url) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise HTTPException(422, f"请先配置有效的 {kind} 服务地址") - - -async def get_storage(kind): - """不包装或重写驱动行为;历史记录读取也交由已有驱动处理。""" - return storages[kind]() diff --git a/apps/delivery/views.py b/apps/delivery/views.py index d7d5c1656..6e8c45b24 100644 --- a/apps/delivery/views.py +++ b/apps/delivery/views.py @@ -47,10 +47,10 @@ async def refresh(authorization: str | None = Header(default=None)): @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", storage_type: str = "all", tag: str = "", sort_by: str = "created_at", sort_order: str = "desc", + 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, storage_type=storage_type, + page=page, page_size=page_size, keyword=keyword, status=status, tag=tag, sort_by=sort_by, sort_order=sort_order, )) diff --git a/core/path_validation.py b/core/path_validation.py deleted file mode 100644 index 09ff561cb..000000000 --- a/core/path_validation.py +++ /dev/null @@ -1,24 +0,0 @@ -"""设置和寄件配置共用目录校验,仅验证输入,不改变各自的路径生成规则。""" - -import re -from pathlib import PurePosixPath - - -def validate_storage_directory(value: str, *, allow_empty: bool = False, max_length: int = 200) -> str: - """限制为存储根目录内的相对目录,跨本地、对象存储和 WebDAV 使用同一规则。""" - if not isinstance(value, str): - raise ValueError("存储目录必须是字符串") - value = value.strip() - if not value and allow_empty: - return "" - parts = value.split("/") - if ( - not value or len(value) > max_length - or PurePosixPath(value).is_absolute() - or not re.fullmatch(r"[\w ./-]+", value, re.UNICODE) - or any(part in {"", ".", ".."} for part in parts) - or any(part.endswith((".", " ")) for part in parts) - or any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?", part) for part in parts) - ): - raise ValueError(f"存储目录须为 {max_length} 字以内的相对路径,如 inbox/project-a;不能包含绝对路径、路径跳转或保留名称") - return value diff --git a/docs/guide/delivery.md b/docs/guide/delivery.md index fbc375cd0..e0c1ed514 100644 --- a/docs/guide/delivery.md +++ b/docs/guide/delivery.md @@ -1,11 +1,11 @@ # 寄件码 -寄件码是管理员发放的受限上传授权。即使关闭游客上传,访客仍可凭有效寄件码投递到指定存储目录。成功上传返回普通取件码,接收者无需登录即可按取件期限下载。寄件码本身不能下载文件,也不能访问管理接口。 +寄件码是管理员发放的受限上传授权。即使关闭游客上传,访客仍可凭有效寄件码投递到系统设置指定的存储目录。成功上传返回普通取件码,接收者无需登录即可按取件期限下载。寄件码本身不能下载文件,也不能访问管理接口。 ## 使用与主题 1. 安装配套 2024 主题,在原后台“寄件管理”创建口令、有效期和最大上传次数。 -2. 默认跟随系统存储;如需指定目录,选择自定义存储并填写相对路径。 +2. 存储方式、目录、文件大小和类型、分片开关及文件过期策略全部使用系统设置,寄件码不提供独立覆盖。 3. 创建结果提供口令、链接和二维码。列表不返回口令原文,点击“查看 / 复制”才通过管理员专用接口读取,响应禁止缓存。 4. 访客进入寄件链接,验证后复用普通发送页面。文件、文本以及一个多文件 ZIP 都按一次成功分享计次。 5. 管理员点击“查看收件”,使用原文件管理的详情、下载、编辑和删除功能。 @@ -21,7 +21,7 @@ - 仅保留 `DeliveryCode` 授权表,成功文件通过 `FileCodes.delivery_id` 关联。上传期间的归属、路径及容量复用 `StorageReservation`,不再维护第二份成功收件记录。 - 额度耗尽后自动停用并保留寄件码,仍可按码查看收件。管理员增加额度后可重新启用。删除采用软删除,保留历史关联,已生成取件码继续独立有效。 - 删除文件不退还已经使用的寄件次数。普通文件清理负责成功收件;未完成上传清理失败时保留容量预留计费,后台重试成功后才释放。 -- 寄件文件保存到授权目录下的独立上传子目录,复用原存储驱动并避免同名覆盖。寄件预留和成功文件保留授权后端;普通文件与普通上传会话不新增通用快照能力。 +- 寄件文件使用原系统路径生成器和存储驱动,与普通发送采用相同目录规则。已开始上传的会话和成功文件保留实际后端与位置,以免系统设置切换后找错文件。 - 寄件授权与文件领取期限独立。到期、改码或手动停用后,未提交的上传不能完成。 ## 授权与续期 @@ -34,16 +34,18 @@ ## 存储 -自定义存储支持 `local`、`s3`、`webdav`,`system` 表示跟随当前设置。目标路径相对于对应后端的根目录,禁止绝对路径、路径穿越、转义路径和 Windows 保留设备名。访客不能指定目标路径,响应也不返回该路径。 +寄件不再保存或接收存储类型和目标目录配置。每次新上传读取系统当前 `file_storage` 和 `storage_path`,调用原 `build_file_path` 与原存储驱动。账号、密钥、桶名、服务地址等只在系统设置维护。 -本功能直接调用站点已有的本地、S3、WebDAV 驱动,不新增或修改公共存储驱动。寄件关联只保存授权时选择的后端类型和对象位置,不复制密钥;同一后端更换账号、桶或根目录时需按原系统方式迁移文件。 +上传限制直接使用原发送页的同一个配置 Store;后端仍由原上传服务实时校验站点限制。寄件独有的数据仅限口令、用途、授权期限、次数、启停及管理备注标签。授权期限与文件领取期限是不同概念,前者控制能否投递,后者仍由原文件管理处理。 ## 升级 -启动依次执行迁移 008~014。升级保留既有数据:013 将成功分享关联迁入 `FileCodes`,旧私有文件转入同表但标记 `is_private`,公共取件接口拒绝访问;后台仍能管理和下载。旧未完成上传取消并转为待清理预留,不跨版本继续提交。 +启动依次执行迁移 008~015。升级保留既有数据:013 将成功分享关联迁入 `FileCodes`,旧私有文件转入同表但标记 `is_private`,公共取件接口拒绝访问;后台仍能管理和下载。旧未完成上传取消并转为待清理预留,不跨版本继续提交。 旧版本已物理删除的寄件码,会为其尚存收件补充停用的历史记录。所有转移完成后删除旧 `deliveryfile` 表。迁移使用事务并支持重试,不修改实际存储文件。 普通上传的参数模型、分片规则、默认存储选择以及驱动实现与主干保持一致。寄件额外校验仅作用于持有寄件授权的请求,保障其次数与容量预占不被绕过。旧版本曾添加到普通上传会话中的快照列可留在数据库中,但运行时代码不再读取或写入。 迁移 014 移除旧摘要和未使用的账号归属列。有原文的寄件码保持原口令和授权状态;缺少原文的历史码保留 ID、次数和收件关系,停用并递增授权版本。管理员必须先编辑设置新口令,再显式启用;不能依靠旧口令或旧令牌恢复授权。 + +迁移 015 移除寄件码的独立存储类型和目录字段,保留授权 ID、口令、计数与历史收件关系。已上传文件及正在上传的会话不搬动;新上传全部跟随系统当前设置。 diff --git a/readme.md b/readme.md index ac92322d1..48c4efbde 100644 --- a/readme.md +++ b/readme.md @@ -23,11 +23,11 @@ ## 寄件码 -使用 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码投递到指定目录;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 +使用 [寄件码功能](./docs/guide/delivery.md):管理员预先生成口令,临时访客凭码按系统设置投递;关闭游客自由上传后仍可收件。寄件码只授权上传,后台可管理有效期、次数和已收文件。 - 访客投递:`/delivery` - 寄件管理:`/delivery/admin`(使用本站管理员密码) -- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–014。2023 前端源码未修改,旧主题访问寄件入口时提示更新并切换到 2024 主题。 +- 原生寄件界面适用于 2024 主题,需配套更新前端;启动时自动执行迁移 008–015。2023 前端源码未修改,旧主题访问寄件入口时提示更新并切换到 2024 主题。 ## 一条命令开始