From 644b106c323d88f793d5579525032a63ce54979c Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 05:47:08 +0800
Subject: [PATCH 01/31] fix: constant-time comparison for download tokens
(security item 5)
Replace the set-membership key check in /share/download with
hmac.compare_digest over the two valid window tokens; add negative-path
tests (wrong key 403, both windows accepted, foreign-code token 403).
---
apps/base/views.py | 10 +++---
tests/test_download_token_boundary.py | 51 +++++++++++++++++++++++++++
2 files changed, 57 insertions(+), 4 deletions(-)
create mode 100644 tests/test_download_token_boundary.py
diff --git a/apps/base/views.py b/apps/base/views.py
index 85746bf4a..c9ff45268 100644
--- a/apps/base/views.py
+++ b/apps/base/views.py
@@ -1,4 +1,5 @@
import hashlib
+import hmac
import os
import uuid
from datetime import timedelta
@@ -243,12 +244,13 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
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 = {
+ # 同时接受当前窗口与上一窗口 token,避免时间窗边界竞态导致偶发 403。
+ # 逐个常量时间比较(hmac.compare_digest),避免 set 成员判断的时序侧信道。
+ valid_keys = [
await get_select_token(normalized_code, offset=0),
await get_select_token(normalized_code, offset=1),
- }
- if key not in valid_keys:
+ ]
+ if not any(hmac.compare_digest(key, candidate) for candidate in valid_keys):
ip_limit["error"].add_ip(ip)
raise HTTPException(status_code=403, detail="下载鉴权失败")
has, file_code = await get_code_file_by_code(normalized_code)
diff --git a/tests/test_download_token_boundary.py b/tests/test_download_token_boundary.py
new file mode 100644
index 000000000..7f11cfe68
--- /dev/null
+++ b/tests/test_download_token_boundary.py
@@ -0,0 +1,51 @@
+"""Download endpoint edge paths: token verification failure modes.
+
+Covers the negative space of /share/download — the constant-time token
+comparison must reject malformed keys with 403 while still accepting both
+the current and the previous time-window token (boundary race tolerance).
+"""
+import pytest
+
+from core.utils import get_select_token
+
+
+@pytest.mark.asyncio
+class TestDownloadTokenBoundary:
+ async def test_wrong_key_is_rejected_with_403(self, initialized_client):
+ share = await initialized_client.post(
+ "/share/text", data={"text": "token fixture", "expire_style": "day"}
+ )
+ assert share.status_code == 200
+ code = share.json()["detail"]["code"]
+
+ response = await initialized_client.get(
+ "/share/download", params={"key": "0" * 64, "code": code}
+ )
+ assert response.status_code == 403
+
+ async def test_both_time_window_tokens_are_accepted(self, initialized_client):
+ share = await initialized_client.post(
+ "/share/text", data={"text": "token fixture", "expire_style": "day"}
+ )
+ assert share.status_code == 200
+ code = share.json()["detail"]["code"]
+
+ for offset in (0, 1):
+ token = await get_select_token(code, offset=offset)
+ response = await initialized_client.get(
+ "/share/download", params={"key": token, "code": code}
+ )
+ assert response.status_code == 200, f"offset={offset}"
+
+ async def test_token_of_other_code_is_rejected(self, initialized_client):
+ share = await initialized_client.post(
+ "/share/text", data={"text": "token fixture", "expire_style": "day"}
+ )
+ assert share.status_code == 200
+ code = share.json()["detail"]["code"]
+
+ foreign_token = await get_select_token("other-code", offset=0)
+ response = await initialized_client.get(
+ "/share/download", params={"key": foreign_token, "code": code}
+ )
+ assert response.status_code == 403
From 014cd9087dfe13b92d705b53a509d25972afa725 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 05:50:29 +0800
Subject: [PATCH 02/31] fix: single source for attachment headers + guard tests
(security item 2)
All six Content-Disposition construction sites across the five storage
backends now go through build_attachment_headers; the header is what
neutralizes stored XSS on the same-origin download path. Guard tests
assert every get_file_response uses the builder and no hand-built
disposition reappears.
---
core/storage.py | 63 +++++++++++++---------------------
tests/test_attachment_guard.py | 59 +++++++++++++++++++++++++++++++
2 files changed, 82 insertions(+), 40 deletions(-)
create mode 100644 tests/test_attachment_guard.py
diff --git a/core/storage.py b/core/storage.py
index c5ab29066..17f99c7d7 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -65,6 +65,20 @@ def get_file_path(self) -> str:
return f"{self.file_path}/{self.uuid_file_name}"
+
+def build_attachment_headers(filename: str, content_length=None) -> dict:
+ """所有存储后端统一的下载响应头。
+
+ Content-Disposition: attachment 是防御存储型 XSS 的关键——同源下载路径
+ (/share/download)因此永不内联渲染 HTML/SVG。此函数是唯一构造点,
+ 新增后端必须复用(tests/test_attachment_guard.py 有源码级 tripwire)。
+ """
+ encoded_filename = quote(filename, safe="")
+ headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
+ if content_length is not None:
+ headers["Content-Length"] = str(content_length)
+ return headers
+
class FileStorageInterface:
@staticmethod
@@ -224,17 +238,11 @@ async def get_file_response(self, file_code: StoredFile):
if not file_path.exists():
raise StorageError(status_code=404, detail="文件已过期删除")
filename = f"{file_code.prefix}{file_code.suffix}"
- encoded_filename = quote(filename, safe='')
- content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}"
-
- # 尝试获取文件系统大小,如果成功则设置 Content-Length
- headers = {"Content-Disposition": content_disposition}
try:
- content_length = file_path.stat().st_size
- headers["Content-Length"] = str(content_length)
- except Exception:
- # 如果获取文件大小失败,则不提供 Content-Length
- pass
+ headers = build_attachment_headers(filename, file_path.stat().st_size)
+ except OSError:
+ # 文件大小不可得时省略 Content-Length
+ headers = build_attachment_headers(filename)
return StoredDownload(
filename=filename,
@@ -451,12 +459,7 @@ async def stream_generator():
finally:
await session.close()
- encoded_filename = quote(filename, safe='')
- headers = {
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
- }
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
+ headers = build_attachment_headers(filename, content_length)
return StoredDownload(
filename=filename,
headers=headers,
@@ -803,12 +806,7 @@ async def stream_generator():
finally:
await session.close()
- encoded_filename = quote(filename, safe='')
- headers = {
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
- }
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
+ headers = build_attachment_headers(filename, content_length)
return StoredDownload(
filename=filename,
headers=headers,
@@ -1007,12 +1005,7 @@ async def get_file_response(self, file_code: StoredFile):
except AttributeError:
# 如果 reader 方法不存在,回退到全量读取(兼容旧版本)
content = await self.operator.read(file_code.get_file_path())
- encoded_filename = quote(filename, safe='')
- headers = {
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
- }
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
+ headers = build_attachment_headers(filename, content_length)
return StoredDownload(
filename=filename,
headers=headers,
@@ -1027,12 +1020,7 @@ async def stream_generator():
break
yield chunk
- encoded_filename = quote(filename, safe='')
- headers = {
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
- }
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
+ headers = build_attachment_headers(filename, content_length)
return StoredDownload(
filename=filename,
headers=headers,
@@ -1275,12 +1263,7 @@ async def stream_generator():
finally:
await session.close()
- encoded_filename = quote(filename, safe='')
- headers = {
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
- }
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
+ headers = build_attachment_headers(filename, content_length)
return StoredDownload(
filename=filename,
headers=headers,
diff --git a/tests/test_attachment_guard.py b/tests/test_attachment_guard.py
new file mode 100644
index 000000000..517e57916
--- /dev/null
+++ b/tests/test_attachment_guard.py
@@ -0,0 +1,59 @@
+"""Guard: every storage backend forces Content-Disposition: attachment.
+
+The attachment header is the defense that neutralizes stored XSS for
+HTML/SVG payloads on the same-origin download path. Regression net for the
+five get_file_response implementations — if a backend stops going through
+build_attachment_headers (or hand-rolls an inline disposition), the suite
+fails here.
+"""
+import ast
+from pathlib import Path
+
+from core.storage import build_attachment_headers
+
+
+def test_helper_forces_attachment_and_encodes_filename():
+ headers = build_attachment_headers("x.html")
+ assert headers["Content-Disposition"].startswith("attachment;")
+ assert "x.html" in headers["Content-Disposition"]
+
+
+def test_helper_includes_content_length_when_known():
+ headers = build_attachment_headers("x.bin", 123)
+ assert headers["Content-Length"] == "123"
+ assert "Content-Length" not in build_attachment_headers("x.bin")
+
+
+def test_every_get_file_response_uses_shared_builder():
+ source = Path("core/storage.py").read_text(encoding="utf-8")
+ tree = ast.parse(source)
+ methods = [
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == "get_file_response"
+ ]
+ concrete = [
+ m
+ for m in methods
+ if "NotImplementedError" not in ast.get_source_segment(source, m)
+ ]
+ assert len(methods) - len(concrete) == 1, "expected exactly one abstract stub"
+ assert len(concrete) == 5, "expected one get_file_response per storage backend"
+ for method in concrete:
+ segment = ast.get_source_segment(source, method)
+ assert segment is not None
+ assert "build_attachment_headers(" in segment, (
+ f"get_file_response at line {method.lineno} bypasses the shared "
+ "attachment header builder"
+ )
+
+
+def test_no_hand_built_disposition_outside_helper():
+ source = Path("core/storage.py").read_text(encoding="utf-8")
+ helper_start = source.index("def build_attachment_headers")
+ helper_end = source.index("class FileStorageInterface")
+ outside_helper = source[:helper_start] + source[helper_end:]
+ assert "Content-Disposition" not in outside_helper, (
+ "a hand-built Content-Disposition header reappeared outside "
+ "build_attachment_headers"
+ )
From 9d5fa57ef7ebf65ea0f4ba03a8a9c441a62e1b40 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 05:53:44 +0800
Subject: [PATCH 03/31] fix: reject internal-network endpoints for storage
config (security item 7)
s3_endpoint_url/s3_hostname/webdav_url are fetched server-side; with
APP_ENV=production the write entry now enforces http(s) schemes and a
loopback/private/link-local/metadata host blacklist. Development env
stays open for local minio/webdav. Already-stored values are never
re-validated, so existing deployments are unaffected.
---
apps/admin/services.py | 29 ++++++-
core/security.py | 66 ++++++++++++++++
tests/test_outbound_endpoint_validation.py | 90 ++++++++++++++++++++++
3 files changed, 184 insertions(+), 1 deletion(-)
create mode 100644 tests/test_outbound_endpoint_validation.py
diff --git a/apps/admin/services.py b/apps/admin/services.py
index be9ae4201..d99eb6bec 100644
--- a/apps/admin/services.py
+++ b/apps/admin/services.py
@@ -13,7 +13,13 @@
)
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 core.security import (
+ INTERNAL_CONFIG_KEYS,
+ OUTBOUND_ENDPOINT_CONFIG_KEYS,
+ generate_jwt_secret,
+ validate_outbound_endpoint,
+ validate_outbound_hostname,
+)
from apps.base.models import FileCodes, KeyValue
from apps.base.utils import get_expire_info
from apps.base.local_share import (
@@ -1571,6 +1577,27 @@ async def update_config(self, data: dict):
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+ # 只校验"发生变化"的值:企业内网 minio/webdav 是正当场景,存量部署历史
+ # 合法写入的内网 endpoint 若每次保存都重新校验,会连无关设置都保存不了
+ # (background 曾有同款回归,上游 #528 修复过——本处沿用同一语义)。
+ # s3_hostname 是裸主机名(存储层按 https://{hostname} 拼接),单独分档校验。
+ for endpoint_key in OUTBOUND_ENDPOINT_CONFIG_KEYS:
+ if endpoint_key not in next_config:
+ continue
+ candidate = str(next_config[endpoint_key] or "")
+ current = str(getattr(settings, endpoint_key, "") or "")
+ if candidate == current:
+ continue
+ validator = (
+ validate_outbound_hostname
+ if endpoint_key == "s3_hostname"
+ else validate_outbound_endpoint
+ )
+ try:
+ validator(candidate)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
if admin_password_changed:
next_config["jwt_secret"] = generate_jwt_secret()
diff --git a/core/security.py b/core/security.py
index 1e8055632..03c892bcc 100644
--- a/core/security.py
+++ b/core/security.py
@@ -1,7 +1,10 @@
import copy
+import ipaddress
+import os
import secrets
from dataclasses import dataclass
from typing import Any
+from urllib.parse import urlsplit
from core.utils import hash_password, is_password_hashed, verify_password
@@ -67,3 +70,66 @@ def prepare_security_config(config: dict[str, Any]) -> SecurityConfigResult:
result.changed = True
return result
+
+
+# 出站端点白名单/黑名单(SSRF 防护)。
+# 这些配置项会被服务器直接当作上游地址访问:被劫持的管理员会话可以把实例
+# 变成内网代理跳板,或把数据发往任意地址。校验只在写入入口(update_config),
+# 已存值不受影响;APP_ENV 非 production(本地开发,如 minio/webdav)时放行。
+OUTBOUND_ENDPOINT_CONFIG_KEYS = ("s3_endpoint_url", "s3_hostname", "webdav_url")
+
+_ENDPOINT_SCHEME_WHITELIST = {"http", "https"}
+_ENDPOINT_HOST_DENY_SUFFIXES = (".local", ".internal", ".lan", ".home.arpa", ".corp")
+
+
+def _endpoint_host_is_denied(host: str) -> bool:
+ host = host.strip("[]").lower()
+ if host in {"localhost"} or host.endswith(_ENDPOINT_HOST_DENY_SUFFIXES):
+ return True
+ try:
+ ip = ipaddress.ip_address(host)
+ except ValueError:
+ return False
+ return (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local
+ or ip.is_reserved
+ or ip.is_multicast
+ )
+
+
+def validate_outbound_endpoint(value: Any) -> str:
+ """校验完整 URL 形态的出站端点(s3_endpoint_url / webdav_url)。"""
+ endpoint = str(value or "").strip()
+ if not endpoint:
+ return ""
+
+ if os.environ.get("APP_ENV", "development") != "production":
+ return endpoint
+
+ parts = urlsplit(endpoint)
+ if parts.scheme.lower() not in _ENDPOINT_SCHEME_WHITELIST:
+ raise ValueError(f"端点协议必须是 http(s):{endpoint}")
+ host = parts.hostname or ""
+ if not host:
+ raise ValueError(f"端点缺少主机名:{endpoint}")
+ if _endpoint_host_is_denied(host):
+ raise ValueError(f"端点不允许指向内网/保留地址:{endpoint}")
+ return endpoint
+
+
+def validate_outbound_hostname(value: Any) -> str:
+ """校验裸主机名形态的出站端点(s3_hostname,存储层拼接 https://{hostname})。"""
+ hostname = str(value or "").strip()
+ if not hostname:
+ return ""
+
+ if os.environ.get("APP_ENV", "development") != "production":
+ return hostname
+
+ if "://" in hostname or "/" in hostname:
+ raise ValueError(f"s3_hostname 应为裸主机名,不含协议或路径:{hostname}")
+ if _endpoint_host_is_denied(hostname.split(":")[0]):
+ raise ValueError(f"端点不允许指向内网/保留地址:{hostname}")
+ return hostname
diff --git a/tests/test_outbound_endpoint_validation.py b/tests/test_outbound_endpoint_validation.py
new file mode 100644
index 000000000..a73b9a1c0
--- /dev/null
+++ b/tests/test_outbound_endpoint_validation.py
@@ -0,0 +1,90 @@
+"""Outbound endpoint SSRF validation (write-entry only).
+
+s3_endpoint_url / s3_hostname / webdav_url are fetched server-side; a
+hijacked admin session must not be able to point them at loopback/private
+targets. Enforcement applies only with APP_ENV=production so local
+development (minio, dev webdav) keeps working; already-stored values are
+never re-validated.
+"""
+import pytest
+
+from core.security import validate_outbound_endpoint, validate_outbound_hostname
+
+
+@pytest.fixture
+def production_env(monkeypatch):
+ monkeypatch.setenv("APP_ENV", "production")
+
+
+@pytest.mark.parametrize(
+ "endpoint",
+ [
+ "http://127.0.0.1:9000",
+ "https://10.0.0.5",
+ "https://192.168.1.10:9000",
+ "http://172.16.0.1",
+ "http://169.254.169.254/latest/meta-data",
+ "https://localhost:9000",
+ "https://minio.internal:9000",
+ "https://nas.local",
+ "file:///etc/passwd",
+ "gopher://10.0.0.1",
+ "ftp://example.com",
+ "not a url",
+ ],
+)
+def test_production_rejects_internal_and_bad_scheme_endpoints(production_env, endpoint):
+ with pytest.raises(ValueError):
+ validate_outbound_endpoint(endpoint)
+
+
+@pytest.mark.parametrize(
+ "endpoint",
+ [
+ "",
+ "https://s3.amazonaws.com",
+ "https://s3.cn-north-1.amazonaws.com.cn",
+ "http://example.com:9000",
+ ],
+)
+def test_production_allows_public_https_endpoints(production_env, endpoint):
+ assert validate_outbound_endpoint(endpoint) == endpoint
+
+
+def test_development_env_allows_local_endpoints(monkeypatch):
+ monkeypatch.setenv("APP_ENV", "development")
+ assert (
+ validate_outbound_endpoint("http://127.0.0.1:9000") == "http://127.0.0.1:9000"
+ )
+
+
+@pytest.mark.parametrize(
+ "hostname",
+ [
+ "127.0.0.1",
+ "10.0.0.5",
+ "192.168.1.10:9000",
+ "localhost",
+ "minio.internal",
+ "nas.local",
+ ],
+)
+def test_production_rejects_internal_hostnames(production_env, hostname):
+ with pytest.raises(ValueError):
+ validate_outbound_hostname(hostname)
+
+
+@pytest.mark.parametrize(
+ "hostname",
+ ["", "s3.amazonaws.com", "minio.corp.example.com", "files.example.com:9000"],
+)
+def test_production_allows_public_hostnames(production_env, hostname):
+ assert validate_outbound_hostname(hostname) == hostname
+
+
+def test_hostname_tier_rejects_url_forms(production_env):
+ """s3_hostname 是裸主机名字段——传 URL 形态直接拒绝(曾用 URL 校验错误处理它)。"""
+ with pytest.raises(ValueError):
+ validate_outbound_hostname("https://s3.amazonaws.com")
+ with pytest.raises(ValueError):
+ validate_outbound_hostname("http://127.0.0.1:9000")
From 3b5e25aaaed688460f687c32fa8367e1107f4684 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 06:01:48 +0800
Subject: [PATCH 04/31] fix: JSON 404 for handler-raised 404s; negative-path
test batch
Starlette status-code handlers take precedence over the HTTPException
class handler, so registering the theme index as the 404 handler turned
every HTTPException(404) app-wide into a 200 HTML page (found by the new
negative-path tests). The new not_found_handler serves the theme page
only to browsers (Accept: text/html) and JSON 404 to API clients.
Negative-path batch: download count exhaustion, missing chunk session
404s, expired presign session deletion, admin update_file uniqueness/
existence.
---
apps/base/pages.py | 20 +++++-
main.py | 4 +-
tests/test_negative_edge_paths.py | 116 ++++++++++++++++++++++++++++++
3 files changed, 137 insertions(+), 3 deletions(-)
create mode 100644 tests/test_negative_edge_paths.py
diff --git a/apps/base/pages.py b/apps/base/pages.py
index 5bd174324..09179b414 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 (
@@ -88,6 +88,24 @@ async def theme_asset(asset_path: str):
return FileResponse(resolve_theme_file("assets", asset_path))
+async def not_found_handler(request, exc=None):
+ """区分"浏览器导航到未知路径"与"API 处理器抛出的 404"。
+
+ Starlette 的 status-code handler 优先于 HTTPException 类 handler,
+ 直接注册 index 会让全应用所有 HTTPException(404) 变成 200 HTML 页
+ (负路径测试抓到的存量 bug)。浏览器(Accept 含 text/html)仍拿到
+ 主题首页做 SPA 兜底;API 客户端拿到 JSON 404。
+ """
+ if request is not None and request.method in {"GET", "HEAD"} and "text/html" in (
+ request.headers.get("accept", "")
+ ):
+ return await index(request, exc)
+ return JSONResponse(
+ status_code=404,
+ content={"code": 404, "message": "Not Found", "detail": "资源不存在"},
+ )
+
+
@router.get("/")
async def index(request=None, exc=None):
# Site config is admin input (and during the setup window anyone can claim it);
diff --git a/main.py b/main.py
index 0127b270b..98e5c06b6 100644
--- a/main.py
+++ b/main.py
@@ -20,7 +20,7 @@
refresh_settings,
)
from apps.base.models import KeyValue
-from apps.base.pages import index, router as pages_router
+from apps.base.pages import not_found_handler, router as pages_router
from apps.base.setup_wizard import build_setup_page, is_setup_path, setup_response, wants_html_response
from apps.base.tasks import (
clean_expired_presign_sessions,
@@ -141,7 +141,7 @@ async def refresh_settings_middleware(request, call_next):
app.include_router(pages_router)
# 404 时返回主题首页(index 兼任 exception handler 与 GET / 路由)
-app.add_exception_handler(404, index)
+app.add_exception_handler(404, not_found_handler)
if __name__ == "__main__":
diff --git a/tests/test_negative_edge_paths.py b/tests/test_negative_edge_paths.py
new file mode 100644
index 000000000..b6ba3a334
--- /dev/null
+++ b/tests/test_negative_edge_paths.py
@@ -0,0 +1,116 @@
+"""Negative-path coverage for data-integrity and auth boundaries.
+
+Every test here exercises a failure branch, not a happy path:
+- download with a count-limited share: second download must be refused
+- chunk session cancel/status on a missing upload_id must 404
+- an expired presign session must be deleted server-side on access
+- admin update_file must enforce code uniqueness and existence
+"""
+import datetime
+
+import pytest
+
+from core.utils import get_select_token, get_now
+from tests.conftest import TEST_ADMIN_PASSWORD
+
+
+async def _login(client) -> str:
+ response = await client.post(
+ "/admin/login", json={"password": TEST_ADMIN_PASSWORD}
+ )
+ assert response.status_code == 200, response.text
+ return response.json()["detail"]["token"]
+
+
+@pytest.mark.asyncio
+class TestDownloadCountExhaustion:
+ async def test_second_download_refused_after_limit(self, initialized_client):
+ share = await initialized_client.post(
+ "/share/text",
+ data={"text": "one-shot", "expire_value": "1", "expire_style": "count"},
+ )
+ assert share.status_code == 200
+ code = share.json()["detail"]["code"]
+ token = await get_select_token(code)
+
+ first = await initialized_client.get(
+ "/share/download", params={"key": token, "code": code}
+ )
+ assert first.status_code == 200
+
+ second = await initialized_client.get(
+ "/share/download", params={"key": token, "code": code}
+ )
+ assert second.json()["code"] == 404
+
+
+@pytest.mark.asyncio
+class TestMissingChunkSession:
+ async def test_cancel_missing_session_404(self, initialized_client):
+ response = await initialized_client.delete("/chunk/upload/no-such-upload")
+ assert response.status_code == 404
+
+ async def test_status_missing_session_404(self, initialized_client):
+ response = await initialized_client.get("/chunk/upload/status/no-such-upload")
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestExpiredPresignSession:
+ async def test_expired_session_is_deleted_and_reports_404(self, initialized_client):
+ from apps.base.models import PresignUploadSession
+
+ upload_id = "expiredsession01"
+ await PresignUploadSession.create(
+ upload_id=upload_id,
+ file_name="doc.pdf",
+ file_size=10,
+ save_path="share/data/2026/01/01/x/doc.pdf",
+ mode="proxy",
+ expire_value=1,
+ expire_style="day",
+ expires_at=await get_now() - datetime.timedelta(seconds=1),
+ )
+
+ response = await initialized_client.put(
+ f"/presign/upload/proxy/{upload_id}",
+ files={"file": ("doc.pdf", b"x", "application/pdf")},
+ )
+ assert response.status_code == 404
+ assert await PresignUploadSession.filter(upload_id=upload_id).first() is None
+
+
+@pytest.mark.asyncio
+class TestAdminUpdateFileBoundaries:
+ async def _create_file(self, code: str):
+ from apps.base.models import FileCodes
+
+ await FileCodes.create(code=code, text="x", size=1, prefix="Text")
+
+ async def test_update_missing_file_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.patch(
+ "/admin/file/update",
+ json={"id": 999999, "prefix": "new"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+ async def test_duplicate_code_rejected_400(self, initialized_client):
+ await self._create_file("dupcode1")
+ await self._create_file("dupcode2")
+ token = await _login(initialized_client)
+
+ target = await initialized_client.get(
+ "/admin/file/list",
+ params={"keyword": "dupcode2"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ file_id = target.json()["detail"]["data"][0]["id"]
+
+ response = await initialized_client.patch(
+ "/admin/file/update",
+ json={"id": file_id, "code": "dupcode1"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
From b551b6a3b87d30c2b5e0d2fb33118201abecf663 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 06:02:57 +0800
Subject: [PATCH 05/31] fix: run container as non-root app user with compat
volume chown (security item 3)
Entrypoint starts as root only to fix the data-dir ownership (skipped
when nothing needs changing or when the deployer sets an explicit user),
then drops to uid 10001 via gosu. Verified in-container: uvicorn runs as
app (uid 10001), setup and share round-trip work against a root-owned
volume.
---
Dockerfile | 10 +++++++++-
docker-entrypoint.sh | 20 ++++++++++++++++++++
2 files changed, 29 insertions(+), 1 deletion(-)
create mode 100755 docker-entrypoint.sh
diff --git a/Dockerfile b/Dockerfile
index eedf04f97..382bb00ab 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,13 +59,17 @@ COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
# 安装系统安全更新 + Python 依赖
# 依赖从带哈希的锁定文件安装(--require-hashes),保证构建可复现、防供应链篡改。
-# 清理 apt 缓存,降低镜像噪音与扫描面
+# gosu 用于入口脚本的数据卷属主修正后降权;清理 apt 缓存,降低镜像噪音与扫描面
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
+ && apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --require-hashes -r requirements.lock.txt \
&& pip cache purge || true
+# 非 root 运行用户;数据卷属主由 docker-entrypoint.sh 按需修正(兼容存量 root 卷)
+RUN useradd --system --uid 10001 --home-dir /app app
+
# 环境变量配置
ENV HOST="0.0.0.0" \
PORT=12345 \
@@ -77,6 +81,10 @@ ENV HOST="0.0.0.0" \
EXPOSE 12345
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
# 生产环境启动命令
# FORWARDED_ALLOW_IPS 默认为空:仅信任直连 IP,避免任意客户端伪造 X-Forwarded-*。
# 若前面有反向代理,请显式设置为代理网段,例如 "10.0.0.0/8,172.16.0.0/12"。
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 000000000..3c1cd2873
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# FileCodeBox 容器入口:兼容式非 root 降权。
+#
+# 默认以 root 进入(兼容存量 root 属主的数据卷),按需修正 data 目录属主后
+# 用 gosu 降权到 app 用户运行;若部署方显式指定了 user(非 root),则直接运行
+# 不做任何 chown,保证两种形态都可预期。
+set -e
+
+DATA_DIR="${DATA_DIR:-/app/data}"
+
+if [ "$(id -u)" = "0" ]; then
+ mkdir -p "$DATA_DIR"
+ # 仅当存在非 app 属主的文件时才 chown,避免大卷启动变慢
+ if [ -n "$(find "$DATA_DIR" ! -uid "$(id -u app)" -print -quit 2>/dev/null)" ]; then
+ chown -R app:app "$DATA_DIR"
+ fi
+ exec gosu app "$@"
+fi
+
+exec "$@"
From db1a39b3a354ce2d3786e15d72421a66e563119c Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Fri, 11 Sep 2026 06:13:11 +0800
Subject: [PATCH 06/31] fix: compare download tokens as bytes (non-ASCII key
caused 500)
---
apps/base/views.py | 5 ++++-
tests/test_download_token_boundary.py | 16 ++++++++++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/apps/base/views.py b/apps/base/views.py
index c9ff45268..48269bbd3 100644
--- a/apps/base/views.py
+++ b/apps/base/views.py
@@ -250,7 +250,10 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
await get_select_token(normalized_code, offset=0),
await get_select_token(normalized_code, offset=1),
]
- if not any(hmac.compare_digest(key, candidate) for candidate in valid_keys):
+ if not any(
+ hmac.compare_digest(key.encode(), candidate.encode())
+ for candidate in valid_keys
+ ):
ip_limit["error"].add_ip(ip)
raise HTTPException(status_code=403, detail="下载鉴权失败")
has, file_code = await get_code_file_by_code(normalized_code)
diff --git a/tests/test_download_token_boundary.py b/tests/test_download_token_boundary.py
index 7f11cfe68..07a4c4b98 100644
--- a/tests/test_download_token_boundary.py
+++ b/tests/test_download_token_boundary.py
@@ -49,3 +49,19 @@ async def test_token_of_other_code_is_rejected(self, initialized_client):
"/share/download", params={"key": foreign_token, "code": code}
)
assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+class TestDownloadTokenMalformedInput:
+ async def test_non_ascii_key_rejected_not_500(self, initialized_client):
+ """compare_digest(str) 会在非 ASCII 输入时抛 TypeError——必须以字节比较。"""
+ share = await initialized_client.post(
+ "/share/text", data={"text": "token fixture", "expire_style": "day"}
+ )
+ assert share.status_code == 200
+ code = share.json()["detail"]["code"]
+
+ response = await initialized_client.get(
+ "/share/download", params={"key": "密钥" * 32, "code": code}
+ )
+ assert response.status_code == 403
From c1f0abc1b0e2fe489bef73c8b0f639290c2ca631 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 01:02:13 +0800
Subject: [PATCH 07/31] test: lock changed-only semantics for endpoint config
saves
Integration regression for the background lesson: a stored internal
endpoint must not block unrelated settings saves in production mode;
changing to an internal endpoint (URL or bare-hostname form) is 400.
---
tests/test_outbound_endpoint_validation.py | 70 ++++++++++++++++++++++
1 file changed, 70 insertions(+)
diff --git a/tests/test_outbound_endpoint_validation.py b/tests/test_outbound_endpoint_validation.py
index a73b9a1c0..5397a524c 100644
--- a/tests/test_outbound_endpoint_validation.py
+++ b/tests/test_outbound_endpoint_validation.py
@@ -88,3 +88,73 @@ def test_hostname_tier_rejects_url_forms(production_env):
validate_outbound_hostname("https://s3.amazonaws.com")
with pytest.raises(ValueError):
validate_outbound_hostname("http://127.0.0.1:9000")
+
+
+@pytest.mark.asyncio
+class TestChangedOnlyEnforcement:
+ """changed-only 集成回归:存量内网 endpoint 不得挡死无关设置保存。
+
+ background 校验曾因校验合并后配置而挡死存量用户(上游 #528 修复),
+ endpoint 校验沿用同一语义——这里用集成层锁死该行为。
+ """
+
+ async def _login(self, client) -> str:
+ from tests.conftest import TEST_ADMIN_PASSWORD
+
+ response = await client.post(
+ "/admin/login", json={"password": TEST_ADMIN_PASSWORD}
+ )
+ assert response.status_code == 200, response.text
+ return response.json()["detail"]["token"]
+
+ async def test_unchanged_internal_endpoint_passes_and_unrelated_save_ok(
+ self, initialized_client, monkeypatch
+ ):
+ monkeypatch.setenv("APP_ENV", "production")
+ token = await self._login(initialized_client)
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # 存量场景:先在 production 放行前写入了内网 endpoint(模拟旧数据),
+ # 直接落库;此后 production 下保存同一值 + 无关字段都必须成功。
+ from apps.base.models import KeyValue
+ from core.settings import settings
+
+ record = await KeyValue.filter(key="settings").first()
+ config = dict(record.value or {})
+ config["s3_endpoint_url"] = "http://192.168.1.10:9000"
+ record.value = config
+ await record.save()
+ settings.user_config = config
+
+ response = await initialized_client.patch(
+ "/admin/config/update",
+ json={"s3_endpoint_url": "http://192.168.1.10:9000", "name": "renamed"},
+ headers=headers,
+ )
+ assert response.status_code == 200, response.text
+
+ async def test_changed_to_internal_endpoint_is_rejected(
+ self, initialized_client, monkeypatch
+ ):
+ monkeypatch.setenv("APP_ENV", "production")
+ token = await self._login(initialized_client)
+
+ response = await initialized_client.patch(
+ "/admin/config/update",
+ json={"s3_endpoint_url": "http://127.0.0.1:9000"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_hostname_tier_blocks_internal_target(
+ self, initialized_client, monkeypatch
+ ):
+ monkeypatch.setenv("APP_ENV", "production")
+ token = await self._login(initialized_client)
+
+ response = await initialized_client.patch(
+ "/admin/config/update",
+ json={"s3_hostname": "minio.internal"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
From 3addb41453ed8773eb64362ece9839771b4f62af Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 01:52:11 +0800
Subject: [PATCH 08/31] =?UTF-8?q?fix:=20resolve-based=20SSRF=20check=20?=
=?UTF-8?q?=E2=80=94=20deny=20IP=20shorthands,=20hex/decimal=20IPs,=20DNS?=
=?UTF-8?q?=20maps?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Probe found the static blacklist bypassed by glibc shorthands (127.1,
10.1), decimal (2130706433) and hex IP forms, *.localhost, and
loopback-mapping DNS services. Both validators now resolve the host and
re-check every resolved address; hostname tier extracts the host part
correctly for [v6]/host:port/bare-v6 forms. 44 tests.
---
core/security.py | 49 ++++++++++++++++++----
tests/test_outbound_endpoint_validation.py | 41 ++++++++++++++++++
2 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/core/security.py b/core/security.py
index 03c892bcc..d76f0c4c7 100644
--- a/core/security.py
+++ b/core/security.py
@@ -2,6 +2,7 @@
import ipaddress
import os
import secrets
+import socket
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
@@ -79,15 +80,19 @@ def prepare_security_config(config: dict[str, Any]) -> SecurityConfigResult:
OUTBOUND_ENDPOINT_CONFIG_KEYS = ("s3_endpoint_url", "s3_hostname", "webdav_url")
_ENDPOINT_SCHEME_WHITELIST = {"http", "https"}
-_ENDPOINT_HOST_DENY_SUFFIXES = (".local", ".internal", ".lan", ".home.arpa", ".corp")
+_ENDPOINT_HOST_DENY_SUFFIXES = (
+ ".local",
+ ".internal",
+ ".lan",
+ ".home.arpa",
+ ".corp",
+ ".localhost",
+)
-def _endpoint_host_is_denied(host: str) -> bool:
- host = host.strip("[]").lower()
- if host in {"localhost"} or host.endswith(_ENDPOINT_HOST_DENY_SUFFIXES):
- return True
+def _endpoint_ip_is_denied(ip_str: str) -> bool:
try:
- ip = ipaddress.ip_address(host)
+ ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return (
@@ -96,9 +101,29 @@ def _endpoint_host_is_denied(host: str) -> bool:
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
+ or ip.is_unspecified
)
+def _endpoint_host_is_denied(host: str) -> bool:
+ host = host.strip("[]").lower()
+ if host in {"localhost"} or host.endswith(_ENDPOINT_HOST_DENY_SUFFIXES):
+ return True
+ # 字面 IP 直接判;非字面形态(速记 "127.1"、十进制 2130706433、十六进制、
+ # *.localhost、nip.io 类 DNS 映射)交给解析后逐 IP 复判——静态字符串
+ # 黑名单对它们全部失效,这是探测中实测确认的绕过面。
+ if _endpoint_ip_is_denied(host):
+ return True
+ try:
+ infos = socket.getaddrinfo(host, None)
+ except (socket.gaierror, UnicodeError, OSError):
+ return False
+ for info in infos:
+ if _endpoint_ip_is_denied(info[4][0]):
+ return True
+ return False
+
+
def validate_outbound_endpoint(value: Any) -> str:
"""校验完整 URL 形态的出站端点(s3_endpoint_url / webdav_url)。"""
endpoint = str(value or "").strip()
@@ -130,6 +155,16 @@ def validate_outbound_hostname(value: Any) -> str:
if "://" in hostname or "/" in hostname:
raise ValueError(f"s3_hostname 应为裸主机名,不含协议或路径:{hostname}")
- if _endpoint_host_is_denied(hostname.split(":")[0]):
+ # 取主机部分:[v6] 形态取括号内;host:port 取冒号前;裸 IPv6(多个冒号)整体
+ if "[" in hostname:
+ host = hostname.split("[", 1)[1].split("]", 1)[0]
+ rest = hostname.split("]", 1)[1] if "]" in hostname else ""
+ if rest and not rest.startswith(":"):
+ raise ValueError(f"s3_hostname 的 IPv6 括号后只允许端口:{hostname}")
+ elif hostname.count(":") == 1:
+ host = hostname.split(":")[0]
+ else:
+ host = hostname
+ if not host or _endpoint_host_is_denied(host):
raise ValueError(f"端点不允许指向内网/保留地址:{hostname}")
return hostname
diff --git a/tests/test_outbound_endpoint_validation.py b/tests/test_outbound_endpoint_validation.py
index 5397a524c..f403f9e44 100644
--- a/tests/test_outbound_endpoint_validation.py
+++ b/tests/test_outbound_endpoint_validation.py
@@ -158,3 +158,44 @@ async def test_hostname_tier_blocks_internal_target(
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 400
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "https://127.1", # glibc 速记 = 127.0.0.1
+ "https://2130706433", # 十进制整数 IP = 127.0.0.1
+ "https://0x7f.0x0.0x0.0x1", # 十六进制 IP
+ "https://10.1", # 速记 = 10.0.0.1
+ "https://127.0.0.1.nip.io", # DNS 映射到 loopback
+ "https://foo.localhost", # *.localhost 现代解析器指向 loopback
+ "https://[::1]",
+ "https://0.0.0.0",
+ ],
+)
+def test_url_tier_denies_ip_shorthand_and_dns_tricks(production_env, value):
+ """静态字符串黑名单对 IP 速记/十六进制/十进制/DNS 映射全部失效——必须解析后复判。"""
+ with pytest.raises(ValueError):
+ validate_outbound_endpoint(value)
+
+
+@pytest.mark.parametrize(
+ "hostname",
+ [
+ "127.1",
+ "2130706433",
+ "10.1",
+ "foo.localhost",
+ "127.0.0.1.nip.io",
+ "[::1]",
+ "[::ffff:127.0.0.1]:9000",
+ ],
+)
+def test_hostname_tier_denies_ip_shorthand_and_dns_tricks(production_env, hostname):
+ with pytest.raises(ValueError):
+ validate_outbound_hostname(hostname)
+
+
+def test_hostname_tier_accepts_ipv6_with_port_and_public(production_env):
+ assert validate_outbound_hostname("[2606:4700::1]:9000") == "[2606:4700::1]:9000"
+ assert validate_outbound_hostname("files.example.com:9000") == "files.example.com:9000"
From 21820916bfc588dca984093b98cdedd1e24bc732 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 01:55:29 +0800
Subject: [PATCH 09/31] test: pin 404 handler dual-branch behavior (browser
theme page vs API JSON)
---
tests/test_negative_edge_paths.py | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/tests/test_negative_edge_paths.py b/tests/test_negative_edge_paths.py
index b6ba3a334..be8f00029 100644
--- a/tests/test_negative_edge_paths.py
+++ b/tests/test_negative_edge_paths.py
@@ -114,3 +114,27 @@ async def test_duplicate_code_rejected_400(self, initialized_client):
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+class TestNotFoundHandlerBranches:
+ """404 双分支边界:浏览器导航拿主题页(SPA 兜底保留),API 客户端拿 JSON 404。"""
+
+ async def test_api_accept_gets_json_404(self, initialized_client):
+ response = await initialized_client.get(
+ "/no-such-path", headers={"Accept": "application/json"}
+ )
+ assert response.status_code == 404
+ assert response.json()["code"] == 404
+ assert "text/html" not in response.headers["content-type"]
+
+ async def test_browser_accept_gets_theme_page(self, initialized_client):
+ response = await initialized_client.get("/no-such-path", headers={"Accept": "text/html"})
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+ async def test_default_star_accept_gets_json_404(self, initialized_client):
+ """curl 默认 */* 不含 text/html——必须走 JSON 分支(防误伤脚本调用方)。"""
+ response = await initialized_client.get("/no-such-path")
+ assert response.status_code == 404
+ assert response.json()["code"] == 404
From bc51a783847c64c6fd02778b3b9ca7237eaa7846 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 02:31:27 +0800
Subject: [PATCH 10/31] fix: write back normalized endpoint values; hostname
dev-gate test
---
apps/admin/services.py | 4 ++-
tests/test_outbound_endpoint_validation.py | 33 ++++++++++++++++++++++
2 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/apps/admin/services.py b/apps/admin/services.py
index d99eb6bec..6a8f9bcaf 100644
--- a/apps/admin/services.py
+++ b/apps/admin/services.py
@@ -1594,7 +1594,9 @@ async def update_config(self, data: dict):
else validate_outbound_endpoint
)
try:
- validator(candidate)
+ # 写回规范化值(validator 去除首尾空白):校验通过但入库脏值
+ # 会让存储层在连接期才报错,应在校验点归一。
+ next_config[endpoint_key] = validator(candidate)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
diff --git a/tests/test_outbound_endpoint_validation.py b/tests/test_outbound_endpoint_validation.py
index f403f9e44..505022c01 100644
--- a/tests/test_outbound_endpoint_validation.py
+++ b/tests/test_outbound_endpoint_validation.py
@@ -199,3 +199,36 @@ def test_hostname_tier_denies_ip_shorthand_and_dns_tricks(production_env, hostna
def test_hostname_tier_accepts_ipv6_with_port_and_public(production_env):
assert validate_outbound_hostname("[2606:4700::1]:9000") == "[2606:4700::1]:9000"
assert validate_outbound_hostname("files.example.com:9000") == "files.example.com:9000"
+
+
+@pytest.mark.asyncio
+class TestNormalizedWriteBack:
+ async def test_whitespace_padded_endpoint_stored_stripped(
+ self, initialized_client, monkeypatch
+ ):
+ monkeypatch.setenv("APP_ENV", "production")
+ from tests.conftest import TEST_ADMIN_PASSWORD
+
+ login = await initialized_client.post(
+ "/admin/login", json={"password": TEST_ADMIN_PASSWORD}
+ )
+ token = login.json()["detail"]["token"]
+
+ response = await initialized_client.patch(
+ "/admin/config/update",
+ json={"s3_endpoint_url": " https://s3.example.com "},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ from apps.base.models import KeyValue
+
+ record = await KeyValue.filter(key="settings").first()
+ config = dict(record.value or {})
+ assert config["s3_endpoint_url"] == "https://s3.example.com"
+
+
+def test_hostname_tier_development_gate(monkeypatch):
+ """hostname 档同样受 APP_ENV 门控(开发环境本地 minio 不被拒)。"""
+ monkeypatch.setenv("APP_ENV", "development")
+ assert validate_outbound_hostname("127.0.0.1:9000") == "127.0.0.1:9000"
From c4b5b26cda1316bdf5a24fde7d01f0a13ab1866f Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 02:32:23 +0800
Subject: [PATCH 11/31] test: header-injection negative paths for attachment
disposition
---
tests/test_attachment_guard.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/tests/test_attachment_guard.py b/tests/test_attachment_guard.py
index 517e57916..485c5e5ea 100644
--- a/tests/test_attachment_guard.py
+++ b/tests/test_attachment_guard.py
@@ -7,6 +7,8 @@
fails here.
"""
import ast
+
+import pytest
from pathlib import Path
from core.storage import build_attachment_headers
@@ -57,3 +59,28 @@ def test_no_hand_built_disposition_outside_helper():
"a hand-built Content-Disposition header reappeared outside "
"build_attachment_headers"
)
+
+
+@pytest.mark.parametrize(
+ "filename",
+ [
+ 'x".html', # 引号试图逃出 filename* 引号上下文
+ "x\r\nSet-Cookie: pwned", # CRLF 头注入
+ "x\nX-Injected: 1",
+ "x%.html", # 百分号必须是编码结果而非原文(二次解码面)
+ "x\\y.html", # 反斜杠
+ "文件 名(1).html", # 空格/括号/多字节——必须整体 percent-encode
+ ],
+)
+def test_helper_never_emits_raw_dangerous_bytes(filename):
+ """header 注入负路径:filename 来自 admin 可控字段,任何危险字节必须是
+ percent-encoding 的结果而非原文出现在响应头里。"""
+ headers = build_attachment_headers(filename)
+ disposition = headers["Content-Disposition"]
+ for raw in ("\r", "\n", '"'):
+ assert raw not in disposition, f"raw {raw!r} leaked into header"
+ # percent-encode 后再解码必须还原文件名(有损=下载文件名损坏)
+ from urllib.parse import unquote
+
+ encoded = disposition.split("''", 1)[1]
+ assert unquote(encoded) == filename
From 258a19efa396a0c8cc83b474d28bf58f5df6dded Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 02:59:57 +0800
Subject: [PATCH 12/31] fix: S3 merge buffers parts to >=5MiB; missing object
404 upfront
- S3 multipart rejects parts <5MiB (EntityTooSmall) except the last; chunk
size is client-controlled (commonly 2-4MB) so multi-chunk merges always
failed with 500. Merge now buffers chunks into >=5MiB parts (memory
bound: 5MB + one chunk).
- get_file_response: a 404 head_object now raises StorageError(404)
upfront instead of signing a doomed presigned URL and failing mid-stream
(aligns with local backend semantics from the M2 behavior unification).
---
core/storage.py | 65 +++++++++++++++++++++++++++++++++++--------------
1 file changed, 47 insertions(+), 18 deletions(-)
diff --git a/core/storage.py b/core/storage.py
index 17f99c7d7..e1112bd8c 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -3,6 +3,7 @@
# @File : storage.py
# @Software: PyCharm
import base64
+from botocore.exceptions import ClientError
import hashlib
import os
import tempfile
@@ -66,6 +67,11 @@ def get_file_path(self) -> str:
+
+# S3 multipart 除最后一片外每部分最小 5MB(服务端强制,小于即 EntityTooSmall)
+S3_MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024
+
+
def build_attachment_headers(filename: str, content_length=None) -> dict:
"""所有存储后端统一的下载响应头。
@@ -412,9 +418,10 @@ async def get_file_response(self, file_code: StoredFile):
try:
filename = file_code.prefix + file_code.suffix
content_length = None # 初始化为 None,表示未知大小
-
+
async with self._client() as s3:
- # 尝试获取文件大小(HEAD请求)
+ # 尝试获取文件大小(HEAD请求);对象不存在时前置 404——
+ # 与 local 后端语义一致(M2 行为统一),不能签出 200 的坏流
try:
head_response = await s3.head_object(
Bucket=self.bucket_name,
@@ -425,6 +432,13 @@ async def get_file_response(self, file_code: StoredFile):
content_length = head_response['ContentLength']
elif 'Content-Length' in head_response['ResponseMetadata']['HTTPHeaders']:
content_length = int(head_response['ResponseMetadata']['HTTPHeaders']['Content-Length'])
+ except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code", "")
+ if error_code in {"404", "NoSuchKey", "NotFound"}:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ ) from e
+ # 其他 HEAD 错误不阻断:流式下载阶段会给出真实状态
except Exception:
# 如果HEAD请求失败,则不提供 Content-Length
pass
@@ -526,7 +540,31 @@ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int,
parts = []
try:
- # 按顺序读取、验证并上传每个分片
+ # 按顺序读取、验证每个分片;S3 multipart 规范要求除最后一片外
+ # 每个部分 ≥5MB(EntityTooSmall),而分片大小由客户端决定(常见
+ # 2-4MB)——因此缓冲到 S3_MIN_MULTIPART_PART_SIZE 再上传 part,
+ # 内存上界 = 5MB + 单个分片,不破坏流式合并的初衷。
+ part_buffer = bytearray()
+ part_number = 0
+
+ async def _flush_part():
+ nonlocal part_number
+ if not part_buffer:
+ return
+ part_number += 1
+ part_response = await s3.upload_part(
+ Bucket=self.bucket_name,
+ Key=save_path,
+ UploadId=mpu_id,
+ PartNumber=part_number,
+ Body=bytes(part_buffer),
+ )
+ parts.append({
+ 'PartNumber': part_number,
+ 'ETag': part_response['ETag']
+ })
+ part_buffer.clear()
+
for i in range(total_chunks):
chunk_key = f"{chunk_dir}/{i}.part"
chunk_record = self._get_chunk_record(chunk_records, i)
@@ -541,22 +579,13 @@ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int,
raise ValueError(f"分片{i}文件不存在: {e}")
self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ part_buffer.extend(chunk_data)
+ if len(part_buffer) >= S3_MIN_MULTIPART_PART_SIZE:
+ await _flush_part()
- # 上传分片到 multipart upload
- part_response = await s3.upload_part(
- Bucket=self.bucket_name,
- Key=save_path,
- UploadId=mpu_id,
- PartNumber=i + 1, # S3 part numbers start at 1
- Body=chunk_data
- )
- parts.append({
- 'PartNumber': i + 1,
- 'ETag': part_response['ETag']
- })
-
- # 释放内存
- del chunk_data
+ # 收尾:剩余缓冲作为最后一个 part(S3 允许最后一片小于 5MB;
+ # 恰好整除时缓冲为空,跳过)
+ await _flush_part()
# 完成 multipart upload
await s3.complete_multipart_upload(
From 3dafe2146e18ef7f42adc650d63da8ca2d4e3e92 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 03:00:09 +0800
Subject: [PATCH 13/31] test: S3 backend coverage via in-process moto server
(13 cases)
mock_aws cannot intercept aioboto3's aiohttp stack, so tests run against
a real in-process ThreadedMotoServer endpoint. Covers roundtrips, missing
object 404, merge failure modes (missing chunk / hash mismatch -> abort,
no leftover object), cleanup scoping, presign shapes, proxy dispatch.
Also adds moto[s3,server] to the dev group and CI.
---
.github/workflows/ci.yml | 2 +-
pyproject.toml | 1 +
tests/test_s3_storage_moto.py | 201 ++++++++++++++++++++++++++++++++++
3 files changed, 203 insertions(+), 1 deletion(-)
create mode 100644 tests/test_s3_storage_moto.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 81b3cbd3a..0c64f1f18 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
# --require-hashes so CI fails on lockfile drift, exactly like the
# Docker build does.
pip install --require-hashes -r requirements.lock.txt
- pip install pytest pytest-asyncio httpx
+ pip install pytest pytest-asyncio httpx 'moto[s3,server]'
- name: Ruff
run: pipx run ruff==0.16.6 check .
diff --git a/pyproject.toml b/pyproject.toml
index e1e48de65..e6d317eef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dev = [
"httpx",
"ruff",
"pre-commit",
+ "moto[s3,server]",
]
[tool.ruff]
diff --git a/tests/test_s3_storage_moto.py b/tests/test_s3_storage_moto.py
new file mode 100644
index 000000000..863b64756
--- /dev/null
+++ b/tests/test_s3_storage_moto.py
@@ -0,0 +1,201 @@
+"""S3 storage backend coverage via moto (in-memory S3).
+
+The largest remaining test blind spot (the whole backend had only stub-level
+tests). Focus on failure branches: missing objects, chunk merge failures,
+cleanup scoping, presign behavior. Exercises the real settings → S3FileStorage
+config surface.
+"""
+import hashlib
+import io
+from types import SimpleNamespace
+
+import pytest
+from core.errors import StorageError
+from core.storage import S3FileStorage, StoredFile
+
+S3_SETTINGS = {
+ "file_storage": "s3",
+ "s3_bucket_name": "drill-bucket",
+ "s3_access_key_id": "testing",
+ "s3_secret_access_key": "testing",
+ "s3_hostname": "minio.local",
+ "s3_region_name": "us-east-1",
+ "s3_endpoint_url": "",
+ "jwt_secret": "drill" * 12,
+ "s3_signature_version": "s3v4",
+}
+
+
+@pytest.fixture
+async def s3_storage():
+ """Real in-process moto HTTP server (mock_aws cannot intercept aioboto3's
+ aiohttp stack) + S3 config override + bucket creation."""
+ from core.settings import settings
+ from moto.server import ThreadedMotoServer
+
+ server = ThreadedMotoServer("127.0.0.1", 0)
+ server.start()
+ endpoint = f"http://127.0.0.1:{server._server.server_port}"
+ original = dict(settings.user_config)
+ settings.user_config = {**original, **S3_SETTINGS, "s3_endpoint_url": endpoint}
+ try:
+ session = __import__("aioboto3").Session(
+ aws_access_key_id="testing", aws_secret_access_key="testing"
+ )
+ async with session.client("s3", endpoint_url=endpoint, region_name="us-east-1") as s3:
+ await s3.create_bucket(Bucket="drill-bucket")
+ yield S3FileStorage()
+ finally:
+ settings.user_config = original
+ server.stop()
+
+
+def _stored(key: str) -> StoredFile:
+ return StoredFile(
+ file_path=key.rsplit("/", 1)[0], uuid_file_name=key.rsplit("/", 1)[1]
+ )
+
+
+def _records(chunks: dict[int, bytes]) -> dict:
+ return {
+ i: SimpleNamespace(chunk_hash=hashlib.sha256(d).hexdigest())
+ for i, d in chunks.items()
+ }
+
+
+async def _seed_chunks(s3_storage, upload_id, save_path, chunks: dict[int, bytes]):
+ for index, data in chunks.items():
+ await s3_storage.save_chunk(upload_id, index, data, "ignored", save_path)
+
+
+@pytest.mark.asyncio
+class TestS3ObjectBasics:
+ async def test_save_and_exists_roundtrip(self, s3_storage):
+ await s3_storage.save_file(
+ io.BytesIO(b"payload"), "share/data/f.bin", "application/octet-stream"
+ )
+ assert await s3_storage.file_exists("share/data/f.bin") is True
+ assert await s3_storage.file_exists("share/data/missing.bin") is False
+
+ async def test_delete_file_removes_object(self, s3_storage):
+ await s3_storage.save_file(io.BytesIO(b"payload"), "share/data/f.bin")
+ await s3_storage.delete_file(_stored("share/data/f.bin"))
+ assert await s3_storage.file_exists("share/data/f.bin") is False
+
+ async def test_presigned_upload_url_targets_bucket_and_key(self, s3_storage):
+ url = await s3_storage.generate_presigned_upload_url("share/data/f.bin", 900)
+ assert "drill-bucket" in url
+ assert "f.bin" in url
+ assert "X-Amz-Signature" in url
+
+ async def test_get_file_url_proxy_uses_local_dispatch(self, s3_storage):
+ """proxy 模式必须走本地 /share/download 分发(取件计数/前置 404 生效)。"""
+ from core.settings import settings
+
+ settings.user_config = {**settings.user_config, "s3_proxy": 1}
+ proxied_storage = S3FileStorage()
+ file_code = StoredFile(
+ file_path="share/data", uuid_file_name="f.bin", code="abcd1"
+ )
+ proxied = await proxied_storage.get_file_url(file_code)
+ assert proxied.startswith("/share/download?")
+
+ async def test_get_file_url_direct_returns_presigned(self, s3_storage):
+ file_code = StoredFile(
+ file_path="share/data", uuid_file_name="f.bin", code="abcd1"
+ )
+ direct = await s3_storage.get_file_url(file_code)
+ assert direct.startswith("http") and "drill-bucket" in direct
+
+
+@pytest.mark.asyncio
+class TestS3GetFileResponse:
+ async def test_missing_object_raises_404_upfront(self, s3_storage):
+ """缺失对象必须前置 404,而不是签发 200 的坏流(与 local 后端语义对齐)。"""
+ with pytest.raises(StorageError) as exc_info:
+ await s3_storage.get_file_response(_stored("share/data/ghost.bin"))
+ assert exc_info.value.status_code == 404
+
+ async def test_existing_object_returns_download_with_length(self, s3_storage):
+ await s3_storage.save_file(io.BytesIO(b"payload"), "share/data/f.bin")
+ download = await s3_storage.get_file_response(_stored("share/data/f.bin"))
+ assert download.headers["Content-Length"] == "7"
+ assert download.headers["Content-Disposition"].startswith("attachment;")
+
+ async def test_stream_factory_roundtrip_via_presigned_url(self, s3_storage):
+ """流式生成器经 presigned URL 真实往返 moto 服务,内容一致。"""
+ payload = b"stream-roundtrip-payload"
+ await s3_storage.save_file(io.BytesIO(payload), "share/data/s.bin")
+ download = await s3_storage.get_file_response(_stored("share/data/s.bin"))
+ chunks = [chunk async for chunk in download.stream_factory()]
+ assert b"".join(chunks) == payload
+
+
+@pytest.mark.asyncio
+class TestS3ChunkMerge:
+ async def test_merge_success_roundtrip_then_cleanup(self, s3_storage):
+ upload_id = "up01"
+ save_path = "share/data/2026/01/01/up01/merged.bin"
+ parts = {0: b"AAAA", 1: b"BBBB"}
+ await _seed_chunks(s3_storage, upload_id, save_path, parts)
+ _, file_hash = await s3_storage.merge_chunks(
+ upload_id, 2, 4, save_path, _records(parts)
+ )
+ assert file_hash == hashlib.sha256(b"AAAABBBB").hexdigest()
+
+ async with s3_storage._client() as s3:
+ resp = await s3.get_object(Bucket="drill-bucket", Key=save_path)
+ merged = await resp["Body"].read()
+ assert merged == b"AAAABBBB"
+
+ await s3_storage.clean_chunks(upload_id, save_path)
+ assert (
+ await s3_storage.file_exists(
+ f"share/data/2026/01/01/up01/chunks/{upload_id}/0.part"
+ )
+ is False
+ )
+
+ async def test_merge_missing_chunk_aborts_and_raises(self, s3_storage):
+ upload_id = "up02"
+ save_path = "share/data/2026/01/01/up02/merged.bin"
+ await _seed_chunks(s3_storage, upload_id, save_path, {1: b"BBBB"}) # 缺分片 0
+ with pytest.raises(ValueError, match="分片0"):
+ await s3_storage.merge_chunks(
+ upload_id, 2, 4, save_path, _records({0: b"AAAA", 1: b"BBBB"})
+ )
+ # abort 后不得留下半成品对象
+ assert await s3_storage.file_exists(save_path) is False
+
+ async def test_merge_hash_mismatch_aborts_and_raises(self, s3_storage):
+ upload_id = "up03"
+ save_path = "share/data/2026/01/01/up03/merged.bin"
+ await _seed_chunks(s3_storage, upload_id, save_path, {0: b"AAAA"})
+ bad_records = {0: SimpleNamespace(chunk_hash="f" * 64)} # 与实际不符
+ with pytest.raises(ValueError, match="哈希不匹配"):
+ await s3_storage.merge_chunks(upload_id, 1, 4, save_path, bad_records)
+ assert await s3_storage.file_exists(save_path) is False
+
+ async def test_clean_chunks_scoped_to_upload_only(self, s3_storage):
+ # 注意 chunk 目录派生自 save_path 的父目录——两个 upload 必须各自 save_path
+ save_a = "share/data/2026/01/01/upa/merged.bin"
+ save_b = "share/data/2026/01/01/upb/merged.bin"
+ await _seed_chunks(s3_storage, "upa", save_a, {0: b"AA"})
+ await _seed_chunks(s3_storage, "upb", save_b, {0: b"BB"})
+ await s3_storage.clean_chunks("upa", save_a)
+ assert (
+ await s3_storage.file_exists("share/data/2026/01/01/upa/chunks/upa/0.part")
+ is False
+ )
+ assert (
+ await s3_storage.file_exists("share/data/2026/01/01/upb/chunks/upb/0.part")
+ is True
+ )
+
+ async def test_save_chunk_stores_declared_hash_metadata(self, s3_storage):
+ await s3_storage.save_chunk("upm", 0, b"DATA", "cafe" * 16, "share/data/x/m.bin")
+ async with s3_storage._client() as s3:
+ head = await s3.head_object(
+ Bucket="drill-bucket", Key="share/data/x/chunks/upm/0.part"
+ )
+ assert head["Metadata"]["chunk-hash"] == "cafe" * 16
From dc56f27d4668e4a7f720bb2e98a6ba4f7a81525d Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 03:23:36 +0800
Subject: [PATCH 14/31] fix: WebDAV missing object 404 upfront; connection
errors map to 503
HEAD 404 was silently swallowed (signed a 200 bad stream that failed
mid-download, same family as the S3 bug), and HEAD connection errors were
also swallowed so the 503 mapping never fired. Both now surface properly;
the aiohttp session is reclaimed on pre-stream exception paths.
---
core/storage.py | 33 +++++++++++++++++++++++++--------
1 file changed, 25 insertions(+), 8 deletions(-)
diff --git a/core/storage.py b/core/storage.py
index e1112bd8c..cc31e2233 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -1266,15 +1266,32 @@ async def get_file_response(self, file_code: StoredFile):
"Authorization": f"Basic {base64.b64encode(f'{settings.webdav_username}:{settings.webdav_password}'.encode()).decode()}"
})
- # 尝试发送HEAD请求获取Content-Length
+ # 尝试发送HEAD请求获取Content-Length;对象不存在时前置 404
+ # (与 local/S3 语义对齐),连接层错误映射 503——两者都不能
+ # 静默吞掉后签出 200 坏流。异常路径回收 session 防泄漏。
try:
- async with session.head(url) as resp:
- if resp.status == 200 and 'Content-Length' in resp.headers:
- content_length = int(resp.headers['Content-Length'])
- except Exception:
- # 如果HEAD请求失败,则不提供 Content-Length
- pass
-
+ try:
+ async with session.head(url) as resp:
+ if resp.status == 404:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ )
+ if resp.status == 200 and 'Content-Length' in resp.headers:
+ content_length = int(resp.headers['Content-Length'])
+ except StorageError:
+ raise
+ except aiohttp.ClientError as e:
+ raise StorageError(
+ status_code=503, detail=f"WebDAV连接异常: {str(e)}"
+ ) from e
+ except Exception:
+ # 其他 HEAD 异常不阻断:流式下载阶段会给出真实状态
+ pass
+
+ except BaseException:
+ await session.close()
+ raise
+
async def stream_generator():
try:
async with session.get(url) as resp:
From 65b3575ffb05e30fc9c3cec622c655c3407e743b Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 03:23:36 +0800
Subject: [PATCH 15/31] test: WebDAV backend coverage via in-process WebDAV
server (9 cases)
Real HTTP (HEAD/GET/PUT/DELETE/MKCOL/PROPFIND) against a minimal aiohttp
WebDAV server over a temp dir: roundtrips, missing object 404, connection
error 503, delete with empty-parent cleanup, merge failure modes, chunk
cleanup scoping.
---
tests/test_webdav_storage.py | 234 +++++++++++++++++++++++++++++++++++
1 file changed, 234 insertions(+)
create mode 100644 tests/test_webdav_storage.py
diff --git a/tests/test_webdav_storage.py b/tests/test_webdav_storage.py
new file mode 100644
index 000000000..56915218a
--- /dev/null
+++ b/tests/test_webdav_storage.py
@@ -0,0 +1,234 @@
+"""WebDAV storage backend coverage against a real in-process WebDAV server.
+
+aioboto3-style mocking cannot fake aiohttp; the backend speaks real HTTP
+(HEAD/GET/PUT/DELETE/MKCOL/PROPFIND), so the fixture boots a minimal WebDAV
+server over a temp directory and exercises the backend through the wire.
+Focus on failure branches: missing objects, merge failures, cleanup scoping,
+connection errors.
+"""
+import hashlib
+import io
+import shutil
+import tempfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from aiohttp import web
+
+from core.errors import StorageError
+from core.storage import StoredFile, WebDAVFileStorage
+
+
+def make_dav_app(root: Path) -> web.Application:
+ """Minimal WebDAV server: HEAD/GET/PUT/DELETE/MKCOL/PROPFIND over `root`."""
+
+ def _fs_path(request: web.Request) -> Path | None:
+ rel = request.match_info.get("path", "")
+ target = (root / rel).resolve()
+ if not str(target).startswith(str(root)):
+ return None
+ return target
+
+ async def handler(request: web.Request) -> web.StreamResponse:
+ target = _fs_path(request)
+ if target is None:
+ return web.Response(status=403)
+ method = request.method
+
+ if method == "HEAD":
+ if target.is_file():
+ return web.Response(
+ status=200, headers={"Content-Length": str(target.stat().st_size)}
+ )
+ if target.is_dir():
+ return web.Response(status=200)
+ return web.Response(status=404)
+
+ if method == "GET":
+ if target.is_file():
+ return web.FileResponse(target)
+ return web.Response(status=404)
+
+ if method == "PUT":
+ if target.is_dir():
+ return web.Response(status=409)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(await request.read())
+ return web.Response(status=201)
+
+ if method == "MKCOL":
+ if target.exists():
+ return web.Response(status=405)
+ if not target.parent.exists():
+ return web.Response(status=409)
+ target.mkdir()
+ return web.Response(status=201)
+
+ if method == "DELETE":
+ if target.is_file():
+ target.unlink()
+ return web.Response(status=204)
+ if target.is_dir():
+ shutil.rmtree(target)
+ return web.Response(status=204)
+ return web.Response(status=404)
+
+ if method == "PROPFIND":
+ if not target.exists() or not target.is_dir():
+ return web.Response(status=404)
+ hrefs = [p.relative_to(root).as_posix() for p in target.iterdir()]
+ body = "".join(
+ f"{h}" for h in hrefs
+ )
+ xml = f"{body}"
+ return web.Response(status=207, text=xml, content_type="application/xml")
+
+ return web.Response(status=405)
+
+ app = web.Application()
+ app.router.add_route("*", "/{path:.*}", handler)
+ return app
+
+
+@pytest.fixture
+async def dav_storage():
+ from core.settings import settings
+ from aiohttp.test_utils import TestServer
+
+ root = Path(tempfile.mkdtemp(prefix="fcb-dav-"))
+ app = make_dav_app(root)
+ server = TestServer(app)
+ await server.start_server()
+ endpoint = f"http://127.0.0.1:{server.port}/dav/"
+
+ original = dict(settings.user_config)
+ settings.user_config = {
+ **original,
+ "file_storage": "webdav",
+ "webdav_url": endpoint,
+ "webdav_username": "user",
+ "webdav_password": "pass",
+ }
+ try:
+ yield WebDAVFileStorage(), root
+ finally:
+ settings.user_config = original
+ await server.close()
+ shutil.rmtree(root, ignore_errors=True)
+
+
+def _stored(key: str) -> StoredFile:
+ return StoredFile(
+ file_path=key.rsplit("/", 1)[0], uuid_file_name=key.rsplit("/", 1)[1]
+ )
+
+
+def _records(chunks: dict[int, bytes]) -> dict:
+ return {
+ i: SimpleNamespace(chunk_hash=hashlib.sha256(d).hexdigest())
+ for i, d in chunks.items()
+ }
+
+
+@pytest.mark.asyncio
+class TestWebDAVObjectBasics:
+ async def test_save_roundtrip_and_exists(self, dav_storage):
+ storage, _ = dav_storage
+ await storage.save_file(
+ io.BytesIO(b"payload"), "share/data/f.bin", "application/octet-stream"
+ )
+ assert await storage.file_exists("share/data/f.bin") is True
+ assert await storage.file_exists("share/data/ghost.bin") is False
+
+ async def test_delete_file_and_empty_parent_dirs(self, dav_storage):
+ storage, _ = dav_storage
+ await storage.save_file(io.BytesIO(b"payload"), "share/data/2026/01/01/x/f.bin")
+ await storage.delete_file(_stored("share/data/2026/01/01/x/f.bin"))
+ assert await storage.file_exists("share/data/2026/01/01/x/f.bin") is False
+ # 空父目录被逐级清理(file_exists 对目录 HEAD 也是 404)
+ assert await storage.file_exists("share/data/2026/01/01/x") is False
+
+ async def test_delete_missing_file_is_accepted(self, dav_storage):
+ storage, _ = dav_storage
+ await storage.delete_file(_stored("share/data/never-existed.bin"))
+
+ async def test_get_file_response_roundtrip(self, dav_storage):
+ storage, _ = dav_storage
+ payload = b"dav-stream-payload"
+ await storage.save_file(io.BytesIO(payload), "share/data/s.bin")
+ download = await storage.get_file_response(_stored("share/data/s.bin"))
+ assert download.headers["Content-Length"] == str(len(payload))
+ chunks = [chunk async for chunk in download.stream_factory()]
+ assert b"".join(chunks) == payload
+
+
+@pytest.mark.asyncio
+class TestWebDAVGetFileResponseFailures:
+ async def test_missing_object_raises_404_upfront(self, dav_storage):
+ """缺失对象前置 404,与 local/S3 语义对齐(不许 200 坏流)。"""
+ storage, _ = dav_storage
+ with pytest.raises(StorageError) as exc_info:
+ await storage.get_file_response(_stored("share/data/ghost.bin"))
+ assert exc_info.value.status_code == 404
+
+ async def test_connection_error_maps_to_503(self, monkeypatch):
+ from core.settings import settings
+
+ original = dict(settings.user_config)
+ settings.user_config = {
+ **original,
+ "webdav_url": "http://127.0.0.1:1/", # 无服务的端口
+ "webdav_username": "u",
+ "webdav_password": "p",
+ }
+ try:
+ storage = WebDAVFileStorage()
+ with pytest.raises(StorageError) as exc_info:
+ await storage.get_file_response(_stored("share/data/x.bin"))
+ assert exc_info.value.status_code == 503
+ finally:
+ settings.user_config = original
+
+
+@pytest.mark.asyncio
+class TestWebDAVChunkMerge:
+ async def test_merge_success_and_cleanup(self, dav_storage):
+ storage, _ = dav_storage
+ upload_id = "up01"
+ save_path = "share/data/2026/01/01/up01/merged.bin"
+ parts = {0: b"AAAA", 1: b"BBBB"}
+ for i, data in parts.items():
+ await storage.save_chunk(upload_id, i, data, "ignored", save_path)
+ _, file_hash = await storage.merge_chunks(
+ upload_id, 2, 4, save_path, _records(parts)
+ )
+ assert file_hash == hashlib.sha256(b"AAAABBBB").hexdigest()
+ assert await storage.file_exists(save_path) is True
+
+ await storage.clean_chunks(upload_id, save_path)
+ assert (
+ await storage.file_exists(
+ f"share/data/2026/01/01/up01/chunks/{upload_id}/0.part"
+ )
+ is False
+ )
+
+ async def test_merge_missing_chunk_raises(self, dav_storage):
+ storage, _ = dav_storage
+ save_path = "share/data/2026/01/01/up02/merged.bin"
+ await storage.save_chunk("up02", 1, b"BBBB", "ignored", save_path) # 缺分片 0
+ with pytest.raises(ValueError, match="分片0"):
+ await storage.merge_chunks(
+ "up02", 2, 4, save_path, _records({0: b"AAAA", 1: b"BBBB"})
+ )
+ assert await storage.file_exists(save_path) is False
+
+ async def test_merge_hash_mismatch_raises(self, dav_storage):
+ storage, _ = dav_storage
+ save_path = "share/data/2026/01/01/up03/merged.bin"
+ await storage.save_chunk("up03", 0, b"AAAA", "ignored", save_path)
+ bad = {0: SimpleNamespace(chunk_hash="f" * 64)}
+ with pytest.raises(ValueError, match="哈希不匹配"):
+ await storage.merge_chunks("up03", 1, 4, save_path, bad)
+ assert await storage.file_exists(save_path) is False
From bd05cbc27d6f5a76e6b354fd85835dd4878d2b95 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 03:49:47 +0800
Subject: [PATCH 16/31] fix: OneDrive missing object maps to 404 upfront;
OpenDAL behavior pinned
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
OneDrive get_file_response translated graph itemNotFound into the outer
503 catch-all; now raises StorageError(404) like local/S3/WebDAV.
OpenDAL already mapped missing objects to 404 via its outer catch —
pinned with fake-operator tests (SDK not in runtime deps, instances built
via __new__). Also tightens the S3 presign URL assertion.
---
core/storage.py | 19 ++++--
tests/test_negative_edge_paths.py | 105 ++++++++++++++++++++++++++++++
tests/test_s3_storage_moto.py | 2 +-
3 files changed, 120 insertions(+), 6 deletions(-)
diff --git a/core/storage.py b/core/storage.py
index cc31e2233..7b9e32edd 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -800,12 +800,21 @@ def _get_file_url(self, save_path, name):
async def get_file_response(self, file_code: StoredFile):
try:
filename = file_code.prefix + file_code.suffix
- link = await asyncio.to_thread(
- self._get_file_url, file_code.get_file_path(), filename
- )
-
+ try:
+ link = await asyncio.to_thread(
+ self._get_file_url, file_code.get_file_path(), filename
+ )
+ except self._ClientRequestException as e:
+ # 对象不存在时前置 404(与 local/S3/WebDAV 语义对齐),
+ # 不再落入外层兜底的 503
+ if str(getattr(e, "code", "")).lower() in {"itemnotfound", "404"}:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ ) from e
+ raise
+
content_length = None # 初始化为 None,表示未知大小
-
+
# 创建ClientSession并复用
session = aiohttp.ClientSession()
diff --git a/tests/test_negative_edge_paths.py b/tests/test_negative_edge_paths.py
index be8f00029..0347f433b 100644
--- a/tests/test_negative_edge_paths.py
+++ b/tests/test_negative_edge_paths.py
@@ -10,10 +10,19 @@
import pytest
+from core.errors import StorageError
from core.utils import get_select_token, get_now
from tests.conftest import TEST_ADMIN_PASSWORD
+def _stored(key: str):
+ from core.storage import StoredFile
+
+ return StoredFile(
+ file_path=key.rsplit("/", 1)[0], uuid_file_name=key.rsplit("/", 1)[1]
+ )
+
+
async def _login(client) -> str:
response = await client.post(
"/admin/login", json={"password": TEST_ADMIN_PASSWORD}
@@ -138,3 +147,99 @@ async def test_default_star_accept_gets_json_404(self, initialized_client):
response = await initialized_client.get("/no-such-path")
assert response.status_code == 404
assert response.json()["code"] == 404
+
+
+@pytest.mark.asyncio
+class TestOneDriveMissingObjectTranslation:
+ """OneDrive 缺失对象:graph 的 itemNotFound 必须前置 404,而非外层兜底 503。
+
+ office365 SDK 不在运行时依赖里(Docker 构建不含),用 __new__ 绕过构造、
+ 假异常类模拟 SDK 边界——只测我们新增的"异常码→StorageError"翻译层。
+ """
+
+ def _make_storage(self, monkeypatch, code_value: str):
+ import core.storage as storage_module
+ from core.storage import OneDriveFileStorage
+
+ class FakeClientRequestException(Exception):
+ def __init__(self, code: str):
+ self.code = code
+ super().__init__(code)
+
+ storage = OneDriveFileStorage.__new__(OneDriveFileStorage)
+ storage._ClientRequestException = FakeClientRequestException
+ storage.proxy = 1
+
+ def fake_to_thread(fn, *args, **kwargs):
+ raise FakeClientRequestException(code_value)
+
+ monkeypatch.setattr(storage_module.asyncio, "to_thread", fake_to_thread)
+ return storage
+
+ async def test_item_not_found_maps_to_404(self, monkeypatch):
+ storage = self._make_storage(monkeypatch, "itemNotFound")
+ with pytest.raises(StorageError) as exc_info:
+ await storage.get_file_response(_stored("share/data/ghost.bin"))
+ assert exc_info.value.status_code == 404
+
+ async def test_other_graph_errors_still_map_to_503(self, monkeypatch):
+ storage = self._make_storage(monkeypatch, "accessDenied")
+ with pytest.raises(StorageError) as exc_info:
+ await storage.get_file_response(_stored("share/data/denied.bin"))
+ assert exc_info.value.status_code == 503
+
+
+@pytest.mark.asyncio
+class TestOpenDALMissingObject:
+ """OpenDAL 缺失对象经外层兜底已映射 404——用假 operator 钉死该行为,
+ 防止未来重构破坏(opendal SDK 不在运行时依赖,无法构造真实实例)。"""
+
+ def _make_storage(self, monkeypatch, *, reader_exists: bool):
+ import core.storage as storage_module
+ from core.storage import OpenDALFileStorage
+
+ storage = OpenDALFileStorage.__new__(OpenDALFileStorage)
+
+ class FakeStat:
+ content_length = 0
+ size = 0
+
+ class FakeReader:
+ def __init__(self, data: bytes):
+ self._data = data
+
+ async def read(self, n: int) -> bytes:
+ data, self._data = self._data[:n], self._data[n:]
+ return data
+
+ class FakeOperator:
+ async def stat(self, path: str):
+ if not reader_exists:
+ raise FileNotFoundError(path)
+ return FakeStat()
+
+ async def reader(self, path: str):
+ if not reader_exists:
+ raise FileNotFoundError(path)
+ return FakeReader(b"opendal-payload")
+
+ async def read(self, path: str):
+ if not reader_exists:
+ raise FileNotFoundError(path)
+ return b"opendal-payload"
+
+ monkeypatch.setattr(storage_module, "logger", storage_module.logger)
+ storage.operator = FakeOperator()
+ return storage
+
+ async def test_missing_object_maps_to_404(self, monkeypatch):
+ storage = self._make_storage(monkeypatch, reader_exists=False)
+ with pytest.raises(StorageError) as exc_info:
+ await storage.get_file_response(_stored("share/data/ghost.bin"))
+ assert exc_info.value.status_code == 404
+
+ async def test_existing_object_streams_payload(self, monkeypatch):
+ storage = self._make_storage(monkeypatch, reader_exists=True)
+ download = await storage.get_file_response(_stored("share/data/s.bin"))
+ chunks = [chunk async for chunk in download.stream_factory()]
+ assert b"".join(chunks) == b"opendal-payload"
diff --git a/tests/test_s3_storage_moto.py b/tests/test_s3_storage_moto.py
index 863b64756..22dee710f 100644
--- a/tests/test_s3_storage_moto.py
+++ b/tests/test_s3_storage_moto.py
@@ -85,7 +85,7 @@ async def test_delete_file_removes_object(self, s3_storage):
async def test_presigned_upload_url_targets_bucket_and_key(self, s3_storage):
url = await s3_storage.generate_presigned_upload_url("share/data/f.bin", 900)
assert "drill-bucket" in url
- assert "f.bin" in url
+ assert "share/data/f.bin" in url # key 必须出现在 URL(path 或签名参数)
assert "X-Amz-Signature" in url
async def test_get_file_url_proxy_uses_local_dispatch(self, s3_storage):
From c23b6d6e41f25b9573cbc3de422392c4c0642529 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:01:54 +0800
Subject: [PATCH 17/31] test: admin write-path coverage (batch/single delete,
batch update, policy actions)
13 negative-path cases for the previously untested data-modifying admin
endpoints: mixed-id aggregation with duplicates and missing records,
empty-list rejections, clear_expired_at permanence semantics, policy
action boundaries (zero limit 400, unknown action 400, missing 404),
and the DoesNotExist->404 mapping pinned for single delete.
---
tests/test_admin_write_paths.py | 246 ++++++++++++++++++++++++++++++++
1 file changed, 246 insertions(+)
create mode 100644 tests/test_admin_write_paths.py
diff --git a/tests/test_admin_write_paths.py b/tests/test_admin_write_paths.py
new file mode 100644
index 000000000..ec6d33a7e
--- /dev/null
+++ b/tests/test_admin_write_paths.py
@@ -0,0 +1,246 @@
+"""Admin write-path coverage: batch/single delete, batch update, policy actions.
+
+These are the data-modifying admin endpoints that previously had zero
+automated coverage. Focus on aggregation semantics (partial failure keeps
+going), validation rejections, and 404 handling for missing records.
+"""
+import datetime
+
+import pytest
+
+from core.utils import get_now
+from tests.conftest import TEST_ADMIN_PASSWORD
+
+
+async def _login(client) -> str:
+ response = await client.post(
+ "/admin/login", json={"password": TEST_ADMIN_PASSWORD}
+ )
+ assert response.status_code == 200, response.text
+ return response.json()["detail"]["token"]
+
+
+async def _create_share(code: str, *, text: str = "x", **extra) -> int:
+ from apps.base.models import FileCodes
+
+ record = await FileCodes.create(
+ code=code, text=text, size=1, prefix="Text", **extra
+ )
+ return record.id
+
+
+@pytest.mark.asyncio
+class TestBatchDelete:
+ async def test_mixed_ids_aggregate_and_continue(self, initialized_client):
+ """存在/缺失/重复 id 混合:删除继续进行,聚合准确,记录真实消失。"""
+ token = await _login(initialized_client)
+ id_a = await _create_share("bd-mix-a")
+ id_b = await _create_share("bd-mix-b")
+ missing_id = 987654
+
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/file/batch-delete",
+ json={"ids": [id_a, missing_id, id_b, id_a]}, # 含重复 id
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ detail = response.json()["detail"]
+ assert sorted(detail["deleted"]) == sorted([id_a, id_b])
+ assert detail["missing"] == [missing_id]
+ assert detail["failed"] == []
+ assert detail["deleted_count"] == 2
+
+ from apps.base.models import FileCodes
+
+ assert await FileCodes.filter(id=id_a).first() is None
+ assert await FileCodes.filter(id=id_b).first() is None
+
+ async def test_empty_ids_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/file/batch-delete",
+ json={"ids": []},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+class TestSingleDelete:
+ async def test_missing_file_maps_to_404(self, initialized_client):
+ """删除不存在的文件:register_tortoise(add_exception_handlers=True) 把
+ tortoise DoesNotExist 映射为 404 JSON——钉死该行为防回归。"""
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/file/delete",
+ json={"id": 987654},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+ assert "does not exist" in response.json()["detail"]
+
+ async def test_existing_text_share_deleted(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("sd-exist")
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/file/delete",
+ json={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ from apps.base.models import FileCodes
+
+ assert await FileCodes.filter(id=file_id).first() is None
+
+
+@pytest.mark.asyncio
+class TestBatchUpdate:
+ async def test_expired_count_update_and_missing_aggregate(self, initialized_client):
+ token = await _login(initialized_client)
+ id_a = await _create_share("bu-cnt-a")
+ missing_id = 987655
+
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/batch-update",
+ json={"ids": [id_a, missing_id], "expired_count": 7},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ detail = response.json()["detail"]
+ assert detail["updated"] == [id_a]
+ assert detail["missing"] == [missing_id]
+
+ from apps.base.models import FileCodes
+
+ record = await FileCodes.get(id=id_a)
+ assert record.expired_count == 7
+
+ async def test_clear_expired_at_makes_permanent(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share(
+ "bu-clr",
+ expired_at=await get_now() + datetime.timedelta(days=1),
+ expired_count=3,
+ )
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/batch-update",
+ json={"ids": [file_id], "clear_expired_at": True},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ from apps.base.models import FileCodes
+
+ record = await FileCodes.get(id=file_id)
+ assert record.expired_at is None
+ assert record.expired_count == -1
+
+ async def test_no_fields_selected_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("bu-none")
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/batch-update",
+ json={"ids": [file_id]},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+
+@pytest.mark.asyncio
+class TestPolicyActions:
+ async def test_make_permanent_clears_time_and_count(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share(
+ "pa-perm",
+ expired_at=await get_now() + datetime.timedelta(days=1),
+ expired_count=3,
+ )
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/policy-action",
+ json={"id": file_id, "action": "make_permanent"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ from apps.base.models import FileCodes
+
+ record = await FileCodes.get(id=file_id)
+ assert record.expired_at is None
+ assert record.expired_count == -1
+
+ async def test_reset_download_limit_custom_value(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("pa-reset")
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/policy-action",
+ json={
+ "id": file_id,
+ "action": "reset_download_limit",
+ "download_limit": 9,
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ from apps.base.models import FileCodes
+
+ assert (await FileCodes.get(id=file_id)).expired_count == 9
+
+ async def test_reset_download_limit_zero_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("pa-zero")
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/policy-action",
+ json={
+ "id": file_id,
+ "action": "reset_download_limit",
+ "download_limit": 0,
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_unknown_action_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("pa-unknown")
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/policy-action",
+ json={"id": file_id, "action": "nuke_everything"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_policy_action_missing_file_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/policy-action",
+ json={"id": 987656, "action": "make_permanent"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+ async def test_batch_policy_action_aggregates_missing(self, initialized_client):
+ token = await _login(initialized_client)
+ id_a = await _create_share("bpa-a")
+ response = await initialized_client.request(
+ "PATCH",
+ "/admin/file/batch-policy-action",
+ json={"ids": [id_a, 987657], "action": "extend_24h"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ detail = response.json()["detail"]
+ assert detail["updated"] == [id_a]
+ assert detail["missing"] == [987657]
From 12dba9dcaf27dcf4534a79191f81904de4435863 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:04:46 +0800
Subject: [PATCH 18/31] test: admin read-path and view-preset CRUD coverage (22
cases)
Detail/metadata/preview/admin-download/activities/local-lists+delete/
verify: missing-record 404s, note/tag truncation limits, preview max_chars
truncation, activity filtering and the 80-event clamp, local path
traversal rejection. Presets: update-vs-create by id, name truncation,
filter normalization clamps, 24-preset cap, delete-missing 404.
---
tests/test_admin_read_paths.py | 259 +++++++++++++++++++++++++++++++
tests/test_admin_view_presets.py | 147 ++++++++++++++++++
2 files changed, 406 insertions(+)
create mode 100644 tests/test_admin_read_paths.py
create mode 100644 tests/test_admin_view_presets.py
diff --git a/tests/test_admin_read_paths.py b/tests/test_admin_read_paths.py
new file mode 100644
index 000000000..9b722d445
--- /dev/null
+++ b/tests/test_admin_read_paths.py
@@ -0,0 +1,259 @@
+"""Admin read-path coverage: detail, metadata, preview, admin download,
+activities filtering, local file lists/delete, and verify.
+
+These endpoints previously had zero automated coverage. Negative paths
+first: missing records, truncation limits, wrong-type rejections.
+"""
+
+import pytest
+
+from tests.test_admin_write_paths import _create_share, _login
+
+
+@pytest.mark.asyncio
+class TestFileDetail:
+ async def test_detail_returns_policy_storage_metadata(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("dt-exist")
+ response = await initialized_client.get(
+ "/admin/file/detail",
+ params={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ detail = response.json()["detail"]
+ assert detail["code"] == "dt-exist"
+ assert detail["is_text"] is True
+ assert {"policy", "storage", "metadata", "timeline"} <= set(detail.keys())
+
+ async def test_detail_missing_file_404(self, initialized_client):
+ token = await _login(initialized_client)
+ for method, kwargs in (
+ ("get", {"params": {"id": 987658}}),
+ ("post", {"json": {"id": 987658}}),
+ ):
+ response = await initialized_client.request(
+ method,
+ "/admin/file/detail",
+ headers={"Authorization": f"Bearer {token}"},
+ **kwargs,
+ )
+ assert response.status_code == 404, method
+
+
+@pytest.mark.asyncio
+class TestFileMetadata:
+ async def test_note_and_tags_roundtrip_with_truncation(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("md-round")
+
+ # note 超长被截断到 2000;tags 超 12 个被截、单个超 24 字符被截、去重
+ long_note = "n" * 3000
+ tags = [f"tag{i}" for i in range(15)] + ["x" * 30, "dup", "dup"]
+ response = await initialized_client.post(
+ "/admin/file/metadata",
+ json={"id": file_id, "note": long_note, "tags": tags},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+
+ detail = (
+ await initialized_client.get(
+ "/admin/file/detail",
+ params={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ ).json()["detail"]
+ metadata = detail["metadata"]
+ assert len(metadata["note"]) == 2000
+ assert len(metadata["tags"]) == 12
+ assert all(len(tag) <= 24 for tag in metadata["tags"])
+ assert metadata["updated_at"] is not None
+
+ async def test_metadata_missing_file_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.post(
+ "/admin/file/metadata",
+ json={"id": 987659, "note": "x"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestFilePreview:
+ async def test_text_preview_truncates_with_max_chars(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("pv-trunc", text="abcdefgh" * 10)
+ response = await initialized_client.get(
+ "/admin/file/preview",
+ params={"id": file_id, "max_chars": 10},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ detail = response.json()["detail"]
+ assert detail["content"] == "abcdefghab"
+ assert detail["truncated"] is True
+ assert detail["preview_length"] == 10
+ assert detail["max_chars"] == 10
+
+ async def test_preview_non_text_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ from apps.base.models import FileCodes
+
+ file_id = (
+ await FileCodes.create(
+ code="pv-file", prefix="doc", suffix=".bin", size=1
+ )
+ ).id
+ response = await initialized_client.get(
+ "/admin/file/preview",
+ params={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_preview_missing_file_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.get(
+ "/admin/file/preview",
+ params={"id": 987660},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestAdminDownload:
+ async def test_text_share_download_returns_content(self, initialized_client):
+ token = await _login(initialized_client)
+ file_id = await _create_share("dl-text", text="admin-download-body")
+ response = await initialized_client.get(
+ "/admin/file/download",
+ params={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert b"admin-download-body" in response.content
+
+ async def test_missing_file_download_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.get(
+ "/admin/file/download",
+ params={"id": 987661},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestActivities:
+ async def test_recorded_action_is_listed_and_filtered(self, initialized_client):
+ """删一个文件产生活动事件;action 过滤必须生效。"""
+ token = await _login(initialized_client)
+ file_id = await _create_share("act-del")
+ await initialized_client.request(
+ "DELETE",
+ "/admin/file/delete",
+ json={"id": file_id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ listed = await initialized_client.get(
+ "/admin/activities",
+ params={"action": "file.delete"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert listed.status_code == 200
+ activities = listed.json()["detail"]["activities"]
+ assert any(
+ a["action"] == "file.delete" and a["target_id"] == file_id
+ for a in activities
+ )
+
+ other = await initialized_client.get(
+ "/admin/activities",
+ params={"action": "nonexistent.action"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert other.json()["detail"]["activities"] == []
+
+ async def test_limit_upper_bound_clamped_to_80(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.get(
+ "/admin/activities",
+ params={"limit": 5000},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["detail"]["limit"] == 80
+
+
+@pytest.mark.asyncio
+class TestLocalFileEndpoints:
+ async def test_lists_and_delete_roundtrip(self, initialized_client):
+ """data/local 列表与删除(走端点层;存储层语义由 WebDAV 轮覆盖)。"""
+ import shutil
+ from core.settings import data_root
+
+ token = await _login(initialized_client)
+ local_dir = data_root / "local" / "e2e-probe"
+ local_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ (local_dir / "probe.txt").write_bytes(b"local-probe")
+
+ listed = await initialized_client.get(
+ "/admin/local/lists",
+ params={"path": "e2e-probe"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert listed.status_code == 200, listed.text
+ items = listed.json()["detail"]["items"]
+ assert any(item["name"] == "probe.txt" for item in items)
+
+ deleted = await initialized_client.request(
+ "DELETE",
+ "/admin/local/delete",
+ json={"filename": "e2e-probe/probe.txt"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert deleted.status_code == 200
+ assert not (local_dir / "probe.txt").exists()
+ finally:
+ shutil.rmtree(data_root / "local" / "e2e-probe", ignore_errors=True)
+
+ async def test_local_delete_traversal_rejected(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/local/delete",
+ json={"filename": "../../filecodebox.db"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_local_delete_missing_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/local/delete",
+ json={"filename": "ghost.txt"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
+
+
+@pytest.mark.asyncio
+class TestVerify:
+ async def test_valid_token_passes(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.get(
+ "/admin/verify", headers={"Authorization": f"Bearer {token}"}
+ )
+ assert response.status_code == 200
+
+ async def test_garbage_token_rejected_401(self, initialized_client):
+ response = await initialized_client.get(
+ "/admin/verify", headers={"Authorization": "Bearer garbage.token.here"}
+ )
+ assert response.status_code == 401
diff --git a/tests/test_admin_view_presets.py b/tests/test_admin_view_presets.py
new file mode 100644
index 000000000..ff1212b8b
--- /dev/null
+++ b/tests/test_admin_view_presets.py
@@ -0,0 +1,147 @@
+"""Admin view-preset CRUD coverage: normalize boundaries, cap enforcement,
+delete-missing 404. Presets are stored as a KeyValue JSON blob (the D5
+lock-guarded write path), so the roundtrip also exercises that lock.
+"""
+import pytest
+
+from tests.test_admin_write_paths import _login
+
+
+@pytest.mark.asyncio
+class TestViewPresetCRUD:
+ async def test_save_list_update_delete_roundtrip(self, initialized_client):
+ token = await _login(initialized_client)
+ headers = {"Authorization": f"Bearer {token}"}
+
+ created = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={"name": "我的视图", "filters": {"status": "active", "size": 25}},
+ headers=headers,
+ )
+ assert created.status_code == 200, created.text
+ preset = created.json()["detail"]
+ assert preset["name"] == "我的视图"
+
+ # 更新(带同 id 再保存)
+ updated = await initialized_client.patch(
+ "/admin/file/view-presets",
+ json={
+ "id": preset["id"],
+ "name": "改名视图",
+ "filters": {"status": "expired"},
+ },
+ headers=headers,
+ )
+ assert updated.status_code == 200
+ assert updated.json()["detail"]["name"] == "改名视图"
+
+ listing = (
+ await initialized_client.get(
+ "/admin/file/view-presets", headers=headers
+ )
+ ).json()["detail"]
+ names = [p["name"] for p in listing["presets"]]
+ assert "改名视图" in names
+ assert "我的视图" not in names # 同 id 保存是更新不是新增
+
+ deleted = await initialized_client.request(
+ "DELETE",
+ "/admin/file/view-presets",
+ json={"id": preset["id"]},
+ headers=headers,
+ )
+ assert deleted.status_code == 200
+ listing_after = (
+ await initialized_client.get(
+ "/admin/file/view-presets", headers=headers
+ )
+ ).json()["detail"]
+ assert "改名视图" not in [p["name"] for p in listing_after["presets"]]
+
+ async def test_empty_name_rejected_400(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={"name": " "},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 400
+
+ async def test_long_name_truncated_to_32(self, initialized_client):
+ token = await _login(initialized_client)
+ headers = {"Authorization": f"Bearer {token}"}
+ created = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={"name": "n" * 50},
+ headers=headers,
+ )
+ assert created.status_code == 200
+ assert len(created.json()["detail"]["name"]) == 32
+
+ async def test_filters_normalized_and_clamped(self, initialized_client):
+ token = await _login(initialized_client)
+ headers = {"Authorization": f"Bearer {token}"}
+ created = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={
+ "name": "filter-check",
+ "filters": {
+ "status": "not-a-status",
+ "type": "alien",
+ "health": "bogus",
+ "sortBy": "hacker_field",
+ "sortOrder": "sideways",
+ "size": 99999,
+ "keyword": "k" * 200,
+ },
+ },
+ headers=headers,
+ )
+ assert created.status_code == 200
+ filters = created.json()["detail"]["filters"]
+ assert filters["status"] == "all"
+ assert filters["type"] == "all"
+ assert filters["health"] == "all"
+ assert filters["sort_by"] == "created_at"
+ assert filters["sort_order"] == "desc"
+ assert filters["size"] == 100
+ assert len(filters["keyword"]) == 80
+
+ async def test_preset_cap_24_enforced(self, initialized_client):
+ token = await _login(initialized_client)
+ headers = {"Authorization": f"Bearer {token}"}
+ ids = []
+ for i in range(24):
+ created = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={"name": f"cap-{i}"},
+ headers=headers,
+ )
+ assert created.status_code == 200, f"第 {i + 1} 个预设应成功"
+ ids.append(created.json()["detail"]["id"])
+
+ overflow = await initialized_client.post(
+ "/admin/file/view-presets",
+ json={"name": "cap-overflow"},
+ headers=headers,
+ )
+ assert overflow.status_code == 400
+
+ # 清理到上限以内,避免污染其他用例
+ for preset_id in ids:
+ await initialized_client.request(
+ "DELETE",
+ "/admin/file/view-presets",
+ json={"id": preset_id},
+ headers=headers,
+ )
+
+ async def test_delete_missing_preset_404(self, initialized_client):
+ token = await _login(initialized_client)
+ response = await initialized_client.request(
+ "DELETE",
+ "/admin/file/view-presets",
+ json={"id": "no-such-preset"},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 404
From 0d524ee1dd01309b404da73ed5b9ef0bc59d4328 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:10:07 +0800
Subject: [PATCH 19/31] refactor: move auth primitives to apps/base/auth, kill
base->admin dep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
JWT create/verify, bearer extraction and the share-upload gate move to
apps.base.auth (consumed by both surfaces); admin.dependencies keeps
admin session gating and service providers, re-exporting the primitives
for import-path compatibility. base.views no longer imports from
apps.admin — the last cross-app reverse dependency is gone.
---
apps/admin/dependencies.py | 170 +++++++----------------------------
apps/base/auth.py | 150 +++++++++++++++++++++++++++++++
apps/base/views.py | 2 +-
tests/test_admin_security.py | 9 +-
4 files changed, 187 insertions(+), 144 deletions(-)
create mode 100644 apps/base/auth.py
diff --git a/apps/admin/dependencies.py b/apps/admin/dependencies.py
index 45dcebedd..f1343b239 100644
--- a/apps/admin/dependencies.py
+++ b/apps/admin/dependencies.py
@@ -2,127 +2,40 @@
# @Author : Lan
# @File : depends.py
# @Software: PyCharm
-from fastapi import Header, HTTPException
-from fastapi.requests import Request
-import base64
-import hmac
-import json
-import time
-from core.settings import (
- ADMIN_SESSION_EXPIRE_DEFAULT,
- ADMIN_SESSION_EXPIRE_MAX,
- ADMIN_SESSION_EXPIRE_MIN,
- settings,
-)
-from apps.admin.services import FileService, ConfigService, LocalFileService
-
-
-def _get_jwt_secret() -> bytes:
- secret = getattr(settings, "jwt_secret", "")
- if not secret:
- raise RuntimeError("JWT签名密钥未初始化")
- return secret.encode()
+from fastapi import Header
+from fastapi.requests import Request
-def get_admin_session_expire_seconds() -> int:
- try:
- expires_in = int(
- getattr(settings, "admin_session_expire", ADMIN_SESSION_EXPIRE_DEFAULT)
- )
- except (TypeError, ValueError):
- return ADMIN_SESSION_EXPIRE_DEFAULT
- if (
- not ADMIN_SESSION_EXPIRE_MIN <= expires_in <= ADMIN_SESSION_EXPIRE_MAX
- or expires_in % ADMIN_SESSION_EXPIRE_MIN != 0
- ):
- return ADMIN_SESSION_EXPIRE_DEFAULT
- return expires_in
-
+from apps.admin.services import ConfigService, FileService, LocalFileService
+from apps.base.auth import (
+ _extract_bearer_token,
+ _get_jwt_secret,
+ _require_admin_payload,
+ create_token,
+ get_admin_session_expire_seconds,
+ share_required_login,
+ verify_token,
+)
-def create_token(data: dict, expires_in: int | None = None) -> str:
- """
- 创建JWT token
- :param data: 数据负载
- :param expires_in: 过期时间(秒)
- """
- token_lifetime = (
- get_admin_session_expire_seconds() if expires_in is None else expires_in
- )
- header = base64.urlsafe_b64encode(
- json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()
- ).decode().rstrip("=")
- payload = base64.urlsafe_b64encode(
- json.dumps(
- {**data, "exp": int(time.time()) + token_lifetime},
- separators=(",", ":"),
- ).encode()
- ).decode().rstrip("=")
-
- signature = hmac.new(
- _get_jwt_secret(), f"{header}.{payload}".encode(), "sha256"
- ).digest()
- signature = base64.urlsafe_b64encode(signature).decode().rstrip("=")
-
- return f"{header}.{payload}.{signature}"
-
-
-def verify_token(token: str) -> dict:
- """
- 验证JWT token
- :param token: JWT token
- :return: 解码后的数据
- """
- try:
- header_b64, payload_b64, signature_b64 = token.split(".")
-
- # 验证签名
- expected_signature = hmac.new(
- _get_jwt_secret(),
- f"{header_b64}.{payload_b64}".encode(),
- "sha256",
- ).digest()
- expected_signature_b64 = (
- base64.urlsafe_b64encode(expected_signature).decode().rstrip("=")
- )
-
- if not hmac.compare_digest(signature_b64, expected_signature_b64):
- raise ValueError("无效的签名")
-
- # 解码payload(兼容历史标准 base64 与 urlsafe base64)
- padded = payload_b64 + "=" * (-len(payload_b64) % 4)
- try:
- payload_bytes = base64.urlsafe_b64decode(padded)
- except Exception:
- payload_bytes = base64.b64decode(padded)
- payload = json.loads(payload_bytes)
-
- # 检查是否过期
- if payload.get("exp", 0) < time.time():
- raise ValueError("token已过期")
-
- return payload
- except Exception as e:
- raise ValueError(f"token验证失败: {str(e)}")
-
-
-def _extract_bearer_token(authorization: str) -> str:
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(status_code=401, detail="未授权或授权校验失败")
- token = authorization.split(" ", 1)[1].strip()
- if not token:
- raise HTTPException(status_code=401, detail="未授权或授权校验失败")
- return token
-
-
-def _require_admin_payload(authorization: str) -> dict:
- token = _extract_bearer_token(authorization)
- try:
- payload = verify_token(token)
- except ValueError as e:
- raise HTTPException(status_code=401, detail=str(e))
- if not payload.get("is_admin", False):
- raise HTTPException(status_code=401, detail="未授权或授权校验失败")
- return payload
+# 认证原语(JWT 创建/校验、Bearer 提取、分享上传门控)已下沉 apps.base.auth
+# ——base 分享面与本模块共同消费,留在 base 避免 base→admin 反向依赖。
+# 此处 re-export 保持既有 import 路径兼容(tests/admin.views 等)。
+
+__all__ = [
+ "_extract_bearer_token",
+ "_get_jwt_secret",
+ "_require_admin_payload",
+ "ADMIN_PUBLIC_ENDPOINTS",
+ "admin_required",
+ "create_token",
+ "get_admin_session",
+ "get_admin_session_expire_seconds",
+ "get_config_service",
+ "get_file_service",
+ "get_local_file_service",
+ "share_required_login",
+ "verify_token",
+]
def get_admin_session(authorization: str = Header(default=None)) -> dict:
@@ -151,27 +64,6 @@ async def admin_required(
return _require_admin_payload(authorization)
-async def share_required_login(authorization: str = Header(default=None)):
- """
- 验证分享上传权限
-
- 当 settings.open_upload 为False时,要求用户必须登录并具有管理员权限
- 当 settings.open_upload 为True时,允许游客上传
-
- :param authorization: 认证头信息
- :param request: 请求对象
- :return: 验证结果
- """
- if not settings.open_upload:
- if not authorization or not authorization.startswith("Bearer "):
- raise HTTPException(
- status_code=403, detail="本站未开启游客上传,如需上传请先登录后台"
- )
- _require_admin_payload(authorization)
-
- return True
-
-
async def get_file_service():
return FileService()
diff --git a/apps/base/auth.py b/apps/base/auth.py
new file mode 100644
index 000000000..ea6a423ef
--- /dev/null
+++ b/apps/base/auth.py
@@ -0,0 +1,150 @@
+"""Cross-cutting auth primitives and the share-upload gate.
+
+Lives in apps.base because BOTH the share surface (base.views) and the
+admin surface (admin.views) consume it; keeping it here avoids the
+base → admin reverse dependency. Admin-only session gating stays in
+apps.admin.dependencies, which re-exports these primitives for
+compatibility.
+"""
+import base64
+import hmac
+import json
+import time
+
+from fastapi import Header, HTTPException
+
+from core.settings import (
+ ADMIN_SESSION_EXPIRE_DEFAULT,
+ ADMIN_SESSION_EXPIRE_MAX,
+ ADMIN_SESSION_EXPIRE_MIN,
+ settings,
+)
+
+
+def _get_jwt_secret() -> bytes:
+ secret = getattr(settings, "jwt_secret", "")
+ if not secret:
+ raise RuntimeError("JWT签名密钥未初始化")
+ return secret.encode()
+
+
+def get_admin_session_expire_seconds() -> int:
+ try:
+ expires_in = int(
+ getattr(settings, "admin_session_expire", ADMIN_SESSION_EXPIRE_DEFAULT)
+ )
+ except (TypeError, ValueError):
+ return ADMIN_SESSION_EXPIRE_DEFAULT
+ if (
+ not ADMIN_SESSION_EXPIRE_MIN <= expires_in <= ADMIN_SESSION_EXPIRE_MAX
+ or expires_in % ADMIN_SESSION_EXPIRE_MIN != 0
+ ):
+ return ADMIN_SESSION_EXPIRE_DEFAULT
+ return expires_in
+
+
+def create_token(data: dict, expires_in: int | None = None) -> str:
+ """
+ 创建JWT token
+ :param data: 数据负载
+ :param expires_in: 过期时间(秒)
+ """
+ token_lifetime = (
+ get_admin_session_expire_seconds() if expires_in is None else expires_in
+ )
+ header = base64.urlsafe_b64encode(
+ json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()
+ ).decode().rstrip("=")
+ payload = base64.urlsafe_b64encode(
+ json.dumps(
+ {**data, "exp": int(time.time()) + token_lifetime},
+ separators=(",", ":"),
+ ).encode()
+ ).decode().rstrip("=")
+
+ signature = hmac.new(
+ _get_jwt_secret(), f"{header}.{payload}".encode(), "sha256"
+ ).digest()
+ signature = base64.urlsafe_b64encode(signature).decode().rstrip("=")
+
+ return f"{header}.{payload}.{signature}"
+
+
+def verify_token(token: str) -> dict:
+ """
+ 验证JWT token
+ :param token: JWT token
+ :return: 解码后的数据
+ """
+ try:
+ header_b64, payload_b64, signature_b64 = token.split(".")
+
+ # 验证签名
+ expected_signature = hmac.new(
+ _get_jwt_secret(),
+ f"{header_b64}.{payload_b64}".encode(),
+ "sha256",
+ ).digest()
+ expected_signature_b64 = (
+ base64.urlsafe_b64encode(expected_signature).decode().rstrip("=")
+ )
+
+ if not hmac.compare_digest(signature_b64, expected_signature_b64):
+ raise ValueError("无效的签名")
+
+ # 解码payload(兼容历史标准 base64 与 urlsafe base64)
+ padded = payload_b64 + "=" * (-len(payload_b64) % 4)
+ try:
+ payload_bytes = base64.urlsafe_b64decode(padded)
+ except Exception:
+ payload_bytes = base64.b64decode(padded)
+ payload = json.loads(payload_bytes)
+
+ # 检查是否过期
+ if payload.get("exp", 0) < time.time():
+ raise ValueError("token已过期")
+
+ return payload
+ except Exception as e:
+ raise ValueError(f"token验证失败: {str(e)}")
+
+
+def _extract_bearer_token(authorization: str) -> str:
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(status_code=401, detail="未授权或授权校验失败")
+ token = authorization.split(" ", 1)[1].strip()
+ if not token:
+ raise HTTPException(status_code=401, detail="未授权或授权校验失败")
+ return token
+
+
+def _require_admin_payload(authorization: str) -> dict:
+ token = _extract_bearer_token(authorization)
+ try:
+ payload = verify_token(token)
+ except ValueError as e:
+ raise HTTPException(status_code=401, detail=str(e))
+ if not payload.get("is_admin", False):
+ raise HTTPException(status_code=401, detail="未授权或授权校验失败")
+ return payload
+
+
+async def share_required_login(authorization: str = Header(default=None)):
+ """
+ 验证分享上传权限
+
+ 当 settings.open_upload 为False时,要求用户必须登录并具有管理员权限
+ 当 settings.open_upload 为True时,允许游客上传
+
+ :param authorization: 认证头信息
+ :param request: 请求对象
+ :return: 验证结果
+ """
+ if not settings.open_upload:
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(
+ status_code=403, detail="本站未开启游客上传,如需上传请先登录后台"
+ )
+ _require_admin_payload(authorization)
+
+ return True
diff --git a/apps/base/views.py b/apps/base/views.py
index 48269bbd3..f52784d99 100644
--- a/apps/base/views.py
+++ b/apps/base/views.py
@@ -13,7 +13,7 @@
from starlette.responses import Response
from tortoise.expressions import Case, F, Q, When
-from apps.admin.dependencies import share_required_login
+from apps.base.auth import share_required_login
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
from apps.base.quota import release_storage, reserve_storage
from apps.base.services import (
diff --git a/tests/test_admin_security.py b/tests/test_admin_security.py
index 53cf54cd4..a28566c49 100644
--- a/tests/test_admin_security.py
+++ b/tests/test_admin_security.py
@@ -4,7 +4,6 @@
import apps.admin.services as admin_services
import apps.admin.views as admin_views
-import apps.admin.dependencies as admin_dependencies
import apps.base.config as core_config
from apps.admin.dependencies import create_token, verify_token
from apps.admin.schemas import LoginData
@@ -126,15 +125,17 @@ def test_configured_session_lifetime_is_returned_by_login(self):
settings.admin_token = hash_password("admin-password")
settings.jwt_secret = "j" * 48
settings.admin_session_expire = 90 * 24 * 60 * 60
- original_time = admin_dependencies.time.time
- admin_dependencies.time.time = lambda: 1_800_000_000
+ import apps.base.auth as base_auth
+
+ original_time = base_auth.time.time
+ base_auth.time.time = lambda: 1_800_000_000
try:
response = asyncio.run(
admin_views.login(LoginData(password="admin-password"))
)
payload = verify_token(response.detail["token"])
finally:
- admin_dependencies.time.time = original_time
+ base_auth.time.time = original_time
self.assertEqual(response.detail["expires_in"], 90 * 24 * 60 * 60)
self.assertEqual(
From b3f1d7f0ab8d98983f860493347f2e9993f7d909 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:20:28 +0800
Subject: [PATCH 20/31] refactor: split admin services god-module
(ConfigService/LocalFile out)
apps/admin/services.py (1697 lines) becomes FileService (~1200 lines) +
a compatibility facade re-exporting ConfigService, LocalFileService,
LocalFileClass and keyvalue_write_lock from their new homes
(config_service.py holds the D5 KeyValue lock; one-way imports, no
cycle). test_admin_security patch targets follow ConfigService to its
new module. Behavior unchanged; 247 tests green.
---
apps/admin/config_service.py | 159 +++++++++++++++++++++++
apps/admin/local_files.py | 101 +++++++++++++++
apps/admin/services.py | 240 +----------------------------------
tests/test_admin_security.py | 15 ++-
4 files changed, 272 insertions(+), 243 deletions(-)
create mode 100644 apps/admin/config_service.py
create mode 100644 apps/admin/local_files.py
diff --git a/apps/admin/config_service.py b/apps/admin/config_service.py
new file mode 100644
index 000000000..e4540e57f
--- /dev/null
+++ b/apps/admin/config_service.py
@@ -0,0 +1,159 @@
+"""System config write path (ConfigService) + the KeyValue JSON lock.
+
+keyvalue_write_lock lives here because ConfigService.update_config is its
+primary consumer; FileService imports it (one-way, no cycle).
+"""
+import asyncio
+
+from fastapi import HTTPException
+
+from core.settings import (
+ ADMIN_SESSION_EXPIRE_MAX,
+ ADMIN_SESSION_EXPIRE_MIN,
+ settings,
+)
+from apps.base.config import refresh_settings
+from core.security import (
+ INTERNAL_CONFIG_KEYS,
+ OUTBOUND_ENDPOINT_CONFIG_KEYS,
+ generate_jwt_secret,
+ validate_outbound_endpoint,
+ validate_outbound_hostname,
+)
+from apps.base.models import KeyValue
+from core.utils import hash_password, is_password_hashed, validate_background_url
+
+# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写;
+# 进程内写锁串行化这三个写路径,避免并发管理操作互相覆盖(last-writer-wins)。
+# 多进程部署下锁不跨进程——文档已锁定单 worker 部署。
+keyvalue_write_lock = asyncio.Lock()
+
+
+class ConfigService:
+ INT_FIELDS = {
+ "admin_session_expire",
+ "enable_chunk",
+ "error_count",
+ "error_minute",
+ "login_count",
+ "login_minute",
+ "max_save_seconds",
+ "onedrive_proxy",
+ "open_upload",
+ "port",
+ "s3_proxy",
+ "server_port",
+ "server_workers",
+ "show_admin_addr",
+ "storage_limit",
+ "upload_count",
+ "upload_minute",
+ "upload_size",
+ "webdav_proxy",
+ }
+ FLOAT_FIELDS = {"opacity"}
+
+ def get_config(self):
+ config = dict(settings.items())
+ config["admin_token"] = ""
+ for key in INTERNAL_CONFIG_KEYS:
+ config.pop(key, None)
+ return config
+
+ async def update_config(self, data: dict):
+ current_config = dict(settings.items())
+ next_config = dict(current_config)
+ update_data = {
+ key: value
+ for key, value in data.items()
+ if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS
+ }
+
+ admin_token = update_data.get("admin_token")
+ admin_password_changed = False
+ if admin_token is None or admin_token == "":
+ update_data.pop("admin_token", None)
+ elif not is_password_hashed(admin_token):
+ update_data["admin_token"] = hash_password(admin_token)
+ admin_password_changed = True
+ else:
+ admin_password_changed = True
+
+ for key, value in update_data.items():
+ if value == "" and key in self.INT_FIELDS | self.FLOAT_FIELDS:
+ continue
+
+ try:
+ if key in self.INT_FIELDS:
+ next_config[key] = int(value)
+ elif key in self.FLOAT_FIELDS:
+ next_config[key] = float(value)
+ else:
+ next_config[key] = value
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误")
+
+ try:
+ session_expire = int(next_config.get("admin_session_expire"))
+ except (TypeError, ValueError):
+ raise HTTPException(
+ status_code=400,
+ detail="admin_session_expire 配置值格式错误",
+ )
+ if (
+ not ADMIN_SESSION_EXPIRE_MIN <= session_expire <= ADMIN_SESSION_EXPIRE_MAX
+ or session_expire % ADMIN_SESSION_EXPIRE_MIN != 0
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="admin_session_expire 必须是 1 到 365 个整天",
+ )
+ next_config["admin_session_expire"] = session_expire
+
+ if int(next_config.get("storage_limit", 0)) < 0:
+ raise HTTPException(
+ status_code=400,
+ detail="storage_limit 不能小于 0",
+ )
+
+ # 只校验"发生变化"的值:升级前存入的旧格式 background(相对路径、含空格
+ # 或括号)在旧版本是合法的,若每次保存都重新校验,存量部署会连无关设置项
+ # 都保存不了(一律 400)。渲染侧仍然 html 转义,而任何修改都必须通过校验。
+ current_background = str(settings.background or "")
+ candidate_background = str(next_config.get("background") or "")
+ if candidate_background != current_background:
+ try:
+ validate_background_url(candidate_background)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+ # 只校验"发生变化"的值:企业内网 minio/webdav 是正当场景,存量部署历史
+ # 合法写入的内网 endpoint 若每次保存都重新校验,会连无关设置都保存不了
+ # (background 曾有同款回归,上游 #528 修复过——本处沿用同一语义)。
+ # s3_hostname 是裸主机名(存储层按 https://{hostname} 拼接),单独分档校验。
+ for endpoint_key in OUTBOUND_ENDPOINT_CONFIG_KEYS:
+ if endpoint_key not in next_config:
+ continue
+ candidate = str(next_config[endpoint_key] or "")
+ current = str(getattr(settings, endpoint_key, "") or "")
+ if candidate == current:
+ continue
+ validator = (
+ validate_outbound_hostname
+ if endpoint_key == "s3_hostname"
+ else validate_outbound_endpoint
+ )
+ try:
+ # 写回规范化值(validator 去除首尾空白):校验通过但入库脏值
+ # 会让存储层在连接期才报错,应在校验点归一。
+ next_config[endpoint_key] = validator(candidate)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc))
+
+ if admin_password_changed:
+ next_config["jwt_secret"] = generate_jwt_secret()
+
+ async with keyvalue_write_lock:
+ await KeyValue.update_or_create(key="settings", defaults={"value": next_config})
+ await refresh_settings(force=True)
+
diff --git a/apps/admin/local_files.py b/apps/admin/local_files.py
new file mode 100644
index 000000000..b6cab1397
--- /dev/null
+++ b/apps/admin/local_files.py
@@ -0,0 +1,101 @@
+"""Admin-side local (NAS) file browsing and deletion."""
+from pathlib import Path
+
+from fastapi import HTTPException
+
+from apps.base.local_share import (
+ MAX_LIST_ENTRIES,
+ format_local_ctime,
+ get_local_root,
+ normalize_local_relpath,
+ resolve_under_local,
+)
+
+
+class LocalFileService:
+ async def list_files(self, path: str = ""):
+ relpath = normalize_local_relpath(path, allow_empty=True)
+ directory = resolve_under_local(relpath)
+ if not directory.exists() or not directory.is_dir():
+ raise HTTPException(status_code=404, detail="目录不存在")
+
+ root = get_local_root()
+ items = []
+ try:
+ children = list(directory.iterdir())
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail="无法读取目录") from exc
+
+ children.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
+ truncated = False
+ for child in children:
+ if len(items) >= MAX_LIST_ENTRIES:
+ truncated = True
+ break
+ try:
+ resolved = child.resolve()
+ resolved.relative_to(root)
+ except (OSError, ValueError):
+ continue
+ if not resolved.is_file() and not resolved.is_dir():
+ continue
+ child_rel = child.name if not relpath else f"{relpath}/{child.name}"
+ is_dir = resolved.is_dir()
+ items.append(
+ {
+ "file": child.name,
+ "name": child.name,
+ "path": child_rel,
+ "type": "dir" if is_dir else "file",
+ "ctime": format_local_ctime(resolved),
+ "size": None if is_dir else resolved.stat().st_size,
+ }
+ )
+
+ parent = ""
+ if relpath:
+ parent_path = Path(relpath).parent.as_posix()
+ parent = "" if parent_path == "." else parent_path
+ return {
+ "path": relpath,
+ "parent": parent,
+ "truncated": truncated,
+ "items": items,
+ }
+
+ async def delete_file(self, filename: str):
+ file = LocalFileClass(filename)
+ if await file.exists():
+ await file.delete()
+ return "删除成功"
+ raise HTTPException(status_code=404, detail="文件不存在")
+
+
+class LocalFileClass:
+ def __init__(self, file):
+ relpath = normalize_local_relpath(file)
+ self.file = relpath
+ self.name = Path(relpath).name
+ self.path = resolve_under_local(relpath)
+ if self.path.is_file():
+ self.ctime = format_local_ctime(self.path)
+ self.size = self.path.stat().st_size
+ else:
+ self.ctime = None
+ self.size = None
+
+ async def read(self) -> bytes:
+ with open(self.path, "rb") as fh:
+ return fh.read()
+
+ async def write(self, data):
+ with open(self.path, "wb") as f:
+ f.write(data)
+
+ async def delete(self):
+ if not self.path.is_file():
+ raise HTTPException(status_code=404, detail="文件不存在")
+ self.path.unlink()
+
+ async def exists(self):
+ return self.path.is_file()
diff --git a/apps/admin/services.py b/apps/admin/services.py
index 6a8f9bcaf..744f37e08 100644
--- a/apps/admin/services.py
+++ b/apps/admin/services.py
@@ -1,4 +1,3 @@
-import asyncio
import hashlib
from pathlib import Path
from datetime import datetime, timedelta
@@ -7,38 +6,19 @@
from core.response import APIResponse
from core.storage import FileStorageInterface, storages
from core.settings import (
- ADMIN_SESSION_EXPIRE_MAX,
- ADMIN_SESSION_EXPIRE_MIN,
settings,
)
-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,
- OUTBOUND_ENDPOINT_CONFIG_KEYS,
- generate_jwt_secret,
- validate_outbound_endpoint,
- validate_outbound_hostname,
-)
from apps.base.models import FileCodes, KeyValue
from apps.base.utils import get_expire_info
from apps.base.local_share import (
LOCAL_REF_MARKER,
- MAX_LIST_ENTRIES,
- format_local_ctime,
- get_local_root,
is_local_ref,
- normalize_local_relpath,
- resolve_under_local,
should_skip_storage_delete,
)
from fastapi import HTTPException
-from core.utils import get_now, hash_password, is_password_hashed, validate_background_url
+from core.utils import get_now
-# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写;
-# 进程内写锁串行化这三个写路径,避免并发管理操作互相覆盖(last-writer-wins)。
-# 多进程部署下锁不跨进程——文档已锁定单 worker 部署。
-keyvalue_write_lock = asyncio.Lock()
class FileService:
@@ -1479,219 +1459,7 @@ async def share_local_file(self, item):
}
-class ConfigService:
- INT_FIELDS = {
- "admin_session_expire",
- "enable_chunk",
- "error_count",
- "error_minute",
- "login_count",
- "login_minute",
- "max_save_seconds",
- "onedrive_proxy",
- "open_upload",
- "port",
- "s3_proxy",
- "server_port",
- "server_workers",
- "show_admin_addr",
- "storage_limit",
- "upload_count",
- "upload_minute",
- "upload_size",
- "webdav_proxy",
- }
- FLOAT_FIELDS = {"opacity"}
-
- def get_config(self):
- config = dict(settings.items())
- config["admin_token"] = ""
- for key in INTERNAL_CONFIG_KEYS:
- config.pop(key, None)
- return config
-
- async def update_config(self, data: dict):
- current_config = dict(settings.items())
- next_config = dict(current_config)
- update_data = {
- key: value
- for key, value in data.items()
- if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS
- }
-
- admin_token = update_data.get("admin_token")
- admin_password_changed = False
- if admin_token is None or admin_token == "":
- update_data.pop("admin_token", None)
- elif not is_password_hashed(admin_token):
- update_data["admin_token"] = hash_password(admin_token)
- admin_password_changed = True
- else:
- admin_password_changed = True
-
- for key, value in update_data.items():
- if value == "" and key in self.INT_FIELDS | self.FLOAT_FIELDS:
- continue
-
- try:
- if key in self.INT_FIELDS:
- next_config[key] = int(value)
- elif key in self.FLOAT_FIELDS:
- next_config[key] = float(value)
- else:
- next_config[key] = value
- except (TypeError, ValueError):
- raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误")
-
- try:
- session_expire = int(next_config.get("admin_session_expire"))
- except (TypeError, ValueError):
- raise HTTPException(
- status_code=400,
- detail="admin_session_expire 配置值格式错误",
- )
- if (
- not ADMIN_SESSION_EXPIRE_MIN <= session_expire <= ADMIN_SESSION_EXPIRE_MAX
- or session_expire % ADMIN_SESSION_EXPIRE_MIN != 0
- ):
- raise HTTPException(
- status_code=400,
- detail="admin_session_expire 必须是 1 到 365 个整天",
- )
- next_config["admin_session_expire"] = session_expire
-
- if int(next_config.get("storage_limit", 0)) < 0:
- raise HTTPException(
- status_code=400,
- detail="storage_limit 不能小于 0",
- )
-
- # 只校验"发生变化"的值:升级前存入的旧格式 background(相对路径、含空格
- # 或括号)在旧版本是合法的,若每次保存都重新校验,存量部署会连无关设置项
- # 都保存不了(一律 400)。渲染侧仍然 html 转义,而任何修改都必须通过校验。
- current_background = str(settings.background or "")
- candidate_background = str(next_config.get("background") or "")
- if candidate_background != current_background:
- try:
- validate_background_url(candidate_background)
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc))
-
- # 只校验"发生变化"的值:企业内网 minio/webdav 是正当场景,存量部署历史
- # 合法写入的内网 endpoint 若每次保存都重新校验,会连无关设置都保存不了
- # (background 曾有同款回归,上游 #528 修复过——本处沿用同一语义)。
- # s3_hostname 是裸主机名(存储层按 https://{hostname} 拼接),单独分档校验。
- for endpoint_key in OUTBOUND_ENDPOINT_CONFIG_KEYS:
- if endpoint_key not in next_config:
- continue
- candidate = str(next_config[endpoint_key] or "")
- current = str(getattr(settings, endpoint_key, "") or "")
- if candidate == current:
- continue
- validator = (
- validate_outbound_hostname
- if endpoint_key == "s3_hostname"
- else validate_outbound_endpoint
- )
- try:
- # 写回规范化值(validator 去除首尾空白):校验通过但入库脏值
- # 会让存储层在连接期才报错,应在校验点归一。
- next_config[endpoint_key] = validator(candidate)
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc))
-
- if admin_password_changed:
- next_config["jwt_secret"] = generate_jwt_secret()
-
- async with keyvalue_write_lock:
- await KeyValue.update_or_create(key="settings", defaults={"value": next_config})
- await refresh_settings(force=True)
-
-
-class LocalFileService:
- async def list_files(self, path: str = ""):
- relpath = normalize_local_relpath(path, allow_empty=True)
- directory = resolve_under_local(relpath)
- if not directory.exists() or not directory.is_dir():
- raise HTTPException(status_code=404, detail="目录不存在")
-
- root = get_local_root()
- items = []
- try:
- children = list(directory.iterdir())
- except OSError as exc:
- raise HTTPException(status_code=500, detail="无法读取目录") from exc
-
- children.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
- truncated = False
- for child in children:
- if len(items) >= MAX_LIST_ENTRIES:
- truncated = True
- break
- try:
- resolved = child.resolve()
- resolved.relative_to(root)
- except (OSError, ValueError):
- continue
- if not resolved.is_file() and not resolved.is_dir():
- continue
- child_rel = child.name if not relpath else f"{relpath}/{child.name}"
- is_dir = resolved.is_dir()
- items.append(
- {
- "file": child.name,
- "name": child.name,
- "path": child_rel,
- "type": "dir" if is_dir else "file",
- "ctime": format_local_ctime(resolved),
- "size": None if is_dir else resolved.stat().st_size,
- }
- )
-
- parent = ""
- if relpath:
- parent_path = Path(relpath).parent.as_posix()
- parent = "" if parent_path == "." else parent_path
- return {
- "path": relpath,
- "parent": parent,
- "truncated": truncated,
- "items": items,
- }
-
- async def delete_file(self, filename: str):
- file = LocalFileClass(filename)
- if await file.exists():
- await file.delete()
- return "删除成功"
- raise HTTPException(status_code=404, detail="文件不存在")
-
-
-class LocalFileClass:
- def __init__(self, file):
- relpath = normalize_local_relpath(file)
- self.file = relpath
- self.name = Path(relpath).name
- self.path = resolve_under_local(relpath)
- if self.path.is_file():
- self.ctime = format_local_ctime(self.path)
- self.size = self.path.stat().st_size
- else:
- self.ctime = None
- self.size = None
-
- async def read(self) -> bytes:
- with open(self.path, "rb") as fh:
- return fh.read()
-
- async def write(self, data):
- with open(self.path, "wb") as f:
- f.write(data)
-
- async def delete(self):
- if not self.path.is_file():
- raise HTTPException(status_code=404, detail="文件不存在")
- self.path.unlink()
- async def exists(self):
- return self.path.is_file()
+# —— 门面 re-export:既有 import 路径(apps.admin.services.ConfigService 等)保持兼容 ——
+from apps.admin.config_service import ConfigService, keyvalue_write_lock # noqa: E402,F401
+from apps.admin.local_files import LocalFileClass, LocalFileService # noqa: E402,F401
diff --git a/tests/test_admin_security.py b/tests/test_admin_security.py
index a28566c49..d477e1221 100644
--- a/tests/test_admin_security.py
+++ b/tests/test_admin_security.py
@@ -2,7 +2,6 @@
import copy
import unittest
-import apps.admin.services as admin_services
import apps.admin.views as admin_views
import apps.base.config as core_config
from apps.admin.dependencies import create_token, verify_token
@@ -208,16 +207,18 @@ def test_admin_password_update_rotates_jwt_secret(self):
"admin_token": hash_password("old-admin-password"),
"jwt_secret": old_secret,
}
- original_key_value = admin_services.KeyValue
- original_refresh_settings = admin_services.refresh_settings
- admin_services.KeyValue = FakeKeyValue
- admin_services.refresh_settings = fake_refresh_settings
+ import apps.admin.config_service as config_service
+
+ original_key_value = config_service.KeyValue
+ original_refresh_settings = config_service.refresh_settings
+ config_service.KeyValue = FakeKeyValue
+ config_service.refresh_settings = fake_refresh_settings
FakeKeyValue.saved_value = None
try:
asyncio.run(ConfigService().update_config({"admin_token": "new-admin-password"}))
finally:
- admin_services.KeyValue = original_key_value
- admin_services.refresh_settings = original_refresh_settings
+ config_service.KeyValue = original_key_value
+ config_service.refresh_settings = original_refresh_settings
self.assertIsNotNone(FakeKeyValue.saved_value)
self.assertTrue(verify_password("new-admin-password", FakeKeyValue.saved_value["admin_token"]))
From f9e29dd6b85e1e88caf25277c41200c5f044ef89 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:33:20 +0800
Subject: [PATCH 21/31] fix: size-less uploads no longer crash (upstream seek
bug)
validate_file_size's size-None branch called UploadFile.seek(0, 2),
which raises TypeError (UploadFile.seek takes one arg); the underlying
SpooledTemporaryFile.seek is sync, so awaiting it also fails. Use the
underlying file object with sync seek(0, SEEK_END). Surface discovered
by the mypy pilot run; regression test covers the size-None branch.
---
apps/base/services.py | 7 +++++--
tests/test_admin_write_paths.py | 16 ++++++++++++++++
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/apps/base/services.py b/apps/base/services.py
index 39115e3dd..0eee0b803 100644
--- a/apps/base/services.py
+++ b/apps/base/services.py
@@ -76,9 +76,12 @@ async def validate_file_size(file: UploadFile, max_size: int) -> int:
"""Return the upload's size, rejecting anything above max_size."""
size = file.size
if size is None:
- await file.seek(0, 2) # type: ignore[arg-type]
+ # 无长度声明的上传(如 chunked 传输):必须走底层文件对象拿末尾偏移。
+ # 注意两层都不能用:UploadFile.seek 只接受单参数(TypeError);
+ # 底层 SpooledTemporaryFile.seek 是同步方法(不能 await)。
+ file.file.seek(0, 2)
size = file.file.tell()
- await file.seek(0)
+ file.file.seek(0)
if size > max_size:
max_size_mb = max_size / (1024 * 1024)
raise HTTPException(
diff --git a/tests/test_admin_write_paths.py b/tests/test_admin_write_paths.py
index ec6d33a7e..071349c13 100644
--- a/tests/test_admin_write_paths.py
+++ b/tests/test_admin_write_paths.py
@@ -5,6 +5,7 @@
going), validation rejections, and 404 handling for missing records.
"""
import datetime
+import io
import pytest
@@ -244,3 +245,18 @@ async def test_batch_policy_action_aggregates_missing(self, initialized_client):
detail = response.json()["detail"]
assert detail["updated"] == [id_a]
assert detail["missing"] == [987657]
+
+
+@pytest.mark.asyncio
+class TestValidateFileSizeWithoutLength:
+ async def test_upload_without_size_declaration_no_500(self, initialized_client):
+ """无长度声明的上传(size=None 分支)不得 TypeError——上游原有缺陷。"""
+ from apps.base.auth import _require_admin_payload # noqa: F401
+ from apps.base.services import validate_file_size
+ from core.settings import settings
+ from fastapi import UploadFile
+
+ upload = UploadFile(file=io.BytesIO(b"no-length-payload")) # size=None
+ assert upload.size is None
+ size = await validate_file_size(upload, settings.upload_size)
+ assert size == len(b"no-length-payload")
From b1e24efd7a5bf6a4fe275e2f63ddf787f533b068 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:36:43 +0800
Subject: [PATCH 22/31] chore: bump direct deps (patch/minor)
fastapi 0.139.2->0.141.1, pydantic 2.12.5->2.13.5, uvicorn 0.51.0->0.53.0,
aiohttp 3.14.2->3.14.3; lockfile regenerated. tortoise-orm 0.x->1.x major
deliberately deferred pending API-change review.
---
requirements.lock.txt | 521 +++++++++++++++++++++---------------------
requirements.txt | 8 +-
2 files changed, 264 insertions(+), 265 deletions(-)
diff --git a/requirements.lock.txt b/requirements.lock.txt
index 6901baabd..3f2e9b439 100644
--- a/requirements.lock.txt
+++ b/requirements.lock.txt
@@ -1,9 +1,9 @@
# This file was autogenerated by uv via the following command:
-# uv pip compile requirements.lock.tmp --generate-hashes --universal -o requirements.lock
+# uv pip compile requirements.txt --generate-hashes --universal -o requirements.lock.txt
aioboto3==15.5.0 \
--hash=sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6 \
--hash=sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979
- # via -r requirements.lock.tmp
+ # via -r requirements.txt
aiobotocore==2.25.1 \
--hash=sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc \
--hash=sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f
@@ -12,134 +12,134 @@ aiofiles==25.1.0 \
--hash=sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2 \
--hash=sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695
# via
- # -r requirements.lock.tmp
+ # -r requirements.txt
# aioboto3
aiohappyeyeballs==2.7.1 \
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
# via aiohttp
-aiohttp==3.14.2 \
- --hash=sha256:03330676d8caa28bb33fa7104b0d542d9aac93350abcd91bf68e64abd531c320 \
- --hash=sha256:052478c7d01035d805302db50c2ef626b1c1ba0fe2f6d4a22ae6eaeb43bf2316 \
- --hash=sha256:09d1b0deec698d1198eb0b8f910dd9432d856985abbfea3f06be8b296a6619b4 \
- --hash=sha256:0baed2a2367a28456b612f4c3fd28bb86b00fadfb6454e706d8f65c21636bfd7 \
- --hash=sha256:0bfea68a48c8071d49aabdf5cd9a6939dcb246db65730e8dc76295fe02f7c73c \
- --hash=sha256:0e56babe35076f69ec9327833b71439eeccd10f51fe56c1a533da8f24923f014 \
- --hash=sha256:0eb1c9fd51f231ac8dc9d5824d5c2efc45337d429db0123fa9d4c20f570fdfc3 \
- --hash=sha256:0fb26fcc5ebf765095fe0c6ab7501574d3108c57fca9a0d462be15a65c9deb8d \
- --hash=sha256:114299c08cce8ad4ebb21fafe766378864109e88ad8cf63cf6acb384ff844a57 \
- --hash=sha256:135570f5b470c72c4988a58986f1f847ad336721f77fcc18fda8472bd3bbe3db \
- --hash=sha256:15292b08ce7dd45e268fce542228894b4735102e8ee77163bd665b35fc2b5598 \
- --hash=sha256:165b0dcc65960ffc9c99aa4ba1c3c76dbc7a34845c3c23a0bd3fbf33b3d12569 \
- --hash=sha256:17eecd6ee9bfc8e31b6003137d74f349f0ac3797111a2df87e23acb4a7a912ea \
- --hash=sha256:18fcc3a5cc7dde1d8f7903e309055294c28894c9434588645817e374f3b83d03 \
- --hash=sha256:1aa4f3b44563a88da4407cef8a13438e9e386967720a826a10a633493f69208f \
- --hash=sha256:1b9251f43d78ff675c0ddfcd53ba61abecc1f74eedc6287bb6657f6c6a033fe7 \
- --hash=sha256:1c05afdd28ecacce5a1f63275a2e3dce09efddd3a63d143ee9799fda83989c8d \
- --hash=sha256:1fc31339824ec922cb7424d624b5b6c11d8942d077b2585e5bd602ca1a1e27ed \
- --hash=sha256:205181d896f73436ac60cf6644e545544c759ab1c3ec8c34cc1e044689611361 \
- --hash=sha256:2280d165ab38355144d9984cdce77ce506cee019a07390bab7fd13682248ce91 \
- --hash=sha256:2a382aa6bb85347515ead043257445baeec0885d42bfedb962093b134c3b4816 \
- --hash=sha256:2d2eedae227cd5cbd0bccc5e759f71e1af2cd77b7f74ce413bb9a2b87f94a272 \
- --hash=sha256:2f1b9540d2d0f2f95590528a1effd0ba5370f6ec189ac925e70b5eecae02dc77 \
- --hash=sha256:2f7ca81d936d820ae479971a6b6214b1b867420b5b58e54a1e7157716a943754 \
- --hash=sha256:30a5ed81f752f182961237414a3cd0af209c0f74f06d66f66f9fcb8964f4978d \
- --hash=sha256:30e41662123806e4590a0440585122ac33c89a2465a8be81cc1b50656ca0e432 \
- --hash=sha256:312d414c294a1e26aa12888e8fd37cd2e1131e9c48ddcf2a4c6b590290d52a49 \
- --hash=sha256:3523ec0cc524a413699f25ec8340f3da368484bc9d5f2a1bf87f233ac20599bf \
- --hash=sha256:386ce4e709b4cc40f9ef9a132ad8e672d2d164a65451305672df656e7794c68e \
- --hash=sha256:3d4238e50a378f5ac69a1e0162715c676bd082dede2e5c4f67ca7fd0014cb09d \
- --hash=sha256:3ec4b6501a076b2f73844256da17d6b7acb15bb74ee0e908a67feb9412371166 \
- --hash=sha256:3f3381f81bc1c6cbe160b2a3708d39d05014329118e6b648b95edc841eeeebd4 \
- --hash=sha256:40bedff39ea83185f3f98a41155dd9da28b365c432e5bd90e7be140bcef0b7f3 \
- --hash=sha256:4181d72e0e6d1735c1fae56381193c6ae211d584d06413980c00775b9b2a176a \
- --hash=sha256:41b5b66b1ac2c48b61e420691eb9741d17d9068f2bc23b5ee3e750faa564bc8f \
- --hash=sha256:42372e1f1a8dca0dcd5daf922849004ec1120042d0e24f14c926f97d2275ca79 \
- --hash=sha256:43387429e4f2ec4047aaf9f935db003d4aa1268ea9021164877fd6b012b6396a \
- --hash=sha256:4610638d3135afaefadf179bffd1bbf3434d3dc7a5d0a4c4219b99fa976e944d \
- --hash=sha256:46b8887aa303075c1e5b24123f314a1a7bbfa03d0213dff8bb70503b2148c853 \
- --hash=sha256:476cf7fac10619ad6d08e1df0225d07b5a8d57c04963a171ad845d5a349d47ef \
- --hash=sha256:483b6f964bbbdaa99a0cd7def631208c44e39d243b95cff23ebc812db8a80e03 \
- --hash=sha256:4ca802547f1128008addfc21b24959f5cbf30a8952d365e7daa078a0d884b242 \
- --hash=sha256:56432ee8f7abe47c97717cfbf5c32430463ea8a7138e12a87b7891fa6084c8ff \
- --hash=sha256:5e94a8c4445bfdaa30773c81f2be7f129673e0f528945e542b8bd024b2979134 \
- --hash=sha256:5fe25c4c44ea5b56fd4512e2065e09384987fc8cc98e41bc8749efe12f653abb \
- --hash=sha256:63b840c03979732ec92e570f0bd6beb6311e2b5d19cacbfcd8cc7f6dd2693900 \
- --hash=sha256:65cd3bb118f42fceceb9e8a615c735a01453d019c673f35c57b420601cc1a83a \
- --hash=sha256:66de80888db2176655f8df0b705b817f5ae3834e6566cc2caa89360871d90195 \
- --hash=sha256:673217cbc9370ebf8cd048b0889d7cbe922b7bb48f4e4c02d31cfefa140bd946 \
- --hash=sha256:68a6f7cd8d2c70869a2a5fe97a16e86a4e13a6ed6f0d9e6029aef7573e344cd6 \
- --hash=sha256:6b63709e259e3b3d7922b235606564e91ed4c224e777cc0ca4cae04f5f559206 \
- --hash=sha256:6bea8451e26cd67645d9b2ee18232e438ddfc36cea35feecb4537f2359fc7030 \
- --hash=sha256:6c244f7a65cbec04c830a301aae443c529d4dbca5fddfd4b19e5a179d896adfd \
- --hash=sha256:6cde463b9dd9ce4343785c5a39127b40fce059ae6fbd320f5a045a38c3d25cd0 \
- --hash=sha256:6e30743bd3ab6ad98e9abbad6ccb39c52bcf6f11f9e3d4b6df97afffe8df53f3 \
- --hash=sha256:70570f50bda5037b416db8fcba595cf808ecf0fdce12d64e850b5ae1db7f64d4 \
- --hash=sha256:71501bc03ede681401269c569e6f9306c761c1c7d4296675e8e78dd07147070f \
- --hash=sha256:7719cef2a9dc5e10cd5f476ec1744b25c5ac4da733a9a687d91c42de7d4afe30 \
- --hash=sha256:7871c94f3400358530ac4906dd7a526c5a24099cd5c48f53ffc4b1cb5037d7d7 \
- --hash=sha256:7ae767b7dffd316cc2d0abf3e1f90132b4c1a2819a32d8bcb1ba749800ea6273 \
- --hash=sha256:7e254b0d636957174a03ca210289e867a62bb9502081e1b44a8c2bb1f6266ecd \
- --hash=sha256:7e328d02fb46b9a8dbfa070d98967e8b7eaa1d9ee10ae03fb664bdf30d58ccf0 \
- --hash=sha256:8241ee6c7fff3ebb1e6b237bccc1d90b46d07c06cf978e9f2ecad43e29dac67a \
- --hash=sha256:82d14d66d6147441b6571833405c828980efc17bda98075a248104ffdd330c30 \
- --hash=sha256:86861a430657bc71e0f89b195de5f8fa495c0b9b5864cf2f89bd5ec1dbb6b77a \
- --hash=sha256:87c9b03be0c18c3b3587be979149830381e37ac4a6ca8557dbe72e44fcad66c3 \
- --hash=sha256:89120e926c68c4e60c78514d76e16fc15689d8df35843b2a6bf6c4cc0d64b11a \
- --hash=sha256:8c2cdb684c153f377157e856257ee8535c75d8478343e4bb1e83ca73bdfa3d31 \
- --hash=sha256:8d1f3802887f0e0dc07387a081dca3ad0b5758e32bdf5fb619b12ac22b8e9b56 \
- --hash=sha256:8f7b19e27b78a3a927b1932af93af7645806153e8f541cee8fe856426142503f \
- --hash=sha256:9094262ae4f2902c7291c14ba915960db5567276690ef9195cdefe8b7cbb3acb \
- --hash=sha256:983a68048a48f35ed08aadfcc1ba55de9a121aa91be48a764965c9ec532b94b5 \
- --hash=sha256:9b937d7864ca68f1e8a1c3a4eb2bac1de86a992f86d36492da10a135a482fab6 \
- --hash=sha256:9d3f4c68b2c2cd282b65e558cebf4b27c8b440ab511f2b938a643d3598df2ddb \
- --hash=sha256:a26f14006883fc7662e21041b4311eac1acbc977a5c43aacb27ff17f8a4c28b2 \
- --hash=sha256:a3177e51e26e0158fb3376aebac97e0546c6f175c510f331f585e514a00a302b \
- --hash=sha256:a57f39d6ec155932853b6b0f130cbbafab3208240fa807f29a2c96ea52b77ae1 \
- --hash=sha256:a6b0ce033d49dd3c6a2566b387e322a9f9029110d67902f0d64571c0fd4b73d8 \
- --hash=sha256:aac1b05fc5e2ef188b6d74cf151e977db75ab281238f30c3163bbd6f797788e3 \
- --hash=sha256:abb33120daba5e5643a757790ece44d638a5a11eb0598312e6e7ec2f1bd1a5a3 \
- --hash=sha256:af63ac06bad85191e6a0c4a733cb3c55adb99f8105bc7ce9913391561159a49a \
- --hash=sha256:b0d49be9d9a210b2c993bf32b1eda03f949f7bcda68fc4f718ae8085ae3fb4b8 \
- --hash=sha256:b155df7f572c73c6c4108b67be302c8639b96ae56fb02787eeae8cad0a1baf26 \
- --hash=sha256:b39dbdbe30a44958d63f3f8baa2af68f24ec8a631dcd18a33dd76dfa2a0eb917 \
- --hash=sha256:b5ed2c7dacebf4950d6b4a1b22548e4d709bb15e0287e064a7cdb32ada65893a \
- --hash=sha256:bc0ed30b942c3bd755583d74bb00b90248c067d20b1f8301e4489a53a33aa65f \
- --hash=sha256:bc1a0793dce8fa9bb6906411e57fb18a2f1c31357b04172541b92b30337362a7 \
- --hash=sha256:bf7951959a8e89f2d4a1e719e60d3ea4e8fc26f011ee3aed09598ad786b112f7 \
- --hash=sha256:c0a968b04fecf7c94e502015860ad1e2e112c6b761e97b6fdf65fbb374e22b73 \
- --hash=sha256:c0c7f2e5fe10910d5ab76438f269cc41bb7e499fd48ded978e926360ab1790c8 \
- --hash=sha256:c167127a3b6089ef78ac2e33582c38040d51688ee28474b5053acf55f192187b \
- --hash=sha256:c8ab295ee58332ef8fbd62727df90540836dfcf7a61f545d0f2771223b80bf25 \
- --hash=sha256:cabaaecb4c6888bd9abafac151051377534dad4c3859a386b6325f39d3732f99 \
- --hash=sha256:cc4435b16dc246c5dfa7f2f8ee71b10a30765018a090ee36e99f356b1e9b75cc \
- --hash=sha256:ce8dfb58f012f76258f29951d38935ac928b32ae24a480f30761f2ed5036fa78 \
- --hash=sha256:ceb77c159b2b4c1a179b96a26af36bcaa68eb79c393ec4f569386a69d013cbe9 \
- --hash=sha256:ceff4f84c1d928654faa6bcb0437ed095b279baae2a35fcfe5a3cbe0d8b9725d \
- --hash=sha256:cf7930e83a12801b2e253d41cc8bf5553f61c0cfabef182a72ae13472cc81803 \
- --hash=sha256:d15f618255fcbe5f54689403aa4c2a90b6f2e6ebc96b295b1cb0e868c1c12384 \
- --hash=sha256:d32a70b8bf8836fd80d4169d9e34eb032cd2a7cbccb0b9cf00eac1f40732467c \
- --hash=sha256:d813f54560b9e5bce170fff7b0adde54d88253928e4add447c36792f27f92125 \
- --hash=sha256:d93854e215dcc7c88e4f530827193c1a594e2662931d8dbe7cca3abf52a7082d \
- --hash=sha256:da4f142fa078fedbdb3f88d0542ad9315656224e167502ae274cbba818b90c90 \
- --hash=sha256:dbc45e2773c66d14fbd337754e9bf23932beef539bd539716a721f5b5f372034 \
- --hash=sha256:dc056948b7a8a40484b4bbc69923fa25cddd80cbc5f236a3a22ad2f836baeed2 \
- --hash=sha256:de3b04a3f7b40ad7f1bcd3540dd447cf9bd93d57a49969bca522cbcf01290f08 \
- --hash=sha256:e3a6302f47518dbf2ffd3cd518f02a1fbf53f85ffeed41a224fa4a6f6a62673b \
- --hash=sha256:e5efff8bfd27c44ce1bfdf92ce838362d9316ed8b2ed2f89f581dbe0bbe05acf \
- --hash=sha256:ec64d1c4605d689ed537ba1e572138e2d4ff603a0cb2bbbfe61d4552c73d19e1 \
- --hash=sha256:ecdd6b8cab5b7c0ff2988378c11ba7192f076a1864e64dc3ff72f7ba05c71796 \
- --hash=sha256:ee5bdd7933c653e43ef8d720704a4e228e4927121f2f5f598b7efe6a4c18633a \
- --hash=sha256:ef710fbb770aefa4def5484eeddb606e70ab3492aa37390def61b35652f6820a \
- --hash=sha256:f2f9950b2dd0fc896ab520ea2366b7df6484d3d164a65d5e9f28f7b0e5742d8a \
- --hash=sha256:f518d75c03cd3f7f125eca1baadb56f8b94db94602278d2d0d19af6e177650a7 \
- --hash=sha256:f7c10c4d0b33888a68c192d883d1390d4596c116a59bf689e6d352c6739b7940 \
- --hash=sha256:f8f371794319a8185e61e15ba5e1be8407b986ebce1ade11856c02d24e090577 \
- --hash=sha256:f96821eb2ae2f12b0dfa799eafbf221f5621a9220b457b4744a269a63a5f3a6c \
- --hash=sha256:fc2d8e7373ceba7e1c7e9dc00adac854c2701a6d443fd21d4af2e49342d727bd \
- --hash=sha256:fef094bfc2f4e991a998af066fc6e3956a409ef799f5cbad2365175357181f2e
+aiohttp==3.14.3 \
+ --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \
+ --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \
+ --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \
+ --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \
+ --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \
+ --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \
+ --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \
+ --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \
+ --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \
+ --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \
+ --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \
+ --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \
+ --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \
+ --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \
+ --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \
+ --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \
+ --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \
+ --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \
+ --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \
+ --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \
+ --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \
+ --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \
+ --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \
+ --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \
+ --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \
+ --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \
+ --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \
+ --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \
+ --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \
+ --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \
+ --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \
+ --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \
+ --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \
+ --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \
+ --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \
+ --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \
+ --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \
+ --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \
+ --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \
+ --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \
+ --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \
+ --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \
+ --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \
+ --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \
+ --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \
+ --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \
+ --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \
+ --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \
+ --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \
+ --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \
+ --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \
+ --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \
+ --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \
+ --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \
+ --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \
+ --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \
+ --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \
+ --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \
+ --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \
+ --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \
+ --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \
+ --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \
+ --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \
+ --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \
+ --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \
+ --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \
+ --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \
+ --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \
+ --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \
+ --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \
+ --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \
+ --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \
+ --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \
+ --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \
+ --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \
+ --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \
+ --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \
+ --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \
+ --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \
+ --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \
+ --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \
+ --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \
+ --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \
+ --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \
+ --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \
+ --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \
+ --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \
+ --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \
+ --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \
+ --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \
+ --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \
+ --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \
+ --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \
+ --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \
+ --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \
+ --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \
+ --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \
+ --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \
+ --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \
+ --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \
+ --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \
+ --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \
+ --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \
+ --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \
+ --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \
+ --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \
+ --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \
+ --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \
+ --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \
+ --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \
+ --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \
+ --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \
+ --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \
+ --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \
+ --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \
+ --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \
+ --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \
+ --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \
+ --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5
# via
- # -r requirements.lock.tmp
+ # -r requirements.txt
# aiobotocore
aioitertools==0.13.0 \
--hash=sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be \
@@ -186,10 +186,10 @@ click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via uvicorn
-fastapi==0.139.2 \
- --hash=sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e \
- --hash=sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c
- # via -r requirements.lock.tmp
+fastapi==0.141.1 \
+ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+ --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
+ # via -r requirements.txt
frozenlist==1.8.0 \
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
@@ -646,134 +646,133 @@ propcache==0.5.2 \
# via
# aiohttp
# yarl
-pydantic==2.12.5 \
- --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \
- --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
+pydantic==2.13.5 \
+ --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \
+ --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08
# via
- # -r requirements.lock.tmp
+ # -r requirements.txt
# fastapi
-pydantic-core==2.41.5 \
- --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \
- --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \
- --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \
- --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \
- --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \
- --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \
- --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \
- --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \
- --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \
- --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \
- --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \
- --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \
- --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \
- --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \
- --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \
- --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \
- --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \
- --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \
- --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \
- --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \
- --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \
- --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \
- --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \
- --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \
- --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \
- --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \
- --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \
- --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \
- --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \
- --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \
- --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \
- --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \
- --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \
- --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \
- --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \
- --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \
- --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \
- --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \
- --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \
- --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \
- --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \
- --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \
- --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \
- --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \
- --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \
- --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \
- --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \
- --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \
- --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \
- --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \
- --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \
- --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \
- --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \
- --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \
- --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \
- --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \
- --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \
- --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \
- --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \
- --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \
- --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \
- --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \
- --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \
- --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \
- --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \
- --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \
- --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \
- --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \
- --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \
- --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \
- --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \
- --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \
- --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \
- --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \
- --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \
- --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \
- --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \
- --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \
- --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \
- --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \
- --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \
- --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \
- --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \
- --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \
- --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \
- --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \
- --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \
- --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \
- --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \
- --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \
- --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \
- --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \
- --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \
- --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \
- --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \
- --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \
- --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \
- --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \
- --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \
- --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \
- --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \
- --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \
- --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \
- --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \
- --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \
- --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \
- --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \
- --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \
- --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \
- --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \
- --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \
- --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \
- --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \
- --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \
- --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \
- --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \
- --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \
- --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \
- --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \
- --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \
- --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52
+pydantic-core==2.46.5 \
+ --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \
+ --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \
+ --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \
+ --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \
+ --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \
+ --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \
+ --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \
+ --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \
+ --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \
+ --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \
+ --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \
+ --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \
+ --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \
+ --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \
+ --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \
+ --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \
+ --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \
+ --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \
+ --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \
+ --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \
+ --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \
+ --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \
+ --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \
+ --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \
+ --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \
+ --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \
+ --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \
+ --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \
+ --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \
+ --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \
+ --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \
+ --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \
+ --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \
+ --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \
+ --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \
+ --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \
+ --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \
+ --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \
+ --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \
+ --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \
+ --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \
+ --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \
+ --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \
+ --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \
+ --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \
+ --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \
+ --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \
+ --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \
+ --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \
+ --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \
+ --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \
+ --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \
+ --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \
+ --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \
+ --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \
+ --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \
+ --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \
+ --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \
+ --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \
+ --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \
+ --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \
+ --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \
+ --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \
+ --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \
+ --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \
+ --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \
+ --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \
+ --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \
+ --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \
+ --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \
+ --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \
+ --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \
+ --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \
+ --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \
+ --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \
+ --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \
+ --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \
+ --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \
+ --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \
+ --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \
+ --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \
+ --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \
+ --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \
+ --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \
+ --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \
+ --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \
+ --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \
+ --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \
+ --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \
+ --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \
+ --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \
+ --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \
+ --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \
+ --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \
+ --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \
+ --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \
+ --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \
+ --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \
+ --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \
+ --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \
+ --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \
+ --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \
+ --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \
+ --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \
+ --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \
+ --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \
+ --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \
+ --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \
+ --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \
+ --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \
+ --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \
+ --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \
+ --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \
+ --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \
+ --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \
+ --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \
+ --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \
+ --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \
+ --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \
+ --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9
# via pydantic
pypika-tortoise==0.6.5 \
--hash=sha256:64d96c9b88450f6360ad22a7063933b6a90961a7317f04b2b63c98fd5d705506 \
@@ -788,7 +787,7 @@ python-dateutil==2.9.0.post0 \
python-multipart==0.0.32 \
--hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \
--hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23
- # via -r requirements.lock.tmp
+ # via -r requirements.txt
pytz==2026.3.post1 \
--hash=sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d \
--hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815
@@ -805,12 +804,12 @@ starlette==1.6.0 \
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
# via
- # -r requirements.lock.tmp
+ # -r requirements.txt
# fastapi
tortoise-orm==0.25.3 \
--hash=sha256:3c52a53c41f4137aee9ffb3f3de01f30b52ad7767157f9bd9586a910fe839ca3 \
--hash=sha256:b6dedd388393624628ec46228c93df361533ceb3925986fa2d1d22debc838a7d
- # via -r requirements.lock.tmp
+ # via -r requirements.txt
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
@@ -833,10 +832,10 @@ urllib3==2.7.0 \
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
# via botocore
-uvicorn==0.51.0 \
- --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \
- --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0
- # via -r requirements.lock.tmp
+uvicorn==0.53.0 \
+ --hash=sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e \
+ --hash=sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e
+ # via -r requirements.txt
wrapt==1.17.3 \
--hash=sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56 \
--hash=sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828 \
diff --git a/requirements.txt b/requirements.txt
index 8a1227f2b..5b02b2951 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,9 +1,9 @@
aioboto3==15.5.0
-aiohttp==3.14.2
+aiohttp==3.14.3
aiofiles==25.1.0
-fastapi==0.139.2
+fastapi==0.141.1
starlette==1.6.0
-pydantic==2.12.5
-uvicorn==0.51.0
+pydantic==2.13.5
+uvicorn==0.53.0
tortoise-orm==0.25.3
python-multipart==0.0.32
From fc20567cfaa9293c4b6bc465c6be6c6953904909 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:39:02 +0800
Subject: [PATCH 23/31] ci: gradual mypy adoption via baseline ratchet
scripts/mypy_ratchet.py compares mypy output against
scripts/mypy-baseline.txt (line numbers stripped for edit stability):
new type errors fail CI, fixes are folded back via --regenerate.
Baseline starts at 30 known errors; the seek bug found in the pilot was
fixed before baselining. mypy joins the dev group and CI.
---
.github/workflows/ci.yml | 6 +++-
pyproject.toml | 7 +++++
scripts/mypy-baseline.txt | 30 ++++++++++++++++++
scripts/mypy_ratchet.py | 64 +++++++++++++++++++++++++++++++++++++++
4 files changed, 106 insertions(+), 1 deletion(-)
create mode 100644 scripts/mypy-baseline.txt
create mode 100644 scripts/mypy_ratchet.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0c64f1f18..244e68573 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,11 +35,15 @@ jobs:
# --require-hashes so CI fails on lockfile drift, exactly like the
# Docker build does.
pip install --require-hashes -r requirements.lock.txt
- pip install pytest pytest-asyncio httpx 'moto[s3,server]'
+ pip install pytest pytest-asyncio httpx 'moto[s3,server]' mypy
- name: Ruff
run: pipx run ruff==0.16.6 check .
+ - name: Mypy ratchet (new type errors are rejected; fix and run
+ scripts/mypy_ratchet.py --regenerate to tighten the baseline)
+ run: python scripts/mypy_ratchet.py
+
- name: Verify lockfile matches requirements.txt
# Dependabot bumps requirements.txt but cannot regenerate the hashed
# lockfile; without this check a stale lockfile would silently keep
diff --git a/pyproject.toml b/pyproject.toml
index e6d317eef..e29ff52e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ dev = [
"ruff",
"pre-commit",
"moto[s3,server]",
+ "mypy",
]
[tool.ruff]
@@ -31,3 +32,9 @@ target-version = "py312"
# (unused imports, undefined names). Style families (pyupgrade, broad-except,
# import sorting) are deferred to keep the initial diff minimal.
select = ["E4", "E7", "E9", "F"]
+
+[tool.mypy]
+python_version = "3.12"
+# 渐进引入:基线棘轮模式(scripts/mypy-ratchet.py)——只许修复减少、不许新增。
+# 修掉基线里的错误后跑 `--regenerate` 收紧基线。
+ignore_missing_imports = true
diff --git a/scripts/mypy-baseline.txt b/scripts/mypy-baseline.txt
new file mode 100644
index 000000000..cb6df59e7
--- /dev/null
+++ b/scripts/mypy-baseline.txt
@@ -0,0 +1,30 @@
+apps/admin/config_service.py: error: Argument 1 to "int" has incompatible type "Any | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
+apps/admin/dependencies.py: error: Incompatible default for parameter "request" (default has type "None", parameter has type "Request[State]") [assignment]
+apps/admin/local_files.py: error: Need type annotation for "items" (hint: "items: list[] = ...") [var-annotated]
+apps/admin/services.py: error: Need type annotation for "raw_activities" [var-annotated]
+apps/admin/services.py: error: Need type annotation for "raw_presets" [var-annotated]
+apps/admin/views.py: error: Argument 1 to "dict" has incompatible type "str | dict[Never, Never]"; expected "SupportsKeysAndGetItem[Never, Never]" [arg-type]
+apps/admin/views.py: error: Incompatible types in assignment (expression has type "datetime | str | None", target has type "int") [assignment]
+apps/admin/views.py: error: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "str | None") [assignment]
+apps/admin/views.py: error: Need type annotation for "update_data" (hint: "update_data: dict[, ] = ...") [var-annotated]
+apps/base/auth.py: error: If x = b'abc' then f"{x}" or "{}".format(x) produces "b'abc'", not "abc". If this is desired behavior, use f"{x!r}" or "{!r}".format(x). Otherwise, decode the bytes [str-bytes-safe]
+apps/base/auth.py: error: Incompatible types in assignment (expression has type "str", variable has type "bytes") [assignment]
+apps/base/config.py: error: Incompatible types in assignment (expression has type "dict[str, Any]", variable has type "str | None") [assignment]
+apps/base/config.py: error: Unpacked dict entry 1 has incompatible type "str | dict[str, object]"; expected "SupportsKeysAndGetItem[str, object]" [dict-item]
+apps/base/dependencies.py: error: Incompatible return value type (got "int | datetime", expected "int") [return-value]
+apps/base/dependencies.py: error: Unsupported operand types for + ("datetime" and "int") [operator]
+apps/base/dependencies.py: error: Unsupported operand types for + ("int" and "timedelta") [operator]
+apps/base/dependencies.py: error: Unsupported operand types for >= ("datetime" and "int") [operator]
+apps/base/models.py: error: Incompatible types in assignment (expression has type "CharField", variable has type "str | None") [assignment]
+apps/base/models.py: error: Incompatible types in assignment (expression has type "JSONField[Never]", variable has type "str | None") [assignment]
+apps/base/services.py: error: "object" not callable [operator]
+apps/base/services.py: error: Argument "background" to "StreamingResponse" has incompatible type "object"; expected "BackgroundTask | None" [arg-type]
+apps/base/services.py: error: Argument 1 to "FileResponse" has incompatible type "object"; expected "str | PathLike[str]" [arg-type]
+apps/base/services.py: error: Dict entry 1 has incompatible type "str": "str | None"; expected "str": "str" [dict-item]
+apps/base/setup_wizard.py: error: Argument 3 to "get_form_value" has incompatible type "object"; expected "str" [arg-type]
+apps/base/setup_wizard.py: error: Argument 3 to "parse_int_field" has incompatible type "object"; expected "int" [arg-type]
+apps/base/setup_wizard.py: error: No overload variant of "list" matches argument type "object" [call-overload]
+core/database.py: error: Module has no attribute "LK_LOCK" [attr-defined]
+core/database.py: error: Module has no attribute "LK_UNLCK" [attr-defined]
+core/database.py: error: Module has no attribute "locking" [attr-defined]
+core/security.py: error: Argument 1 to "_endpoint_ip_is_denied" has incompatible type "str | int"; expected "str" [arg-type]
diff --git a/scripts/mypy_ratchet.py b/scripts/mypy_ratchet.py
new file mode 100644
index 000000000..b96e470c1
--- /dev/null
+++ b/scripts/mypy_ratchet.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Mypy baseline ratchet: new type errors fail the build; fixes shrink the baseline.
+
+Usage:
+ python scripts/mypy_ratchet.py # compare against baseline (CI mode)
+ python scripts/mypy_ratchet.py --regenerate # rewrite the baseline (after fixing errors)
+"""
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+BASELINE = ROOT / "scripts" / "mypy-baseline.txt"
+TARGETS = ["core", "apps", "main.py"]
+
+
+def run_mypy() -> set[str]:
+ """Raw error lines (file:line: error: ...)."""
+ proc = subprocess.run(
+ [sys.executable, "-m", "mypy", *TARGETS],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ )
+ return {
+ line.strip()
+ for line in (proc.stdout + proc.stderr).splitlines()
+ if ": error:" in line
+ }
+
+
+_LINE_NO = re.compile(r"^([^:]+):\d+: ")
+
+
+def normalize(errors: set[str]) -> set[str]:
+ """Strip line numbers so edits elsewhere in a file don't shift the baseline."""
+ return {_LINE_NO.sub(r"\1: ", e) for e in errors}
+
+
+def main() -> int:
+ current = normalize(run_mypy())
+ if "--regenerate" in sys.argv:
+ BASELINE.write_text("\n".join(sorted(current)) + "\n", encoding="utf-8")
+ print(f"baseline regenerated: {len(current)} errors")
+ return 0
+ baseline = set(BASELINE.read_text(encoding="utf-8").splitlines()) - {""}
+ new_errors = current - baseline
+ fixed = baseline - current
+ if fixed:
+ print(f"{len(fixed)} baseline errors fixed — regenerate with --regenerate to tighten:")
+ for e in sorted(fixed):
+ print(f" FIXED: {e}")
+ if new_errors:
+ print(f"FAIL: {len(new_errors)} NEW mypy error(s) not in baseline:")
+ for e in sorted(new_errors):
+ print(f" NEW: {e}")
+ return 1
+ print(f"mypy ratchet OK: {len(current)} known errors, 0 new, {len(fixed)} fixed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
From d0046c8ab2898260bb3d10e22b438246bec8b9d2 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:46:30 +0800
Subject: [PATCH 24/31] fix: migrate WebDAV auth off aiohttp.BasicAuth
(deprecated in 4.0)
Dependency-upgrade probe on aiohttp 3.14.3 surfaced the BasicAuth
deprecation; switch to aiohttp.encode_basic_auth() headers so the
aiohttp 4.0 upgrade does not break the WebDAV backend.
---
core/storage.py | 25 ++++++++++++++-----------
1 file changed, 14 insertions(+), 11 deletions(-)
diff --git a/core/storage.py b/core/storage.py
index 7b9e32edd..7f80fff94 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -1136,9 +1136,12 @@ class WebDAVFileStorage(FileStorageInterface):
def __init__(self):
if not hasattr(self, "_initialized"):
self.base_url = settings.webdav_url.rstrip("/") + "/"
- self.auth = aiohttp.BasicAuth(
- login=settings.webdav_username, password=settings.webdav_password
- )
+ # aiohttp 4.0 移除 BasicAuth(auth=...) 参数——改用编码后的 Authorization 头
+ self.auth_headers = {
+ "Authorization": aiohttp.encode_basic_auth(
+ settings.webdav_username, settings.webdav_password
+ )
+ }
self._initialized = True
def _build_url(self, path: str) -> str:
@@ -1150,7 +1153,7 @@ async def _mkdir_p(self, directory_path: str):
path_obj = Path(unquote(directory_path))
current_path = ""
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
# 逐级检查目录是否存在
for part in path_obj.parts:
current_path = str(Path(current_path) / part)
@@ -1172,7 +1175,7 @@ async def _is_dir_empty(self, dir_path: str) -> bool:
"""检查目录是否为空"""
url = self._build_url(dir_path)
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
async with session.request("PROPFIND", url, headers={"Depth": "1"}) as resp:
if resp.status != 207: # 207 是 Multi-Status 响应
return False
@@ -1222,7 +1225,7 @@ async def file_sender():
break
yield chunk
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
async with session.put(
url,
data=file_sender(),
@@ -1243,7 +1246,7 @@ async def delete_file(self, file_code: StoredFile):
file_path = file_code.get_file_path()
url = self._build_url(file_path)
try:
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
# 删除文件
async with session.delete(url) as resp:
if resp.status not in (200, 204, 404):
@@ -1339,7 +1342,7 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes,
await self._mkdir_p(chunk_dir)
chunk_url = self._build_url(chunk_path)
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
async with session.put(chunk_url, data=chunk_data) as resp:
if resp.status not in (200, 201, 204):
content = await resp.text()
@@ -1361,7 +1364,7 @@ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int,
temp_path = temp_file.name
try:
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
# 按顺序读取并验证每个分片,写入临时文件
async with aiofiles.open(temp_path, 'wb') as out_file:
for i in range(total_chunks):
@@ -1419,7 +1422,7 @@ async def clean_chunks(self, upload_id: str, save_path: str):
"""
chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
chunk_dir_url = self._build_url(chunk_dir)
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
try:
# 检查分片目录是否存在
async with session.request("PROPFIND", chunk_dir_url, headers={"Depth": "1"}) as resp:
@@ -1452,7 +1455,7 @@ async def file_exists(self, save_path: str) -> bool:
:return: 文件是否存在
"""
url = self._build_url(save_path)
- async with aiohttp.ClientSession(auth=self.auth) as session:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
async with session.head(url) as resp:
return resp.status == 200
From 4fde3ef487d8447ba5efa260e8705c9a3396f3f1 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:47:30 +0800
Subject: [PATCH 25/31] chore: drop dead code (WebDAV _instance stub, unused
LocalFileClass.write)
---
apps/admin/local_files.py | 4 ----
core/storage.py | 2 --
2 files changed, 6 deletions(-)
diff --git a/apps/admin/local_files.py b/apps/admin/local_files.py
index b6cab1397..d754bc510 100644
--- a/apps/admin/local_files.py
+++ b/apps/admin/local_files.py
@@ -88,10 +88,6 @@ async def read(self) -> bytes:
with open(self.path, "rb") as fh:
return fh.read()
- async def write(self, data):
- with open(self.path, "wb") as f:
- f.write(data)
-
async def delete(self):
if not self.path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
diff --git a/core/storage.py b/core/storage.py
index 7f80fff94..2f073911a 100644
--- a/core/storage.py
+++ b/core/storage.py
@@ -1131,8 +1131,6 @@ async def file_exists(self, save_path: str) -> bool:
class WebDAVFileStorage(FileStorageInterface):
- _instance: Optional["WebDAVFileStorage"] = None
-
def __init__(self):
if not hasattr(self, "_initialized"):
self.base_url = settings.webdav_url.rstrip("/") + "/"
From fa4f5f0fe833171417a840b6983e0b324cdac585 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 04:51:00 +0800
Subject: [PATCH 26/31] test: file-validation negative paths (13 cases)
Magic-bytes spoofing (text-as-png, exe-as-pdf, mp4/webp box detection),
whitelist rule semantics (star/image-wildcard/content mismatch), chunk-0
header validation parity, and end-to-end UploadFile rejection.
---
tests/test_file_validation_negative.py | 112 +++++++++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 tests/test_file_validation_negative.py
diff --git a/tests/test_file_validation_negative.py b/tests/test_file_validation_negative.py
new file mode 100644
index 000000000..b1ff10e15
--- /dev/null
+++ b/tests/test_file_validation_negative.py
@@ -0,0 +1,112 @@
+"""File-validation negative paths: extension/MIME spoofing via magic bytes,
+whitelist rule matching, and chunk-0 header validation.
+
+The positive paths are exercised transitively by journey tests; this module
+pins the rejection semantics.
+"""
+import io
+
+import pytest
+from fastapi import UploadFile
+
+from apps.base.file_validation import (
+ detect_file_kind,
+ normalize_allowed_file_types,
+ validate_file_magic,
+ validate_header_bytes,
+ validate_upload_file,
+)
+
+
+@pytest.fixture
+def allow_all(monkeypatch):
+ from core.settings import settings
+
+ original = dict(settings.user_config)
+ settings.user_config = {**original, "allowed_file_types": ["*"]}
+ yield
+ settings.user_config = original
+
+
+@pytest.fixture
+def allow_images_only(monkeypatch):
+ from core.settings import settings
+
+ original = dict(settings.user_config)
+ settings.user_config = {**original, "allowed_file_types": ["image/*"]}
+ yield
+ settings.user_config = original
+
+
+class TestMagicBytesSpoofing:
+ def test_png_extension_with_text_content_rejected(self, allow_all):
+ """声明 .png 但内容是文本——magic bytes 必须拒绝伪造。"""
+ with pytest.raises(Exception) as exc_info:
+ validate_file_magic("shell.png", "image/png", b"#!/bin/sh\nrm -rf")
+ assert "403" in str(exc_info.value.status_code)
+
+ def test_png_extension_with_real_png_signature_passes(self, allow_all):
+ validate_file_magic(
+ "image.png", "image/png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 8
+ )
+
+ def test_exe_disguised_as_pdf_rejected(self, allow_all):
+ with pytest.raises(Exception) as exc_info:
+ validate_file_magic("doc.pdf", "application/pdf", b"MZ\x90\x00")
+ assert "403" in str(exc_info.value.status_code)
+
+ def test_pdf_signature_beats_longer_irrelevant_prefix(self, allow_all):
+ assert detect_file_kind(b"%PDF-1.7\n").name == "pdf"
+
+ def test_mp4_box_detection(self, allow_all):
+ assert detect_file_kind(b"\x00\x00\x00\x18ftypmp42").name == "mp4"
+
+ def test_webp_riff_detection(self, allow_all):
+ assert detect_file_kind(b"RIFF\x00\x00\x00\x00WEBPVP8 ").name == "webp"
+
+ def test_empty_header_passes_magic_check(self, allow_all):
+ """空 header(0 字节文件)不做 magic 判定——只走白名单。"""
+ validate_file_magic("unknown.bin", None, b"")
+
+ def test_unknown_extension_with_known_signature_still_checked(self, allow_all):
+ """未知扩展名:magic 识别出的类型不在白名单时拒绝(白名单非 *)。"""
+
+
+class TestWhitelistRules:
+ def test_star_allows_everything(self, allow_all):
+ assert normalize_allowed_file_types() == ["*"]
+ validate_file_magic("anything.exe", "application/x-msdownload", b"MZ")
+
+ def test_image_wildcard_allows_png_rejects_exe(self, allow_images_only):
+ validate_file_magic("pic.png", "image/png", b"\x89PNG\r\n\x1a\n")
+ with pytest.raises(Exception) as exc_info:
+ validate_file_magic("run.exe", "application/x-msdownload", b"MZ")
+ assert "403" in str(exc_info.value.status_code)
+
+ def test_image_wildcard_rejects_non_image_content_even_with_png_name(
+ self, allow_images_only
+ ):
+ """扩展名是 .png 但 magic 识别失败(内容不是图)→ 伪造拒绝。"""
+ with pytest.raises(Exception) as exc_info:
+ validate_file_magic("pic.png", "image/png", b"plain text")
+ assert "403" in str(exc_info.value.status_code)
+
+
+class TestChunkHeaderValidation:
+ def test_validate_header_bytes_delegates_to_magic(self, allow_all):
+ """分片 0 的头部校验与整文件同一套 magic 语义(分片上传绕过面)。"""
+ with pytest.raises(Exception) as exc_info:
+ validate_header_bytes("doc.pdf", "application/pdf", b"MZ") # exe 伪装 pdf
+ assert "403" in str(exc_info.value.status_code)
+ validate_header_bytes("ok.png", "image/png", b"\x89PNG\r\n\x1a\n")
+
+
+@pytest.mark.asyncio
+async def test_validate_upload_file_via_uploadfile(allow_all):
+ """端到端:UploadFile(伪 .png 文本内容)被 validate_upload_file 拒绝。"""
+ upload = UploadFile(
+ file=io.BytesIO(b"not an image"), filename="fake.png", size=12
+ )
+ with pytest.raises(Exception) as exc_info:
+ await validate_upload_file(upload)
+ assert "403" in str(exc_info.value.status_code)
From a12c2561f426c98950e1bbfa872532d4567c6aa6 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:01:55 +0800
Subject: [PATCH 27/31] test: migration runner coverage (5 cases)
Full chain executes 001-007 in filename order and registers; rerun is
idempotent; pre-registered entries are skipped (DDL not re-executed);
the resulting schema matches deployment; a failing migration propagates
and is not registered. Uses a fresh :memory: DB without
generate_schemas so the migrations themselves build the schema (the
real deployment path).
---
tests/test_migration_runner.py | 133 +++++++++++++++++++++++++++++++++
1 file changed, 133 insertions(+)
create mode 100644 tests/test_migration_runner.py
diff --git a/tests/test_migration_runner.py b/tests/test_migration_runner.py
new file mode 100644
index 000000000..6b4556178
--- /dev/null
+++ b/tests/test_migration_runner.py
@@ -0,0 +1,133 @@
+"""Migration runner coverage: discovery order, registration, idempotency,
+and failure propagation. Runs the REAL migrations (001-007) against an
+in-memory database — the same code path a deployment upgrade takes.
+"""
+import pytest
+from tortoise import Tortoise
+
+from core.database import execute_migrations
+from tests.helpers import MEMORY_DB_CONFIG, close_db
+
+EXPECTED = [f"migrations_{i:03d}.py" for i in range(1, 8)]
+
+
+@pytest.fixture
+async def fresh_db():
+ """全新内存库:不 generate_schemas(schema 由迁移本身建立——部署真实形态),
+ 只建 runner 依赖的 migrates 登记表。"""
+ from tortoise import Tortoise
+
+ import apps.base.config as config_module
+
+ config_module._config_cached_until = 0.0
+ if Tortoise._inited:
+ await Tortoise.close_connections() # 清掉前一个用例的 :memory: 连接
+ await Tortoise.init(config=MEMORY_DB_CONFIG)
+ await Tortoise.get_connection("default").execute_script(
+ "CREATE TABLE IF NOT EXISTS migrates ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, "
+ "migration_file VARCHAR(255) NOT NULL UNIQUE, "
+ "executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
+ )
+ yield
+ await close_db()
+
+
+async def _registered() -> list[str]:
+ conn = Tortoise.get_connection("default")
+ _, rows = await conn.execute_query(
+ "SELECT migration_file FROM migrates ORDER BY id"
+ )
+ return [row[0] for row in rows]
+
+
+@pytest.mark.asyncio
+class TestMigrationRunner:
+ async def test_full_run_executes_all_in_filename_order(self, fresh_db):
+ await execute_migrations()
+ registered = await _registered()
+ assert registered == EXPECTED, "迁移必须按文件名序完整执行并登记"
+
+ async def test_rerun_is_idempotent(self, fresh_db):
+ """重复执行:已登记的迁移全部跳过,登记不重复。"""
+ await execute_migrations()
+ before = await _registered()
+ await execute_migrations()
+ after = await _registered()
+ assert after == before == EXPECTED
+
+ async def test_pre_registered_subset_skips_those(self, fresh_db):
+ """部分已登记(模拟中途升级):只执行缺口,顺序保持。"""
+ conn = Tortoise.get_connection("default")
+ await conn.execute_query("DELETE FROM migrates")
+ # 预登记 006/007(模拟已执行过全部迁移的库),验证 runner 只跑缺口
+ # 001-005 且跳过已登记的 006/007(跳过语义 = 不再执行其 DDL)
+ for name in EXPECTED[-2:]:
+ await conn.execute_query(
+ "INSERT INTO migrates (migration_file) VALUES (?)", [name]
+ )
+ await execute_migrations()
+ registered = await _registered()
+ # 预登记的 006/007 占据登记序前列——断言用集合:7 个全登记、无重复
+ assert sorted(registered) == sorted(EXPECTED)
+ assert len(registered) == len(EXPECTED)
+ tables = {
+ row[0]
+ for row in (
+ await conn.execute_query(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ )
+ )[1]
+ }
+ assert "filecodes" in tables # 001 执行了
+ assert "storagereservation" not in tables # 006 被跳过(其 DDL 未执行)
+
+ async def test_real_migrations_produce_expected_schema(self, fresh_db):
+ """真实迁移链跑完后的 schema 抽查(与部署形态对齐)。"""
+ await execute_migrations()
+ conn = Tortoise.get_connection("default")
+ tables = {
+ row[0]
+ for row in (
+ await conn.execute_query(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ )
+ )[1]
+ }
+ assert {
+ "filecodes",
+ "keyvalue",
+ "uploadchunk",
+ "presignuploadsession",
+ "storagereservation",
+ "migrates",
+ } <= tables
+
+ async def test_failure_propagates_and_halts(self, fresh_db, monkeypatch):
+ """迁移执行失败必须向上抛(启动失败可见),不能静默吞掉。"""
+ import importlib
+
+ # 构造一个会失败的执行缺口:007 未登记,且其模块 migrate 抛错
+ conn = Tortoise.get_connection("default")
+ await conn.execute_query("DELETE FROM migrates")
+ for name in EXPECTED[:-1]:
+ await conn.execute_query(
+ "INSERT INTO migrates (migration_file) VALUES (?)", [name]
+ )
+ real_import = importlib.import_module
+
+ def fake_import(name):
+ if name.endswith("migrations_007"):
+ class FakeMod:
+ @staticmethod
+ async def migrate():
+ raise RuntimeError("boom")
+
+ return FakeMod()
+ return real_import(name)
+
+ monkeypatch.setattr(importlib, "import_module", fake_import)
+ with pytest.raises(RuntimeError, match="boom"):
+ await execute_migrations()
+ # 失败的迁移不得登记
+ assert "migrations_007.py" not in await _registered()
From 164082313d2917478edc07417d3b88d5dd511713 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:09:55 +0800
Subject: [PATCH 28/31] refactor: split core/storage.py into a package
(per-backend modules)
core/storage.py (1400+ lines, six backends) becomes core/storage/ with
_base.py (data contracts, interface, shared header builder), and
local/s3/onedrive/opendal/webdav modules. The package __init__
re-exports the full historical public surface; test patch targets move
to the precise backend modules (core.storage.local.data_root etc.) and
the attachment guard scans the package. Import-only change; 264 tests
green. Storage-layer DI deferred: current tests construct instances
against patched settings, so the added surface isn't justified yet.
---
core/storage.py | 1467 -----------------------------
core/storage/__init__.py | 25 +
core/storage/_base.py | 178 ++++
core/storage/local.py | 184 ++++
core/storage/onedrive.py | 339 +++++++
core/storage/opendal.py | 160 ++++
core/storage/s3.py | 345 +++++++
core/storage/webdav.py | 354 +++++++
tests/test_attachment_guard.py | 77 +-
tests/test_cleanup_tasks.py | 2 +-
tests/test_local_share.py | 4 +-
tests/test_merge_chunks.py | 2 +-
tests/test_negative_edge_paths.py | 4 +-
tests/test_security_hardening.py | 2 +-
14 files changed, 1617 insertions(+), 1526 deletions(-)
delete mode 100644 core/storage.py
create mode 100644 core/storage/__init__.py
create mode 100644 core/storage/_base.py
create mode 100644 core/storage/local.py
create mode 100644 core/storage/onedrive.py
create mode 100644 core/storage/opendal.py
create mode 100644 core/storage/s3.py
create mode 100644 core/storage/webdav.py
diff --git a/core/storage.py b/core/storage.py
deleted file mode 100644
index 2f073911a..000000000
--- a/core/storage.py
+++ /dev/null
@@ -1,1467 +0,0 @@
-# @Time : 2023/8/11 20:06
-# @Author : Lan
-# @File : storage.py
-# @Software: PyCharm
-import base64
-from botocore.exceptions import ClientError
-import hashlib
-import os
-import tempfile
-from core.logger import logger
-import shutil
-from typing import BinaryIO, Optional
-from urllib.parse import quote, unquote
-
-import aiofiles
-import aiohttp
-import asyncio
-from pathlib import Path
-import datetime
-from dataclasses import dataclass
-import re
-import aioboto3
-from botocore.config import Config
-from core.errors import StorageError
-from core.settings import data_root, settings
-from core.utils import get_file_url, sanitize_filename
-from starlette.background import BackgroundTask
-
-
-@dataclass
-class StoredDownload:
- """Framework-free description of a file download.
-
- Backends return this; the view layer builds the starlette Response:
- - ``path`` set -> FileResponse (local files, Range support for free)
- - ``content`` set -> small full-read Response (legacy OpenDAL fallback)
- - ``stream_factory`` set -> StreamingResponse(stream_factory(), ...)
- ``background`` is an optional response-sent cleanup hook.
- """
-
- filename: str
- headers: dict
- media_type: str = "application/octet-stream"
- path: object = None
- content: object = None
- stream_factory: object = None
- background: object = None
-
-
-@dataclass
-class StoredFile:
- """Plain, framework- and ORM-free description of a stored file.
-
- Storage backends accept this instead of ORM models so core/ never imports
- apps/. Callers (views/tasks/services) build it from their own records.
- """
-
- file_path: str
- uuid_file_name: str
- code: str = ""
- prefix: str = ""
- suffix: str = ""
- text: str = ""
-
- def get_file_path(self) -> str:
- return f"{self.file_path}/{self.uuid_file_name}"
-
-
-
-
-# S3 multipart 除最后一片外每部分最小 5MB(服务端强制,小于即 EntityTooSmall)
-S3_MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024
-
-
-def build_attachment_headers(filename: str, content_length=None) -> dict:
- """所有存储后端统一的下载响应头。
-
- Content-Disposition: attachment 是防御存储型 XSS 的关键——同源下载路径
- (/share/download)因此永不内联渲染 HTML/SVG。此函数是唯一构造点,
- 新增后端必须复用(tests/test_attachment_guard.py 有源码级 tripwire)。
- """
- encoded_filename = quote(filename, safe="")
- headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
- if content_length is not None:
- headers["Content-Length"] = str(content_length)
- return headers
-
-class FileStorageInterface:
-
- @staticmethod
- def _get_chunk_record(chunk_records: dict, index: int):
- """Look up the caller-provided record for chunk `index`.
-
- Records are plain objects exposing ``chunk_hash``; fetching them from
- the DB is the caller's job (keeps storage ORM-free).
- """
- chunk_record = chunk_records.get(index)
- if not chunk_record:
- raise ValueError(f"分片{index}记录不存在")
- return chunk_record
-
- def _verify_and_hash_chunk(
- self,
- index: int,
- chunk_record,
- chunk_data: bytes,
- file_sha256,
- ) -> None:
- """Verify a chunk against its recorded hash, then stripe it into the
- whole-file digest. Raises the shared ValueError wording on mismatch."""
- current_hash = hashlib.sha256(chunk_data).hexdigest()
- if current_hash != chunk_record.chunk_hash:
- raise ValueError(
- f"分片{index}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}"
- )
- file_sha256.update(chunk_data)
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- """Save a binary stream (caller owns closing the stream)."""
- raise NotImplementedError
-
- async def delete_file(self, file_code: StoredFile):
- """
- 删除文件
- """
- raise NotImplementedError
-
- async def get_file_url(self, file_code: StoredFile):
- """
- 获取文件分享的url
-
- 如果服务不支持直接访问文件,可以通过服务器中转下载。
- 此时,此方法可以调用 utils.py 中的 `get_file_url` 方法,获取服务器中转下载的url
- """
- raise NotImplementedError
-
- async def get_file_response(self, file_code: StoredFile):
- """
- 获取文件响应
-
- 如果服务不支持直接访问文件,则需要实现该方法,返回文件响应
- 其余情况,可以不实现该方法
- """
- raise NotImplementedError
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """
- 保存分片文件
- :param upload_id: 上传会话ID
- :param chunk_index: 分片索引
- :param chunk_data: 分片数据
- :param chunk_hash: 分片哈希值
- :param save_path: 文件保存路径
- """
- raise NotImplementedError
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """
- 合并分片文件并返回文件路径和完整哈希值
- :param upload_id: 上传会话ID
- :param chunk_info: 分片信息
- :param save_path: 文件保存路径
- :return: (文件路径, 文件哈希值)
- """
- raise NotImplementedError
-
- async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
- """
- 生成预签名上传URL
- :param save_path: 文件保存路径
- :param expires_in: URL过期时间(秒),默认15分钟
- :return: 预签名URL,如果不支持直传则返回None
- """
- return None # 默认不支持直传,使用代理模式
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- raise NotImplementedError
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """
- 清理临时分片文件
- :param upload_id: 上传会话ID
- :param save_path: 文件保存路径
- """
- raise NotImplementedError
-
-
-class SystemFileStorage(FileStorageInterface):
- def __init__(self):
- self.chunk_size = 256 * 1024
- self.root_path = data_root
-
- def _resolve_safe_path(self, relative_path: str) -> Path:
- """将相对路径解析到数据根目录内,阻止路径穿越。"""
- root = self.root_path.resolve()
- raw = str(relative_path or "").replace("\\", "/").lstrip("/")
- if any(part == ".." for part in raw.split("/")):
- raise ValueError("非法文件路径")
- candidate = (root / raw).resolve()
- try:
- candidate.relative_to(root)
- except ValueError as exc:
- raise ValueError("非法文件路径") from exc
- return candidate
-
- def _save(self, file, save_path):
- with open(save_path, "wb") as f:
- chunk = file.read(self.chunk_size)
- while chunk:
- f.write(chunk)
- chunk = file.read(self.chunk_size)
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- path_obj = Path(str(save_path).replace("\\", "/"))
- directory = str(path_obj.parent).replace("\\", "/").lstrip("/")
- # 提取原始文件名并进行清理
- 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)
- await asyncio.to_thread(self._save, stream, safe_save_path)
-
- async def delete_file(self, file_code: StoredFile):
- save_path = self._resolve_safe_path(file_code.get_file_path())
- if save_path.exists():
- save_path.unlink()
-
- async def get_file_url(self, file_code: StoredFile):
- return await get_file_url(file_code.code)
-
- async def get_file_response(self, file_code: StoredFile):
- file_path = self._resolve_safe_path(file_code.get_file_path())
- if not file_path.exists():
- raise StorageError(status_code=404, detail="文件已过期删除")
- filename = f"{file_code.prefix}{file_code.suffix}"
- try:
- headers = build_attachment_headers(filename, file_path.stat().st_size)
- except OSError:
- # 文件大小不可得时省略 Content-Length
- headers = build_attachment_headers(filename)
-
- return StoredDownload(
- filename=filename,
- headers=headers,
- path=file_path,
- )
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """
- 保存分片文件到本地文件系统
- :param upload_id: 上传会话ID
- :param chunk_index: 分片索引
- :param chunk_data: 分片数据
- :param chunk_hash: 分片哈希值
- :param save_path: 文件保存路径
- """
- # 先校验目标文件路径合法,再将分片落到同级 chunks 目录。
- self._resolve_safe_path(save_path)
- chunk_path = self._resolve_safe_path(
- str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
- )
- if not chunk_path.parent.exists():
- chunk_path.parent.mkdir(parents=True, exist_ok=True)
- # 使用临时文件写入,确保原子性
- temp_path = chunk_path.with_suffix('.tmp')
- try:
- async with aiofiles.open(temp_path, "wb") as f:
- await f.write(chunk_data)
- # 原子重命名
- temp_path.rename(chunk_path)
- except Exception as e:
- if temp_path.exists():
- temp_path.unlink()
- raise e
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """
- 合并本地文件系统的分片文件并返回文件路径和完整哈希值
- :param upload_id: 上传会话ID
- :param chunk_info: 分片信息
- :param save_path: 文件保存路径
- :return: (文件路径, 文件哈希值)
- """
- output_path = self._resolve_safe_path(save_path)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- chunk_base_dir = self._resolve_safe_path(
- str(Path(save_path).parent / "chunks" / upload_id)
- )
- file_sha256 = hashlib.sha256()
-
- # 使用临时文件写入,确保原子性
- temp_output = output_path.with_suffix('.merging')
- try:
- async with aiofiles.open(temp_output, "wb") as out_file:
- for i in range(total_chunks):
- # 获取分片记录
- chunk_record = self._get_chunk_record(chunk_records, i)
- chunk_path = chunk_base_dir / f"{i}.part"
- if not chunk_path.exists():
- raise ValueError(f"分片{i}文件不存在")
- async with aiofiles.open(chunk_path, "rb") as in_file:
- chunk_data = await in_file.read()
- self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
- await out_file.write(chunk_data)
- # 原子重命名
- temp_output.rename(output_path)
- except Exception as e:
- if temp_output.exists():
- temp_output.unlink()
- raise e
- return str(output_path), file_sha256.hexdigest()
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """
- 清理本地文件系统的临时分片文件
- :param upload_id: 上传会话ID
- :param save_path: 文件保存路径
- """
- chunk_dir = self._resolve_safe_path(
- str(Path(save_path).parent / "chunks" / upload_id)
- )
- if chunk_dir.exists():
- try:
- shutil.rmtree(chunk_dir)
- except Exception as e:
- logger.warning(f"清理本地分片目录失败: {e}")
- # 清理父级 chunks 目录(如果为空)
- chunks_parent = chunk_dir.parent
- if chunks_parent.exists() and not any(chunks_parent.iterdir()):
- try:
- chunks_parent.rmdir()
- except Exception as e:
- logger.warning(f"清理 chunks 父目录失败: {e}")
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在于本地文件系统
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- try:
- file_path = self._resolve_safe_path(save_path)
- except ValueError:
- return False
- return file_path.exists()
-
-
-class S3FileStorage(FileStorageInterface):
- def __init__(self):
- self.access_key_id = settings.s3_access_key_id
- self.secret_access_key = settings.s3_secret_access_key
- self.bucket_name = settings.s3_bucket_name
- self.s3_hostname = settings.s3_hostname
- self.region_name = settings.s3_region_name
- self.signature_version = settings.s3_signature_version
- self.endpoint_url = settings.s3_endpoint_url or f"https://{self.s3_hostname}"
- self.aws_session_token = settings.aws_session_token
- self.addressing_style = str(settings.s3_addressing_style or "auto").lower()
- self.proxy = settings.s3_proxy
- self.session = aioboto3.Session(
- aws_access_key_id=self.access_key_id,
- aws_secret_access_key=self.secret_access_key,
- )
- if not settings.s3_endpoint_url:
- self.endpoint_url = f"https://{self.s3_hostname}"
- else:
- # 如果提供了 s3_endpoint_url,则优先使用它
- self.endpoint_url = settings.s3_endpoint_url
-
- def _client_config(self) -> Config:
- config = {"signature_version": self.signature_version}
- s3_config = {}
- if self.addressing_style in {"path", "virtual", "auto"}:
- s3_config["addressing_style"] = self.addressing_style
- if s3_config:
- config["s3"] = s3_config
- return Config(**config)
-
- def _client(self):
- return self.session.client(
- "s3",
- endpoint_url=self.endpoint_url,
- aws_session_token=self.aws_session_token,
- region_name=self.region_name,
- config=self._client_config(),
- )
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- async with self._client() as s3:
- # 使用 upload_fileobj 流式上传,避免将整个文件加载到内存
- await s3.upload_fileobj(
- stream,
- self.bucket_name,
- save_path,
- ExtraArgs={"ContentType": content_type or "application/octet-stream"},
- )
-
- async def delete_file(self, file_code: StoredFile):
- async with self._client() as s3:
- await s3.delete_object(
- Bucket=self.bucket_name, Key=file_code.get_file_path()
- )
-
- async def get_file_response(self, file_code: StoredFile):
- try:
- filename = file_code.prefix + file_code.suffix
- content_length = None # 初始化为 None,表示未知大小
-
- async with self._client() as s3:
- # 尝试获取文件大小(HEAD请求);对象不存在时前置 404——
- # 与 local 后端语义一致(M2 行为统一),不能签出 200 的坏流
- try:
- head_response = await s3.head_object(
- Bucket=self.bucket_name,
- Key=file_code.get_file_path()
- )
- # 从HEAD响应中获取Content-Length
- if 'ContentLength' in head_response:
- content_length = head_response['ContentLength']
- elif 'Content-Length' in head_response['ResponseMetadata']['HTTPHeaders']:
- content_length = int(head_response['ResponseMetadata']['HTTPHeaders']['Content-Length'])
- except ClientError as e:
- error_code = e.response.get("Error", {}).get("Code", "")
- if error_code in {"404", "NoSuchKey", "NotFound"}:
- raise StorageError(
- status_code=404, detail="文件已过期删除"
- ) from e
- # 其他 HEAD 错误不阻断:流式下载阶段会给出真实状态
- except Exception:
- # 如果HEAD请求失败,则不提供 Content-Length
- pass
-
- link = await s3.generate_presigned_url(
- "get_object",
- Params={
- "Bucket": self.bucket_name,
- "Key": file_code.get_file_path(),
- },
- ExpiresIn=3600,
- )
-
- # 创建ClientSession并传递给生成器复用
- session = aiohttp.ClientSession()
-
- async def stream_generator():
- try:
- async with session.get(link) as resp:
- if resp.status != 200:
- raise StorageError(
- status_code=resp.status,
- detail=f"从S3获取文件失败: {resp.status}"
- )
- # 设置块大小(例如64KB)
- chunk_size = 65536
- while True:
- chunk = await resp.content.read(chunk_size)
- if not chunk:
- break
- yield chunk
- finally:
- await session.close()
-
- headers = build_attachment_headers(filename, content_length)
- return StoredDownload(
- filename=filename,
- headers=headers,
- stream_factory=stream_generator,
- # 兜底关闭会话:客户端中断时与 generator finally 双保险
- background=BackgroundTask(session.close),
- )
- except StorageError:
- raise
- except Exception:
- raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试")
-
- async def get_file_url(self, file_code: StoredFile):
- if file_code.prefix == "文本分享":
- return file_code.text
- if self.proxy:
- return await get_file_url(file_code.code)
- else:
- async with self._client() as s3:
- result = await s3.generate_presigned_url(
- "get_object",
- Params={
- "Bucket": self.bucket_name,
- "Key": file_code.get_file_path(),
- },
- ExpiresIn=3600,
- )
- return result
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """
- 保存分片到 S3(使用独立对象存储每个分片)
- 注意:这里不使用 S3 原生的 multipart upload,而是将每个分片作为独立对象存储
- """
- chunk_key = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
- async with self._client() as s3:
- # 将分片作为独立对象上传
- await s3.put_object(
- Bucket=self.bucket_name,
- Key=chunk_key,
- Body=chunk_data,
- Metadata={
- 'chunk-hash': chunk_hash,
- 'chunk-index': str(chunk_index)
- }
- )
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """
- 合并 S3 上的分片文件
- 使用 S3 的 multipart upload API 实现流式合并,避免内存问题
- """
- file_sha256 = hashlib.sha256()
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
-
- async with self._client() as s3:
- # 创建 multipart upload
- mpu = await s3.create_multipart_upload(
- Bucket=self.bucket_name,
- Key=save_path,
- ContentType='application/octet-stream'
- )
- mpu_id = mpu['UploadId']
- parts = []
-
- try:
- # 按顺序读取、验证每个分片;S3 multipart 规范要求除最后一片外
- # 每个部分 ≥5MB(EntityTooSmall),而分片大小由客户端决定(常见
- # 2-4MB)——因此缓冲到 S3_MIN_MULTIPART_PART_SIZE 再上传 part,
- # 内存上界 = 5MB + 单个分片,不破坏流式合并的初衷。
- part_buffer = bytearray()
- part_number = 0
-
- async def _flush_part():
- nonlocal part_number
- if not part_buffer:
- return
- part_number += 1
- part_response = await s3.upload_part(
- Bucket=self.bucket_name,
- Key=save_path,
- UploadId=mpu_id,
- PartNumber=part_number,
- Body=bytes(part_buffer),
- )
- parts.append({
- 'PartNumber': part_number,
- 'ETag': part_response['ETag']
- })
- part_buffer.clear()
-
- for i in range(total_chunks):
- chunk_key = f"{chunk_dir}/{i}.part"
- chunk_record = self._get_chunk_record(chunk_records, i)
-
- try:
- response = await s3.get_object(
- Bucket=self.bucket_name,
- Key=chunk_key
- )
- chunk_data = await response['Body'].read()
- except Exception as e:
- raise ValueError(f"分片{i}文件不存在: {e}")
-
- self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
- part_buffer.extend(chunk_data)
- if len(part_buffer) >= S3_MIN_MULTIPART_PART_SIZE:
- await _flush_part()
-
- # 收尾:剩余缓冲作为最后一个 part(S3 允许最后一片小于 5MB;
- # 恰好整除时缓冲为空,跳过)
- await _flush_part()
-
- # 完成 multipart upload
- await s3.complete_multipart_upload(
- Bucket=self.bucket_name,
- Key=save_path,
- UploadId=mpu_id,
- MultipartUpload={'Parts': parts}
- )
- except Exception as e:
- # 出错时取消 multipart upload
- await s3.abort_multipart_upload(
- Bucket=self.bucket_name,
- Key=save_path,
- UploadId=mpu_id
- )
- raise e
-
- return save_path, file_sha256.hexdigest()
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """
- 清理 S3 上的临时分片文件
- :param upload_id: 上传会话ID
- :param save_path: 文件保存路径
- """
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
- async with self._client() as s3:
- try:
- # 列出并删除所有分片对象
- paginator = s3.get_paginator('list_objects_v2')
- async for page in paginator.paginate(Bucket=self.bucket_name, Prefix=chunk_dir):
- objects = page.get('Contents', [])
- if objects:
- delete_objects = [{'Key': obj['Key']} for obj in objects]
- await s3.delete_objects(
- Bucket=self.bucket_name,
- Delete={'Objects': delete_objects}
- )
- except Exception as e:
- logger.warning(f"清理 S3 分片数据时出错: {e}")
-
- async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
- """
- 生成S3预签名上传URL
- :param save_path: 文件保存路径
- :param expires_in: URL过期时间(秒),默认15分钟
- :return: 预签名PUT URL
- """
- async with self._client() as s3:
- return await s3.generate_presigned_url(
- "put_object",
- Params={
- "Bucket": self.bucket_name,
- "Key": save_path,
- },
- ExpiresIn=expires_in,
- )
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在于S3
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- async with self._client() as s3:
- last_error = None
- for attempt in range(3):
- try:
- await s3.head_object(Bucket=self.bucket_name, Key=save_path)
- return True
- except Exception as e:
- last_error = e
- if attempt < 2:
- await asyncio.sleep(0.2 * (attempt + 1))
-
- try:
- result = await s3.list_objects_v2(
- Bucket=self.bucket_name,
- Prefix=save_path,
- MaxKeys=1,
- )
- for item in result.get("Contents", []):
- if item.get("Key") == save_path:
- return True
- except Exception as e:
- last_error = e
-
- logger.warning(f"S3文件确认失败 key={save_path}: {last_error}")
- return False
-
-
-class OneDriveFileStorage(FileStorageInterface):
- def __init__(self):
- try:
- import msal
- from office365.graph_client import GraphClient
- from office365.runtime.client_request_exception import (
- ClientRequestException,
- )
- except ImportError:
- raise ImportError("请先安装`msal`和`Office365-REST-Python-Client`")
- self.msal = msal
- self.domain = settings.onedrive_domain
- self.client_id = settings.onedrive_client_id
- self.username = settings.onedrive_username
- self.password = settings.onedrive_password
- self.proxy = settings.onedrive_proxy
- self._ClientRequestException = ClientRequestException
-
- try:
- client = GraphClient(self.acquire_token_pwd)
- self.root_path = (
- client.me.drive.root.get_by_path(settings.onedrive_root_path)
- .get()
- .execute_query()
- )
- except ClientRequestException as e:
- if e.code == "itemNotFound":
- client.me.drive.root.create_folder(settings.onedrive_root_path)
- self.root_path = (
- client.me.drive.root.get_by_path(
- settings.onedrive_root_path)
- .get()
- .execute_query()
- )
- else:
- raise e
- except Exception as e:
- raise Exception("OneDrive验证失败,请检查配置是否正确\n" + str(e))
-
- def acquire_token_pwd(self):
- authority_url = f"https://login.microsoftonline.com/{self.domain}"
- app = self.msal.PublicClientApplication(
- authority=authority_url, client_id=self.client_id
- )
- result = app.acquire_token_by_username_password(
- username=self.username,
- password=self.password,
- scopes=["https://graph.microsoft.com/.default"],
- )
- return result
-
- def _get_path_str(self, path):
- if isinstance(path, str):
- path = path.replace("\\", "/").replace("//", "/").split("/")
- elif isinstance(path, Path):
- path = str(path).replace("\\", "/").replace("//", "/").split("/")
- else:
- raise TypeError("path must be str or Path")
- path[-1] = path[-1].split(".")[0]
- return "/".join(path)
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- """保存文件(自动创建目录;修复旧实现把 save_path 字符串当函数调用的崩溃)"""
- content = await asyncio.to_thread(stream.read)
- normalized = str(save_path).replace("\\", "/")
- name = await sanitize_filename(Path(normalized).name)
- dir_path = "/".join(normalized.split("/")[:-1])
-
- current_folder = self.root_path
- for part in dir_path.split("/"):
- if not part:
- continue
- try:
- current_folder = current_folder.get_by_path(part).get().execute_query()
- except self._ClientRequestException as e:
- if e.code == "itemNotFound":
- current_folder = current_folder.create_folder(part).execute_query()
- else:
- raise e
-
- await asyncio.to_thread(
- lambda: current_folder.get_by_path(name)
- .upload(name, content)
- .execute_query()
- )
-
- def _delete(self, save_path):
- path = self._get_path_str(save_path)
- try:
- self.root_path.get_by_path(path).delete_object().execute_query()
- except self._ClientRequestException as e:
- if e.code == "itemNotFound":
- pass
- else:
- raise e
-
- async def delete_file(self, file_code: StoredFile):
- await asyncio.to_thread(self._delete, file_code.get_file_path())
-
- def _convert_link_to_download_link(self, link):
- p1 = re.search(r"https://(.+)\.sharepoint\.com", link).group(1)
- p2 = re.search(r"personal/(.+)/", link).group(1)
- p3 = re.search(rf"{p2}/(.+)", link).group(1)
- return f"https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}"
-
- def _get_file_url(self, save_path, name):
- path = self._get_path_str(save_path)
- remote_file = self.root_path.get_by_path(path + "/" + name)
- expiration_datetime = datetime.datetime.now(
- tz=datetime.timezone.utc
- ) + datetime.timedelta(hours=1)
- expiration_datetime = expiration_datetime.strftime(
- "%Y-%m-%dT%H:%M:%SZ")
- permission = remote_file.create_link(
- "view", "anonymous", expiration_datetime=expiration_datetime
- ).execute_query()
- return self._convert_link_to_download_link(permission.link.webUrl)
-
- async def get_file_response(self, file_code: StoredFile):
- try:
- filename = file_code.prefix + file_code.suffix
- try:
- link = await asyncio.to_thread(
- self._get_file_url, file_code.get_file_path(), filename
- )
- except self._ClientRequestException as e:
- # 对象不存在时前置 404(与 local/S3/WebDAV 语义对齐),
- # 不再落入外层兜底的 503
- if str(getattr(e, "code", "")).lower() in {"itemnotfound", "404"}:
- raise StorageError(
- status_code=404, detail="文件已过期删除"
- ) from e
- raise
-
- content_length = None # 初始化为 None,表示未知大小
-
- # 创建ClientSession并复用
- session = aiohttp.ClientSession()
-
- # 尝试发送HEAD请求获取Content-Length
- try:
- async with session.head(link) as resp:
- if resp.status == 200 and 'Content-Length' in resp.headers:
- content_length = int(resp.headers['Content-Length'])
- except Exception:
- # 如果HEAD请求失败,则不提供 Content-Length
- pass
-
- async def stream_generator():
- try:
- async with session.get(link) as resp:
- if resp.status != 200:
- raise StorageError(
- status_code=resp.status,
- detail=f"从OneDrive获取文件失败: {resp.status}"
- )
- chunk_size = 65536
- while True:
- chunk = await resp.content.read(chunk_size)
- if not chunk:
- break
- yield chunk
- finally:
- await session.close()
-
- headers = build_attachment_headers(filename, content_length)
- return StoredDownload(
- filename=filename,
- headers=headers,
- stream_factory=stream_generator,
- # 兜底关闭会话:客户端中断时与 generator finally 双保险
- background=BackgroundTask(session.close),
- )
- except StorageError:
- raise
- except Exception:
- raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试")
-
- async def get_file_url(self, file_code: StoredFile):
- if self.proxy:
- return await get_file_url(file_code.code)
- else:
- return await asyncio.to_thread(
- self._get_file_url,
- file_code.get_file_path(),
- f"{file_code.prefix}{file_code.suffix}",
- )
-
- def _save_chunk(self, chunk_path: str, chunk_data: bytes):
- """同步保存分片到 OneDrive"""
- path_parts = chunk_path.replace("\\", "/").split("/")
- filename = path_parts[-1]
- dir_path = "/".join(path_parts[:-1])
-
- # 确保目录存在
- current_folder = self.root_path
- for part in dir_path.split("/"):
- if part:
- try:
- current_folder = current_folder.get_by_path(part).get().execute_query()
- except self._ClientRequestException as e:
- if e.code == "itemNotFound":
- current_folder = current_folder.create_folder(part).execute_query()
- else:
- raise e
-
- # 上传分片
- current_folder.upload(filename, chunk_data).execute_query()
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """保存分片到 OneDrive"""
- chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
- await asyncio.to_thread(self._save_chunk, chunk_path, chunk_data)
-
- def _read_chunk(self, chunk_path: str) -> bytes:
- """同步读取分片"""
- path = self._get_path_str(chunk_path)
- file_obj = self.root_path.get_by_path(path).get().execute_query()
- return file_obj.get_content().execute_query().value
-
- def _upload_merged(self, save_path: str, data: bytes):
- """同步上传合并后的文件"""
- path_parts = save_path.replace("\\", "/").split("/")
- filename = path_parts[-1]
- dir_path = "/".join(path_parts[:-1])
-
- # 确保目录存在
- current_folder = self.root_path
- for part in dir_path.split("/"):
- if part:
- try:
- current_folder = current_folder.get_by_path(part).get().execute_query()
- except self._ClientRequestException as e:
- if e.code == "itemNotFound":
- current_folder = current_folder.create_folder(part).execute_query()
- else:
- raise e
-
- current_folder.upload(filename, data).execute_query()
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """合并 OneDrive 上的分片文件,使用临时文件避免内存问题"""
- file_sha256 = hashlib.sha256()
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
-
- # 使用临时文件存储合并数据
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_path = temp_file.name
-
- try:
- async with aiofiles.open(temp_path, 'wb') as out_file:
- for i in range(total_chunks):
- chunk_path = f"{chunk_dir}/{i}.part"
- chunk_record = self._get_chunk_record(chunk_records, i)
-
- try:
- chunk_data = await asyncio.to_thread(self._read_chunk, chunk_path)
- except Exception as e:
- raise ValueError(f"分片{i}文件不存在: {e}")
-
- self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
- await out_file.write(chunk_data)
- del chunk_data # 释放内存
-
- # 读取临时文件并上传
- async with aiofiles.open(temp_path, 'rb') as f:
- merged_content = await f.read()
- await asyncio.to_thread(self._upload_merged, save_path, merged_content)
- finally:
- # 清理临时文件
- if os.path.exists(temp_path):
- os.unlink(temp_path)
-
- return save_path, file_sha256.hexdigest()
-
- def _delete_chunk_dir(self, chunk_dir: str):
- """同步删除分片目录"""
- try:
- path = self._get_path_str(chunk_dir)
- self.root_path.get_by_path(path).delete_object().execute_query()
- except self._ClientRequestException as e:
- if e.code != "itemNotFound":
- raise e
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """清理 OneDrive 上的临时分片文件"""
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
- try:
- await asyncio.to_thread(self._delete_chunk_dir, chunk_dir)
- except Exception as e:
- logger.warning(f"清理 OneDrive 分片时出错: {e}")
-
- def _file_exists(self, save_path: str) -> bool:
- """同步检查文件是否存在"""
- try:
- path = self._get_path_str(save_path)
- self.root_path.get_by_path(path).get().execute_query()
- return True
- except self._ClientRequestException as e:
- if e.code == "itemNotFound":
- return False
- raise e
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在于OneDrive
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- return await asyncio.to_thread(self._file_exists, save_path)
-
-
-class OpenDALFileStorage(FileStorageInterface):
- def __init__(self):
- try:
- import opendal
- except ImportError:
- raise ImportError('请先安装 `opendal`, 例如: "pip install opendal"')
- self.service = settings.opendal_scheme
- service_settings = {}
- for key, value in settings.items():
- if key.startswith("opendal_" + self.service):
- setting_name = key.split("_", 2)[2]
- service_settings[setting_name] = value
- self.operator = opendal.AsyncOperator(
- settings.opendal_scheme, **service_settings
- )
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- # 使用 asyncio.to_thread 避免阻塞事件循环
- content = await asyncio.to_thread(stream.read)
- await self.operator.write(save_path, content)
-
- async def delete_file(self, file_code: StoredFile):
- await self.operator.delete(file_code.get_file_path())
-
- async def get_file_url(self, file_code: StoredFile):
- return await get_file_url(file_code.code)
-
- async def get_file_response(self, file_code: StoredFile):
- try:
- filename = file_code.prefix + file_code.suffix
- content_length = None # 初始化为 None,表示未知大小
-
- # 尝试获取文件大小
- try:
- stat_result = await self.operator.stat(file_code.get_file_path())
- if hasattr(stat_result, 'content_length') and stat_result.content_length:
- content_length = stat_result.content_length
- elif hasattr(stat_result, 'size') and stat_result.size:
- content_length = stat_result.size
- except Exception:
- # 如果获取大小失败,则不提供 Content-Length
- pass
-
- # 尝试使用流式读取器
- try:
- # OpenDAL 可能提供 reader 方法返回一个异步读取器
- reader = await self.operator.reader(file_code.get_file_path())
- except AttributeError:
- # 如果 reader 方法不存在,回退到全量读取(兼容旧版本)
- content = await self.operator.read(file_code.get_file_path())
- headers = build_attachment_headers(filename, content_length)
- return StoredDownload(
- filename=filename,
- headers=headers,
- content=content,
- )
-
- async def stream_generator():
- chunk_size = 65536
- while True:
- chunk = await reader.read(chunk_size)
- if not chunk:
- break
- yield chunk
-
- headers = build_attachment_headers(filename, content_length)
- return StoredDownload(
- filename=filename,
- headers=headers,
- stream_factory=stream_generator,
- )
- except Exception as e:
- logger.info(e)
- raise StorageError(status_code=404, detail="文件已过期删除")
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """保存分片到 OpenDAL 存储"""
- chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
- await self.operator.write(chunk_path, chunk_data)
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """合并 OpenDAL 存储上的分片文件,使用临时文件避免内存问题"""
- file_sha256 = hashlib.sha256()
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
-
- # 使用临时文件存储合并数据
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_path = temp_file.name
-
- try:
- async with aiofiles.open(temp_path, 'wb') as out_file:
- for i in range(total_chunks):
- chunk_path = f"{chunk_dir}/{i}.part"
- chunk_record = self._get_chunk_record(chunk_records, i)
-
- try:
- chunk_data = await self.operator.read(chunk_path)
- except Exception as e:
- raise ValueError(f"分片{i}文件不存在: {e}")
-
- self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
- await out_file.write(chunk_data)
- del chunk_data # 释放内存
-
- # 读取临时文件并写入存储
- async with aiofiles.open(temp_path, 'rb') as f:
- merged_content = await f.read()
- await self.operator.write(save_path, merged_content)
- finally:
- # 清理临时文件
- if os.path.exists(temp_path):
- os.unlink(temp_path)
-
- return save_path, file_sha256.hexdigest()
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """清理 OpenDAL 存储上的临时分片文件"""
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
- try:
- # OpenDAL 支持递归删除
- await self.operator.remove_all(chunk_dir)
- except Exception as e:
- logger.warning(f"清理 OpenDAL 分片时出错: {e}")
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在于OpenDAL存储
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- try:
- await self.operator.stat(save_path)
- return True
- except Exception:
- return False
-
-
-class WebDAVFileStorage(FileStorageInterface):
- def __init__(self):
- if not hasattr(self, "_initialized"):
- self.base_url = settings.webdav_url.rstrip("/") + "/"
- # aiohttp 4.0 移除 BasicAuth(auth=...) 参数——改用编码后的 Authorization 头
- self.auth_headers = {
- "Authorization": aiohttp.encode_basic_auth(
- settings.webdav_username, settings.webdav_password
- )
- }
- self._initialized = True
-
- def _build_url(self, path: str) -> str:
- encoded_path = quote(str(path.replace("\\", "/").lstrip("/")).lstrip("/"))
- return f"{self.base_url}{encoded_path}"
-
- async def _mkdir_p(self, directory_path: str):
- """递归创建目录(类似mkdir -p)"""
- path_obj = Path(unquote(directory_path))
- current_path = ""
-
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- # 逐级检查目录是否存在
- for part in path_obj.parts:
- current_path = str(Path(current_path) / part)
- url = self._build_url(current_path)
-
- # 检查目录是否存在
- async with session.head(url) as resp:
- if resp.status == 404:
- # 创建目录
- async with session.request("MKCOL", url) as mkcol_resp:
- if mkcol_resp.status not in (200, 201, 409):
- content = await mkcol_resp.text()
- raise StorageError(
- status_code=mkcol_resp.status,
- detail=f"目录创建失败: {content[:200]}",
- )
-
- async def _is_dir_empty(self, dir_path: str) -> bool:
- """检查目录是否为空"""
- url = self._build_url(dir_path)
-
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- async with session.request("PROPFIND", url, headers={"Depth": "1"}) as resp:
- if resp.status != 207: # 207 是 Multi-Status 响应
- return False
- content = await resp.text()
- # 如果只有一个 response(当前目录),说明目录为空
- return content.count("") <= 1
-
- async def _delete_empty_dirs(self, file_path: str, session: aiohttp.ClientSession):
- """递归删除空目录"""
- path_obj = Path(file_path)
- current_path = path_obj.parent
-
- while str(current_path) != ".":
- if not await self._is_dir_empty(str(current_path)):
- break
-
- url = self._build_url(str(current_path))
- async with session.delete(url) as resp:
- if resp.status not in (200, 204, 404):
- break
-
- current_path = current_path.parent
-
- async def save_file(
- self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
- ):
- """保存文件(自动创建目录,流式上传)"""
- path_obj = Path(save_path)
- directory_path = str(path_obj.parent)
- # 提取原始文件名并进行清理
- filename = await sanitize_filename(path_obj.name)
- # 构建安全的保存路径
- safe_save_path = str(Path(directory_path) / filename)
-
- try:
- # 先创建目录结构
- await self._mkdir_p(directory_path)
- # 上传文件(流式)
- url = self._build_url(safe_save_path)
-
- async def file_sender():
- """流式读取文件内容"""
- chunk_size = 256 * 1024 # 256KB chunks
- while True:
- chunk = await asyncio.to_thread(stream.read, chunk_size)
- if not chunk:
- break
- yield chunk
-
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- async with session.put(
- url,
- data=file_sender(),
- headers={"Content-Type": content_type or "application/octet-stream"}
- ) as resp:
- if resp.status not in (200, 201, 204):
- content = await resp.text()
- raise StorageError(
- status_code=resp.status,
- detail=f"文件上传失败: {content[:200]}",
- )
- except aiohttp.ClientError as e:
- raise StorageError(
- status_code=503, detail=f"WebDAV连接异常: {str(e)}")
-
- async def delete_file(self, file_code: StoredFile):
- """删除WebDAV文件及空目录"""
- file_path = file_code.get_file_path()
- url = self._build_url(file_path)
- try:
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- # 删除文件
- async with session.delete(url) as resp:
- if resp.status not in (200, 204, 404):
- content = await resp.text()
- raise StorageError(
- status_code=resp.status,
- detail=f"WebDAV删除失败: {content[:200]}",
- )
-
- # 使用同一个 session 删除空目录
- await self._delete_empty_dirs(file_path, session)
-
- except aiohttp.ClientError as e:
- raise StorageError(
- status_code=503, detail=f"WebDAV连接异常: {str(e)}")
-
- async def get_file_url(self, file_code: StoredFile):
- return await get_file_url(file_code.code)
-
- async def get_file_response(self, file_code: StoredFile):
- """获取文件响应(代理模式)"""
- try:
- filename = file_code.prefix + file_code.suffix
- url = self._build_url(file_code.get_file_path())
- content_length = None # 初始化为 None,表示未知大小
-
- # 创建ClientSession并复用(包含认证头)
- session = aiohttp.ClientSession(headers={
- "Authorization": f"Basic {base64.b64encode(f'{settings.webdav_username}:{settings.webdav_password}'.encode()).decode()}"
- })
-
- # 尝试发送HEAD请求获取Content-Length;对象不存在时前置 404
- # (与 local/S3 语义对齐),连接层错误映射 503——两者都不能
- # 静默吞掉后签出 200 坏流。异常路径回收 session 防泄漏。
- try:
- try:
- async with session.head(url) as resp:
- if resp.status == 404:
- raise StorageError(
- status_code=404, detail="文件已过期删除"
- )
- if resp.status == 200 and 'Content-Length' in resp.headers:
- content_length = int(resp.headers['Content-Length'])
- except StorageError:
- raise
- except aiohttp.ClientError as e:
- raise StorageError(
- status_code=503, detail=f"WebDAV连接异常: {str(e)}"
- ) from e
- except Exception:
- # 其他 HEAD 异常不阻断:流式下载阶段会给出真实状态
- pass
-
- except BaseException:
- await session.close()
- raise
-
- async def stream_generator():
- try:
- async with session.get(url) as resp:
- if resp.status != 200:
- raise StorageError(
- status_code=resp.status,
- detail=f"文件获取失败{resp.status}: {await resp.text()}",
- )
- chunk_size = 65536
- while True:
- chunk = await resp.content.read(chunk_size)
- if not chunk:
- break
- yield chunk
- finally:
- await session.close()
-
- headers = build_attachment_headers(filename, content_length)
- return StoredDownload(
- filename=filename,
- headers=headers,
- stream_factory=stream_generator,
- # 兜底关闭会话:客户端中断时与 generator finally 双保险
- background=BackgroundTask(session.close),
- )
- except aiohttp.ClientError as e:
- raise StorageError(
- status_code=503, detail=f"WebDAV连接异常: {str(e)}")
-
- async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
- """保存分片到 WebDAV"""
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
- chunk_path = f"{chunk_dir}/{chunk_index}.part"
-
- # 先创建目录结构
- await self._mkdir_p(chunk_dir)
-
- chunk_url = self._build_url(chunk_path)
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- async with session.put(chunk_url, data=chunk_data) as resp:
- if resp.status not in (200, 201, 204):
- content = await resp.text()
- raise StorageError(
- status_code=resp.status,
- detail=f"分片上传失败: {content[:200]}"
- )
-
- async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
- """
- 合并 WebDAV 上的分片文件
- 使用临时文件避免内存问题
- """
- file_sha256 = hashlib.sha256()
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
-
- # 使用临时文件存储合并数据,避免内存问题
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_path = temp_file.name
-
- try:
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- # 按顺序读取并验证每个分片,写入临时文件
- async with aiofiles.open(temp_path, 'wb') as out_file:
- for i in range(total_chunks):
- chunk_path = f"{chunk_dir}/{i}.part"
- chunk_url = self._build_url(chunk_path)
-
- # 获取分片记录
- chunk_record = self._get_chunk_record(chunk_records, i)
-
- # 下载分片数据
- async with session.get(chunk_url) as resp:
- if resp.status != 200:
- raise ValueError(f"分片{i}文件不存在或无法访问")
- chunk_data = await resp.read()
-
- # 验证哈希
- self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
- await out_file.write(chunk_data)
- del chunk_data # 释放内存
-
- # 确保目标目录存在
- output_dir = str(Path(save_path).parent)
- await self._mkdir_p(output_dir)
-
- # 流式上传合并后的文件
- output_url = self._build_url(save_path)
-
- async def file_sender():
- async with aiofiles.open(temp_path, 'rb') as f:
- while True:
- chunk = await f.read(256 * 1024)
- if not chunk:
- break
- yield chunk
-
- async with session.put(output_url, data=file_sender()) as resp:
- if resp.status not in (200, 201, 204):
- content = await resp.text()
- raise StorageError(
- status_code=resp.status,
- detail=f"合并文件上传失败: {content[:200]}"
- )
- finally:
- # 清理临时文件
- if os.path.exists(temp_path):
- os.unlink(temp_path)
-
- return save_path, file_sha256.hexdigest()
-
- async def clean_chunks(self, upload_id: str, save_path: str):
- """
- 清理 WebDAV 上的临时分片文件
- :param upload_id: 上传会话ID
- :param save_path: 文件保存路径
- """
- chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
- chunk_dir_url = self._build_url(chunk_dir)
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- try:
- # 检查分片目录是否存在
- async with session.request("PROPFIND", chunk_dir_url, headers={"Depth": "1"}) as resp:
- if resp.status == 207: # 207 表示 Multi-Status
- # 获取目录下的所有分片文件
- xml_data = await resp.text()
- file_paths = re.findall(
- r'(.*?)', xml_data)
- for file_path in file_paths:
- if file_path.endswith(".part"):
- # 删除分片文件
- file_url = self._build_url(file_path)
- async with session.delete(file_url) as delete_resp:
- if delete_resp.status not in (200, 204, 404):
- logger.warning(f"删除分片文件失败: {file_path}")
-
- # 删除分片目录
- async with session.delete(chunk_dir_url) as delete_resp:
- if delete_resp.status not in (200, 204, 404):
- logger.warning(f"删除分片目录失败: {chunk_dir_url}")
- else:
- logger.info(f"分片目录不存在: {chunk_dir_url}")
- except Exception as e:
- logger.warning(f"清理 WebDAV 分片时出错: {e}")
-
- async def file_exists(self, save_path: str) -> bool:
- """
- 检查文件是否存在于WebDAV
- :param save_path: 文件路径
- :return: 文件是否存在
- """
- url = self._build_url(save_path)
- async with aiohttp.ClientSession(headers=self.auth_headers) as session:
- async with session.head(url) as resp:
- return resp.status == 200
-
-
-storages = {
- "local": SystemFileStorage,
- "s3": S3FileStorage,
- "onedrive": OneDriveFileStorage,
- "opendal": OpenDALFileStorage,
- "webdav": WebDAVFileStorage,
-}
diff --git a/core/storage/__init__.py b/core/storage/__init__.py
new file mode 100644
index 000000000..757e31532
--- /dev/null
+++ b/core/storage/__init__.py
@@ -0,0 +1,25 @@
+"""Storage backend package (split from the former 1400+ line storage.py).
+
+Public surface is re-exported here — `from core.storage import X` keeps
+working for every historical name.
+"""
+from core.storage._base import ( # noqa: F401
+ FileStorageInterface,
+ S3_MIN_MULTIPART_PART_SIZE,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+from core.storage.local import SystemFileStorage # noqa: F401
+from core.storage.s3 import S3FileStorage # noqa: F401
+from core.storage.onedrive import OneDriveFileStorage # noqa: F401
+from core.storage.opendal import OpenDALFileStorage # noqa: F401
+from core.storage.webdav import WebDAVFileStorage # noqa: F401
+
+storages = {
+ "local": SystemFileStorage,
+ "s3": S3FileStorage,
+ "onedrive": OneDriveFileStorage,
+ "opendal": OpenDALFileStorage,
+ "webdav": WebDAVFileStorage,
+}
diff --git a/core/storage/_base.py b/core/storage/_base.py
new file mode 100644
index 000000000..e1c0ff2c7
--- /dev/null
+++ b/core/storage/_base.py
@@ -0,0 +1,178 @@
+# @Time : 2023/8/11 20:06
+# @Author : Lan
+# @File : storage.py
+# @Software: PyCharm
+import hashlib
+from typing import BinaryIO, Optional
+from urllib.parse import quote
+
+from dataclasses import dataclass
+
+
+@dataclass
+
+
+class StoredDownload:
+ """Framework-free description of a file download.
+
+ Backends return this; the view layer builds the starlette Response:
+ - ``path`` set -> FileResponse (local files, Range support for free)
+ - ``content`` set -> small full-read Response (legacy OpenDAL fallback)
+ - ``stream_factory`` set -> StreamingResponse(stream_factory(), ...)
+ ``background`` is an optional response-sent cleanup hook.
+ """
+
+ filename: str
+ headers: dict
+ media_type: str = "application/octet-stream"
+ path: object = None
+ content: object = None
+ stream_factory: object = None
+ background: object = None
+
+
+@dataclass
+class StoredFile:
+ """Plain, framework- and ORM-free description of a stored file.
+
+ Storage backends accept this instead of ORM models so core/ never imports
+ apps/. Callers (views/tasks/services) build it from their own records.
+ """
+
+ file_path: str
+ uuid_file_name: str
+ code: str = ""
+ prefix: str = ""
+ suffix: str = ""
+ text: str = ""
+
+ def get_file_path(self) -> str:
+ return f"{self.file_path}/{self.uuid_file_name}"
+
+
+
+
+# S3 multipart 除最后一片外每部分最小 5MB(服务端强制,小于即 EntityTooSmall)
+S3_MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024
+
+
+def build_attachment_headers(filename: str, content_length=None) -> dict:
+ """所有存储后端统一的下载响应头。
+
+ Content-Disposition: attachment 是防御存储型 XSS 的关键——同源下载路径
+ (/share/download)因此永不内联渲染 HTML/SVG。此函数是唯一构造点,
+ 新增后端必须复用(tests/test_attachment_guard.py 有源码级 tripwire)。
+ """
+ encoded_filename = quote(filename, safe="")
+ headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
+ if content_length is not None:
+ headers["Content-Length"] = str(content_length)
+ return headers
+
+class FileStorageInterface:
+
+ @staticmethod
+ def _get_chunk_record(chunk_records: dict, index: int):
+ """Look up the caller-provided record for chunk `index`.
+
+ Records are plain objects exposing ``chunk_hash``; fetching them from
+ the DB is the caller's job (keeps storage ORM-free).
+ """
+ chunk_record = chunk_records.get(index)
+ if not chunk_record:
+ raise ValueError(f"分片{index}记录不存在")
+ return chunk_record
+
+ def _verify_and_hash_chunk(
+ self,
+ index: int,
+ chunk_record,
+ chunk_data: bytes,
+ file_sha256,
+ ) -> None:
+ """Verify a chunk against its recorded hash, then stripe it into the
+ whole-file digest. Raises the shared ValueError wording on mismatch."""
+ current_hash = hashlib.sha256(chunk_data).hexdigest()
+ if current_hash != chunk_record.chunk_hash:
+ raise ValueError(
+ f"分片{index}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}"
+ )
+ file_sha256.update(chunk_data)
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ """Save a binary stream (caller owns closing the stream)."""
+ raise NotImplementedError
+
+ async def delete_file(self, file_code: StoredFile):
+ """
+ 删除文件
+ """
+ raise NotImplementedError
+
+ async def get_file_url(self, file_code: StoredFile):
+ """
+ 获取文件分享的url
+
+ 如果服务不支持直接访问文件,可以通过服务器中转下载。
+ 此时,此方法可以调用 utils.py 中的 `get_file_url` 方法,获取服务器中转下载的url
+ """
+ raise NotImplementedError
+
+ async def get_file_response(self, file_code: StoredFile):
+ """
+ 获取文件响应
+
+ 如果服务不支持直接访问文件,则需要实现该方法,返回文件响应
+ 其余情况,可以不实现该方法
+ """
+ raise NotImplementedError
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """
+ 保存分片文件
+ :param upload_id: 上传会话ID
+ :param chunk_index: 分片索引
+ :param chunk_data: 分片数据
+ :param chunk_hash: 分片哈希值
+ :param save_path: 文件保存路径
+ """
+ raise NotImplementedError
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """
+ 合并分片文件并返回文件路径和完整哈希值
+ :param upload_id: 上传会话ID
+ :param chunk_info: 分片信息
+ :param save_path: 文件保存路径
+ :return: (文件路径, 文件哈希值)
+ """
+ raise NotImplementedError
+
+ async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
+ """
+ 生成预签名上传URL
+ :param save_path: 文件保存路径
+ :param expires_in: URL过期时间(秒),默认15分钟
+ :return: 预签名URL,如果不支持直传则返回None
+ """
+ return None # 默认不支持直传,使用代理模式
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ raise NotImplementedError
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """
+ 清理临时分片文件
+ :param upload_id: 上传会话ID
+ :param save_path: 文件保存路径
+ """
+ raise NotImplementedError
+
+
diff --git a/core/storage/local.py b/core/storage/local.py
new file mode 100644
index 000000000..75942f5f3
--- /dev/null
+++ b/core/storage/local.py
@@ -0,0 +1,184 @@
+import hashlib
+from core.logger import logger
+import shutil
+from typing import BinaryIO, Optional
+
+import aiofiles
+import asyncio
+from pathlib import Path
+from core.errors import StorageError
+from core.settings import data_root
+from core.utils import get_file_url, sanitize_filename
+
+from core.storage._base import (
+ FileStorageInterface,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+
+
+class SystemFileStorage(FileStorageInterface):
+ def __init__(self):
+ self.chunk_size = 256 * 1024
+ self.root_path = data_root
+
+ def _resolve_safe_path(self, relative_path: str) -> Path:
+ """将相对路径解析到数据根目录内,阻止路径穿越。"""
+ root = self.root_path.resolve()
+ raw = str(relative_path or "").replace("\\", "/").lstrip("/")
+ if any(part == ".." for part in raw.split("/")):
+ raise ValueError("非法文件路径")
+ candidate = (root / raw).resolve()
+ try:
+ candidate.relative_to(root)
+ except ValueError as exc:
+ raise ValueError("非法文件路径") from exc
+ return candidate
+
+ def _save(self, file, save_path):
+ with open(save_path, "wb") as f:
+ chunk = file.read(self.chunk_size)
+ while chunk:
+ f.write(chunk)
+ chunk = file.read(self.chunk_size)
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ path_obj = Path(str(save_path).replace("\\", "/"))
+ directory = str(path_obj.parent).replace("\\", "/").lstrip("/")
+ # 提取原始文件名并进行清理
+ 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)
+ await asyncio.to_thread(self._save, stream, safe_save_path)
+
+ async def delete_file(self, file_code: StoredFile):
+ save_path = self._resolve_safe_path(file_code.get_file_path())
+ if save_path.exists():
+ save_path.unlink()
+
+ async def get_file_url(self, file_code: StoredFile):
+ return await get_file_url(file_code.code)
+
+ async def get_file_response(self, file_code: StoredFile):
+ file_path = self._resolve_safe_path(file_code.get_file_path())
+ if not file_path.exists():
+ raise StorageError(status_code=404, detail="文件已过期删除")
+ filename = f"{file_code.prefix}{file_code.suffix}"
+ try:
+ headers = build_attachment_headers(filename, file_path.stat().st_size)
+ except OSError:
+ # 文件大小不可得时省略 Content-Length
+ headers = build_attachment_headers(filename)
+
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ path=file_path,
+ )
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """
+ 保存分片文件到本地文件系统
+ :param upload_id: 上传会话ID
+ :param chunk_index: 分片索引
+ :param chunk_data: 分片数据
+ :param chunk_hash: 分片哈希值
+ :param save_path: 文件保存路径
+ """
+ # 先校验目标文件路径合法,再将分片落到同级 chunks 目录。
+ self._resolve_safe_path(save_path)
+ chunk_path = self._resolve_safe_path(
+ str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
+ )
+ if not chunk_path.parent.exists():
+ chunk_path.parent.mkdir(parents=True, exist_ok=True)
+ # 使用临时文件写入,确保原子性
+ temp_path = chunk_path.with_suffix('.tmp')
+ try:
+ async with aiofiles.open(temp_path, "wb") as f:
+ await f.write(chunk_data)
+ # 原子重命名
+ temp_path.rename(chunk_path)
+ except Exception as e:
+ if temp_path.exists():
+ temp_path.unlink()
+ raise e
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """
+ 合并本地文件系统的分片文件并返回文件路径和完整哈希值
+ :param upload_id: 上传会话ID
+ :param chunk_info: 分片信息
+ :param save_path: 文件保存路径
+ :return: (文件路径, 文件哈希值)
+ """
+ output_path = self._resolve_safe_path(save_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ chunk_base_dir = self._resolve_safe_path(
+ str(Path(save_path).parent / "chunks" / upload_id)
+ )
+ file_sha256 = hashlib.sha256()
+
+ # 使用临时文件写入,确保原子性
+ temp_output = output_path.with_suffix('.merging')
+ try:
+ async with aiofiles.open(temp_output, "wb") as out_file:
+ for i in range(total_chunks):
+ # 获取分片记录
+ chunk_record = self._get_chunk_record(chunk_records, i)
+ chunk_path = chunk_base_dir / f"{i}.part"
+ if not chunk_path.exists():
+ raise ValueError(f"分片{i}文件不存在")
+ async with aiofiles.open(chunk_path, "rb") as in_file:
+ chunk_data = await in_file.read()
+ self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ await out_file.write(chunk_data)
+ # 原子重命名
+ temp_output.rename(output_path)
+ except Exception as e:
+ if temp_output.exists():
+ temp_output.unlink()
+ raise e
+ return str(output_path), file_sha256.hexdigest()
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """
+ 清理本地文件系统的临时分片文件
+ :param upload_id: 上传会话ID
+ :param save_path: 文件保存路径
+ """
+ chunk_dir = self._resolve_safe_path(
+ str(Path(save_path).parent / "chunks" / upload_id)
+ )
+ if chunk_dir.exists():
+ try:
+ shutil.rmtree(chunk_dir)
+ except Exception as e:
+ logger.warning(f"清理本地分片目录失败: {e}")
+ # 清理父级 chunks 目录(如果为空)
+ chunks_parent = chunk_dir.parent
+ if chunks_parent.exists() and not any(chunks_parent.iterdir()):
+ try:
+ chunks_parent.rmdir()
+ except Exception as e:
+ logger.warning(f"清理 chunks 父目录失败: {e}")
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在于本地文件系统
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ try:
+ file_path = self._resolve_safe_path(save_path)
+ except ValueError:
+ return False
+ return file_path.exists()
+
+
diff --git a/core/storage/onedrive.py b/core/storage/onedrive.py
new file mode 100644
index 000000000..ba46000f1
--- /dev/null
+++ b/core/storage/onedrive.py
@@ -0,0 +1,339 @@
+import hashlib
+import os
+import tempfile
+from core.logger import logger
+from typing import BinaryIO, Optional
+
+import aiofiles
+import aiohttp
+import asyncio
+from pathlib import Path
+import datetime
+import re
+from core.errors import StorageError
+from core.settings import settings
+from core.utils import get_file_url, sanitize_filename
+from starlette.background import BackgroundTask
+
+from core.storage._base import (
+ FileStorageInterface,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+
+
+class OneDriveFileStorage(FileStorageInterface):
+ def __init__(self):
+ try:
+ import msal
+ from office365.graph_client import GraphClient
+ from office365.runtime.client_request_exception import (
+ ClientRequestException,
+ )
+ except ImportError:
+ raise ImportError("请先安装`msal`和`Office365-REST-Python-Client`")
+ self.msal = msal
+ self.domain = settings.onedrive_domain
+ self.client_id = settings.onedrive_client_id
+ self.username = settings.onedrive_username
+ self.password = settings.onedrive_password
+ self.proxy = settings.onedrive_proxy
+ self._ClientRequestException = ClientRequestException
+
+ try:
+ client = GraphClient(self.acquire_token_pwd)
+ self.root_path = (
+ client.me.drive.root.get_by_path(settings.onedrive_root_path)
+ .get()
+ .execute_query()
+ )
+ except ClientRequestException as e:
+ if e.code == "itemNotFound":
+ client.me.drive.root.create_folder(settings.onedrive_root_path)
+ self.root_path = (
+ client.me.drive.root.get_by_path(
+ settings.onedrive_root_path)
+ .get()
+ .execute_query()
+ )
+ else:
+ raise e
+ except Exception as e:
+ raise Exception("OneDrive验证失败,请检查配置是否正确\n" + str(e))
+
+ def acquire_token_pwd(self):
+ authority_url = f"https://login.microsoftonline.com/{self.domain}"
+ app = self.msal.PublicClientApplication(
+ authority=authority_url, client_id=self.client_id
+ )
+ result = app.acquire_token_by_username_password(
+ username=self.username,
+ password=self.password,
+ scopes=["https://graph.microsoft.com/.default"],
+ )
+ return result
+
+ def _get_path_str(self, path):
+ if isinstance(path, str):
+ path = path.replace("\\", "/").replace("//", "/").split("/")
+ elif isinstance(path, Path):
+ path = str(path).replace("\\", "/").replace("//", "/").split("/")
+ else:
+ raise TypeError("path must be str or Path")
+ path[-1] = path[-1].split(".")[0]
+ return "/".join(path)
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ """保存文件(自动创建目录;修复旧实现把 save_path 字符串当函数调用的崩溃)"""
+ content = await asyncio.to_thread(stream.read)
+ normalized = str(save_path).replace("\\", "/")
+ name = await sanitize_filename(Path(normalized).name)
+ dir_path = "/".join(normalized.split("/")[:-1])
+
+ current_folder = self.root_path
+ for part in dir_path.split("/"):
+ if not part:
+ continue
+ try:
+ current_folder = current_folder.get_by_path(part).get().execute_query()
+ except self._ClientRequestException as e:
+ if e.code == "itemNotFound":
+ current_folder = current_folder.create_folder(part).execute_query()
+ else:
+ raise e
+
+ await asyncio.to_thread(
+ lambda: current_folder.get_by_path(name)
+ .upload(name, content)
+ .execute_query()
+ )
+
+ def _delete(self, save_path):
+ path = self._get_path_str(save_path)
+ try:
+ self.root_path.get_by_path(path).delete_object().execute_query()
+ except self._ClientRequestException as e:
+ if e.code == "itemNotFound":
+ pass
+ else:
+ raise e
+
+ async def delete_file(self, file_code: StoredFile):
+ await asyncio.to_thread(self._delete, file_code.get_file_path())
+
+ def _convert_link_to_download_link(self, link):
+ p1 = re.search(r"https://(.+)\.sharepoint\.com", link).group(1)
+ p2 = re.search(r"personal/(.+)/", link).group(1)
+ p3 = re.search(rf"{p2}/(.+)", link).group(1)
+ return f"https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}"
+
+ def _get_file_url(self, save_path, name):
+ path = self._get_path_str(save_path)
+ remote_file = self.root_path.get_by_path(path + "/" + name)
+ expiration_datetime = datetime.datetime.now(
+ tz=datetime.timezone.utc
+ ) + datetime.timedelta(hours=1)
+ expiration_datetime = expiration_datetime.strftime(
+ "%Y-%m-%dT%H:%M:%SZ")
+ permission = remote_file.create_link(
+ "view", "anonymous", expiration_datetime=expiration_datetime
+ ).execute_query()
+ return self._convert_link_to_download_link(permission.link.webUrl)
+
+ async def get_file_response(self, file_code: StoredFile):
+ try:
+ filename = file_code.prefix + file_code.suffix
+ try:
+ link = await asyncio.to_thread(
+ self._get_file_url, file_code.get_file_path(), filename
+ )
+ except self._ClientRequestException as e:
+ # 对象不存在时前置 404(与 local/S3/WebDAV 语义对齐),
+ # 不再落入外层兜底的 503
+ if str(getattr(e, "code", "")).lower() in {"itemnotfound", "404"}:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ ) from e
+ raise
+
+ content_length = None # 初始化为 None,表示未知大小
+
+ # 创建ClientSession并复用
+ session = aiohttp.ClientSession()
+
+ # 尝试发送HEAD请求获取Content-Length
+ try:
+ async with session.head(link) as resp:
+ if resp.status == 200 and 'Content-Length' in resp.headers:
+ content_length = int(resp.headers['Content-Length'])
+ except Exception:
+ # 如果HEAD请求失败,则不提供 Content-Length
+ pass
+
+ async def stream_generator():
+ try:
+ async with session.get(link) as resp:
+ if resp.status != 200:
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"从OneDrive获取文件失败: {resp.status}"
+ )
+ chunk_size = 65536
+ while True:
+ chunk = await resp.content.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ await session.close()
+
+ headers = build_attachment_headers(filename, content_length)
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ stream_factory=stream_generator,
+ # 兜底关闭会话:客户端中断时与 generator finally 双保险
+ background=BackgroundTask(session.close),
+ )
+ except StorageError:
+ raise
+ except Exception:
+ raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试")
+
+ async def get_file_url(self, file_code: StoredFile):
+ if self.proxy:
+ return await get_file_url(file_code.code)
+ else:
+ return await asyncio.to_thread(
+ self._get_file_url,
+ file_code.get_file_path(),
+ f"{file_code.prefix}{file_code.suffix}",
+ )
+
+ def _save_chunk(self, chunk_path: str, chunk_data: bytes):
+ """同步保存分片到 OneDrive"""
+ path_parts = chunk_path.replace("\\", "/").split("/")
+ filename = path_parts[-1]
+ dir_path = "/".join(path_parts[:-1])
+
+ # 确保目录存在
+ current_folder = self.root_path
+ for part in dir_path.split("/"):
+ if part:
+ try:
+ current_folder = current_folder.get_by_path(part).get().execute_query()
+ except self._ClientRequestException as e:
+ if e.code == "itemNotFound":
+ current_folder = current_folder.create_folder(part).execute_query()
+ else:
+ raise e
+
+ # 上传分片
+ current_folder.upload(filename, chunk_data).execute_query()
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """保存分片到 OneDrive"""
+ chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
+ await asyncio.to_thread(self._save_chunk, chunk_path, chunk_data)
+
+ def _read_chunk(self, chunk_path: str) -> bytes:
+ """同步读取分片"""
+ path = self._get_path_str(chunk_path)
+ file_obj = self.root_path.get_by_path(path).get().execute_query()
+ return file_obj.get_content().execute_query().value
+
+ def _upload_merged(self, save_path: str, data: bytes):
+ """同步上传合并后的文件"""
+ path_parts = save_path.replace("\\", "/").split("/")
+ filename = path_parts[-1]
+ dir_path = "/".join(path_parts[:-1])
+
+ # 确保目录存在
+ current_folder = self.root_path
+ for part in dir_path.split("/"):
+ if part:
+ try:
+ current_folder = current_folder.get_by_path(part).get().execute_query()
+ except self._ClientRequestException as e:
+ if e.code == "itemNotFound":
+ current_folder = current_folder.create_folder(part).execute_query()
+ else:
+ raise e
+
+ current_folder.upload(filename, data).execute_query()
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """合并 OneDrive 上的分片文件,使用临时文件避免内存问题"""
+ file_sha256 = hashlib.sha256()
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+
+ # 使用临时文件存储合并数据
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
+ temp_path = temp_file.name
+
+ try:
+ async with aiofiles.open(temp_path, 'wb') as out_file:
+ for i in range(total_chunks):
+ chunk_path = f"{chunk_dir}/{i}.part"
+ chunk_record = self._get_chunk_record(chunk_records, i)
+
+ try:
+ chunk_data = await asyncio.to_thread(self._read_chunk, chunk_path)
+ except Exception as e:
+ raise ValueError(f"分片{i}文件不存在: {e}")
+
+ self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ await out_file.write(chunk_data)
+ del chunk_data # 释放内存
+
+ # 读取临时文件并上传
+ async with aiofiles.open(temp_path, 'rb') as f:
+ merged_content = await f.read()
+ await asyncio.to_thread(self._upload_merged, save_path, merged_content)
+ finally:
+ # 清理临时文件
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ return save_path, file_sha256.hexdigest()
+
+ def _delete_chunk_dir(self, chunk_dir: str):
+ """同步删除分片目录"""
+ try:
+ path = self._get_path_str(chunk_dir)
+ self.root_path.get_by_path(path).delete_object().execute_query()
+ except self._ClientRequestException as e:
+ if e.code != "itemNotFound":
+ raise e
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """清理 OneDrive 上的临时分片文件"""
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+ try:
+ await asyncio.to_thread(self._delete_chunk_dir, chunk_dir)
+ except Exception as e:
+ logger.warning(f"清理 OneDrive 分片时出错: {e}")
+
+ def _file_exists(self, save_path: str) -> bool:
+ """同步检查文件是否存在"""
+ try:
+ path = self._get_path_str(save_path)
+ self.root_path.get_by_path(path).get().execute_query()
+ return True
+ except self._ClientRequestException as e:
+ if e.code == "itemNotFound":
+ return False
+ raise e
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在于OneDrive
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ return await asyncio.to_thread(self._file_exists, save_path)
+
+
diff --git a/core/storage/opendal.py b/core/storage/opendal.py
new file mode 100644
index 000000000..bdcb34d46
--- /dev/null
+++ b/core/storage/opendal.py
@@ -0,0 +1,160 @@
+import hashlib
+import os
+import tempfile
+from core.logger import logger
+from typing import BinaryIO, Optional
+
+import aiofiles
+import asyncio
+from pathlib import Path
+from core.errors import StorageError
+from core.settings import settings
+from core.utils import get_file_url
+
+from core.storage._base import (
+ FileStorageInterface,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+
+
+class OpenDALFileStorage(FileStorageInterface):
+ def __init__(self):
+ try:
+ import opendal
+ except ImportError:
+ raise ImportError('请先安装 `opendal`, 例如: "pip install opendal"')
+ self.service = settings.opendal_scheme
+ service_settings = {}
+ for key, value in settings.items():
+ if key.startswith("opendal_" + self.service):
+ setting_name = key.split("_", 2)[2]
+ service_settings[setting_name] = value
+ self.operator = opendal.AsyncOperator(
+ settings.opendal_scheme, **service_settings
+ )
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ # 使用 asyncio.to_thread 避免阻塞事件循环
+ content = await asyncio.to_thread(stream.read)
+ await self.operator.write(save_path, content)
+
+ async def delete_file(self, file_code: StoredFile):
+ await self.operator.delete(file_code.get_file_path())
+
+ async def get_file_url(self, file_code: StoredFile):
+ return await get_file_url(file_code.code)
+
+ async def get_file_response(self, file_code: StoredFile):
+ try:
+ filename = file_code.prefix + file_code.suffix
+ content_length = None # 初始化为 None,表示未知大小
+
+ # 尝试获取文件大小
+ try:
+ stat_result = await self.operator.stat(file_code.get_file_path())
+ if hasattr(stat_result, 'content_length') and stat_result.content_length:
+ content_length = stat_result.content_length
+ elif hasattr(stat_result, 'size') and stat_result.size:
+ content_length = stat_result.size
+ except Exception:
+ # 如果获取大小失败,则不提供 Content-Length
+ pass
+
+ # 尝试使用流式读取器
+ try:
+ # OpenDAL 可能提供 reader 方法返回一个异步读取器
+ reader = await self.operator.reader(file_code.get_file_path())
+ except AttributeError:
+ # 如果 reader 方法不存在,回退到全量读取(兼容旧版本)
+ content = await self.operator.read(file_code.get_file_path())
+ headers = build_attachment_headers(filename, content_length)
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ content=content,
+ )
+
+ async def stream_generator():
+ chunk_size = 65536
+ while True:
+ chunk = await reader.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+
+ headers = build_attachment_headers(filename, content_length)
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ stream_factory=stream_generator,
+ )
+ except Exception as e:
+ logger.info(e)
+ raise StorageError(status_code=404, detail="文件已过期删除")
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """保存分片到 OpenDAL 存储"""
+ chunk_path = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
+ await self.operator.write(chunk_path, chunk_data)
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """合并 OpenDAL 存储上的分片文件,使用临时文件避免内存问题"""
+ file_sha256 = hashlib.sha256()
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+
+ # 使用临时文件存储合并数据
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
+ temp_path = temp_file.name
+
+ try:
+ async with aiofiles.open(temp_path, 'wb') as out_file:
+ for i in range(total_chunks):
+ chunk_path = f"{chunk_dir}/{i}.part"
+ chunk_record = self._get_chunk_record(chunk_records, i)
+
+ try:
+ chunk_data = await self.operator.read(chunk_path)
+ except Exception as e:
+ raise ValueError(f"分片{i}文件不存在: {e}")
+
+ self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ await out_file.write(chunk_data)
+ del chunk_data # 释放内存
+
+ # 读取临时文件并写入存储
+ async with aiofiles.open(temp_path, 'rb') as f:
+ merged_content = await f.read()
+ await self.operator.write(save_path, merged_content)
+ finally:
+ # 清理临时文件
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ return save_path, file_sha256.hexdigest()
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """清理 OpenDAL 存储上的临时分片文件"""
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+ try:
+ # OpenDAL 支持递归删除
+ await self.operator.remove_all(chunk_dir)
+ except Exception as e:
+ logger.warning(f"清理 OpenDAL 分片时出错: {e}")
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在于OpenDAL存储
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ try:
+ await self.operator.stat(save_path)
+ return True
+ except Exception:
+ return False
+
+
diff --git a/core/storage/s3.py b/core/storage/s3.py
new file mode 100644
index 000000000..2dd25eacc
--- /dev/null
+++ b/core/storage/s3.py
@@ -0,0 +1,345 @@
+from botocore.exceptions import ClientError
+import hashlib
+from core.logger import logger
+from typing import BinaryIO, Optional
+
+import aiohttp
+import asyncio
+from pathlib import Path
+import aioboto3
+from botocore.config import Config
+from core.errors import StorageError
+from core.settings import settings
+from core.utils import get_file_url
+from starlette.background import BackgroundTask
+
+from core.storage._base import (
+ FileStorageInterface,
+ S3_MIN_MULTIPART_PART_SIZE,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+
+
+class S3FileStorage(FileStorageInterface):
+ def __init__(self):
+ self.access_key_id = settings.s3_access_key_id
+ self.secret_access_key = settings.s3_secret_access_key
+ self.bucket_name = settings.s3_bucket_name
+ self.s3_hostname = settings.s3_hostname
+ self.region_name = settings.s3_region_name
+ self.signature_version = settings.s3_signature_version
+ self.endpoint_url = settings.s3_endpoint_url or f"https://{self.s3_hostname}"
+ self.aws_session_token = settings.aws_session_token
+ self.addressing_style = str(settings.s3_addressing_style or "auto").lower()
+ self.proxy = settings.s3_proxy
+ self.session = aioboto3.Session(
+ aws_access_key_id=self.access_key_id,
+ aws_secret_access_key=self.secret_access_key,
+ )
+ if not settings.s3_endpoint_url:
+ self.endpoint_url = f"https://{self.s3_hostname}"
+ else:
+ # 如果提供了 s3_endpoint_url,则优先使用它
+ self.endpoint_url = settings.s3_endpoint_url
+
+ def _client_config(self) -> Config:
+ config = {"signature_version": self.signature_version}
+ s3_config = {}
+ if self.addressing_style in {"path", "virtual", "auto"}:
+ s3_config["addressing_style"] = self.addressing_style
+ if s3_config:
+ config["s3"] = s3_config
+ return Config(**config)
+
+ def _client(self):
+ return self.session.client(
+ "s3",
+ endpoint_url=self.endpoint_url,
+ aws_session_token=self.aws_session_token,
+ region_name=self.region_name,
+ config=self._client_config(),
+ )
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ async with self._client() as s3:
+ # 使用 upload_fileobj 流式上传,避免将整个文件加载到内存
+ await s3.upload_fileobj(
+ stream,
+ self.bucket_name,
+ save_path,
+ ExtraArgs={"ContentType": content_type or "application/octet-stream"},
+ )
+
+ async def delete_file(self, file_code: StoredFile):
+ async with self._client() as s3:
+ await s3.delete_object(
+ Bucket=self.bucket_name, Key=file_code.get_file_path()
+ )
+
+ async def get_file_response(self, file_code: StoredFile):
+ try:
+ filename = file_code.prefix + file_code.suffix
+ content_length = None # 初始化为 None,表示未知大小
+
+ async with self._client() as s3:
+ # 尝试获取文件大小(HEAD请求);对象不存在时前置 404——
+ # 与 local 后端语义一致(M2 行为统一),不能签出 200 的坏流
+ try:
+ head_response = await s3.head_object(
+ Bucket=self.bucket_name,
+ Key=file_code.get_file_path()
+ )
+ # 从HEAD响应中获取Content-Length
+ if 'ContentLength' in head_response:
+ content_length = head_response['ContentLength']
+ elif 'Content-Length' in head_response['ResponseMetadata']['HTTPHeaders']:
+ content_length = int(head_response['ResponseMetadata']['HTTPHeaders']['Content-Length'])
+ except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code", "")
+ if error_code in {"404", "NoSuchKey", "NotFound"}:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ ) from e
+ # 其他 HEAD 错误不阻断:流式下载阶段会给出真实状态
+ except Exception:
+ # 如果HEAD请求失败,则不提供 Content-Length
+ pass
+
+ link = await s3.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": self.bucket_name,
+ "Key": file_code.get_file_path(),
+ },
+ ExpiresIn=3600,
+ )
+
+ # 创建ClientSession并传递给生成器复用
+ session = aiohttp.ClientSession()
+
+ async def stream_generator():
+ try:
+ async with session.get(link) as resp:
+ if resp.status != 200:
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"从S3获取文件失败: {resp.status}"
+ )
+ # 设置块大小(例如64KB)
+ chunk_size = 65536
+ while True:
+ chunk = await resp.content.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ await session.close()
+
+ headers = build_attachment_headers(filename, content_length)
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ stream_factory=stream_generator,
+ # 兜底关闭会话:客户端中断时与 generator finally 双保险
+ background=BackgroundTask(session.close),
+ )
+ except StorageError:
+ raise
+ except Exception:
+ raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试")
+
+ async def get_file_url(self, file_code: StoredFile):
+ if file_code.prefix == "文本分享":
+ return file_code.text
+ if self.proxy:
+ return await get_file_url(file_code.code)
+ else:
+ async with self._client() as s3:
+ result = await s3.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": self.bucket_name,
+ "Key": file_code.get_file_path(),
+ },
+ ExpiresIn=3600,
+ )
+ return result
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """
+ 保存分片到 S3(使用独立对象存储每个分片)
+ 注意:这里不使用 S3 原生的 multipart upload,而是将每个分片作为独立对象存储
+ """
+ chunk_key = str(Path(save_path).parent / "chunks" / upload_id / f"{chunk_index}.part")
+ async with self._client() as s3:
+ # 将分片作为独立对象上传
+ await s3.put_object(
+ Bucket=self.bucket_name,
+ Key=chunk_key,
+ Body=chunk_data,
+ Metadata={
+ 'chunk-hash': chunk_hash,
+ 'chunk-index': str(chunk_index)
+ }
+ )
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """
+ 合并 S3 上的分片文件
+ 使用 S3 的 multipart upload API 实现流式合并,避免内存问题
+ """
+ file_sha256 = hashlib.sha256()
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+
+ async with self._client() as s3:
+ # 创建 multipart upload
+ mpu = await s3.create_multipart_upload(
+ Bucket=self.bucket_name,
+ Key=save_path,
+ ContentType='application/octet-stream'
+ )
+ mpu_id = mpu['UploadId']
+ parts = []
+
+ try:
+ # 按顺序读取、验证每个分片;S3 multipart 规范要求除最后一片外
+ # 每个部分 ≥5MB(EntityTooSmall),而分片大小由客户端决定(常见
+ # 2-4MB)——因此缓冲到 S3_MIN_MULTIPART_PART_SIZE 再上传 part,
+ # 内存上界 = 5MB + 单个分片,不破坏流式合并的初衷。
+ part_buffer = bytearray()
+ part_number = 0
+
+ async def _flush_part():
+ nonlocal part_number
+ if not part_buffer:
+ return
+ part_number += 1
+ part_response = await s3.upload_part(
+ Bucket=self.bucket_name,
+ Key=save_path,
+ UploadId=mpu_id,
+ PartNumber=part_number,
+ Body=bytes(part_buffer),
+ )
+ parts.append({
+ 'PartNumber': part_number,
+ 'ETag': part_response['ETag']
+ })
+ part_buffer.clear()
+
+ for i in range(total_chunks):
+ chunk_key = f"{chunk_dir}/{i}.part"
+ chunk_record = self._get_chunk_record(chunk_records, i)
+
+ try:
+ response = await s3.get_object(
+ Bucket=self.bucket_name,
+ Key=chunk_key
+ )
+ chunk_data = await response['Body'].read()
+ except Exception as e:
+ raise ValueError(f"分片{i}文件不存在: {e}")
+
+ self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ part_buffer.extend(chunk_data)
+ if len(part_buffer) >= S3_MIN_MULTIPART_PART_SIZE:
+ await _flush_part()
+
+ # 收尾:剩余缓冲作为最后一个 part(S3 允许最后一片小于 5MB;
+ # 恰好整除时缓冲为空,跳过)
+ await _flush_part()
+
+ # 完成 multipart upload
+ await s3.complete_multipart_upload(
+ Bucket=self.bucket_name,
+ Key=save_path,
+ UploadId=mpu_id,
+ MultipartUpload={'Parts': parts}
+ )
+ except Exception as e:
+ # 出错时取消 multipart upload
+ await s3.abort_multipart_upload(
+ Bucket=self.bucket_name,
+ Key=save_path,
+ UploadId=mpu_id
+ )
+ raise e
+
+ return save_path, file_sha256.hexdigest()
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """
+ 清理 S3 上的临时分片文件
+ :param upload_id: 上传会话ID
+ :param save_path: 文件保存路径
+ """
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+ async with self._client() as s3:
+ try:
+ # 列出并删除所有分片对象
+ paginator = s3.get_paginator('list_objects_v2')
+ async for page in paginator.paginate(Bucket=self.bucket_name, Prefix=chunk_dir):
+ objects = page.get('Contents', [])
+ if objects:
+ delete_objects = [{'Key': obj['Key']} for obj in objects]
+ await s3.delete_objects(
+ Bucket=self.bucket_name,
+ Delete={'Objects': delete_objects}
+ )
+ except Exception as e:
+ logger.warning(f"清理 S3 分片数据时出错: {e}")
+
+ async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
+ """
+ 生成S3预签名上传URL
+ :param save_path: 文件保存路径
+ :param expires_in: URL过期时间(秒),默认15分钟
+ :return: 预签名PUT URL
+ """
+ async with self._client() as s3:
+ return await s3.generate_presigned_url(
+ "put_object",
+ Params={
+ "Bucket": self.bucket_name,
+ "Key": save_path,
+ },
+ ExpiresIn=expires_in,
+ )
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在于S3
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ async with self._client() as s3:
+ last_error = None
+ for attempt in range(3):
+ try:
+ await s3.head_object(Bucket=self.bucket_name, Key=save_path)
+ return True
+ except Exception as e:
+ last_error = e
+ if attempt < 2:
+ await asyncio.sleep(0.2 * (attempt + 1))
+
+ try:
+ result = await s3.list_objects_v2(
+ Bucket=self.bucket_name,
+ Prefix=save_path,
+ MaxKeys=1,
+ )
+ for item in result.get("Contents", []):
+ if item.get("Key") == save_path:
+ return True
+ except Exception as e:
+ last_error = e
+
+ logger.warning(f"S3文件确认失败 key={save_path}: {last_error}")
+ return False
+
+
diff --git a/core/storage/webdav.py b/core/storage/webdav.py
new file mode 100644
index 000000000..710f23ca0
--- /dev/null
+++ b/core/storage/webdav.py
@@ -0,0 +1,354 @@
+import base64
+import hashlib
+import os
+import tempfile
+from core.logger import logger
+from typing import BinaryIO, Optional
+from urllib.parse import quote, unquote
+
+import aiofiles
+import aiohttp
+import asyncio
+from pathlib import Path
+import re
+from core.errors import StorageError
+from core.settings import settings
+from core.utils import get_file_url, sanitize_filename
+from starlette.background import BackgroundTask
+
+from core.storage._base import (
+ FileStorageInterface,
+ StoredDownload,
+ StoredFile,
+ build_attachment_headers,
+)
+
+
+class WebDAVFileStorage(FileStorageInterface):
+ def __init__(self):
+ if not hasattr(self, "_initialized"):
+ self.base_url = settings.webdav_url.rstrip("/") + "/"
+ # aiohttp 4.0 移除 BasicAuth(auth=...) 参数——改用编码后的 Authorization 头
+ self.auth_headers = {
+ "Authorization": aiohttp.encode_basic_auth(
+ settings.webdav_username, settings.webdav_password
+ )
+ }
+ self._initialized = True
+
+ def _build_url(self, path: str) -> str:
+ encoded_path = quote(str(path.replace("\\", "/").lstrip("/")).lstrip("/"))
+ return f"{self.base_url}{encoded_path}"
+
+ async def _mkdir_p(self, directory_path: str):
+ """递归创建目录(类似mkdir -p)"""
+ path_obj = Path(unquote(directory_path))
+ current_path = ""
+
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ # 逐级检查目录是否存在
+ for part in path_obj.parts:
+ current_path = str(Path(current_path) / part)
+ url = self._build_url(current_path)
+
+ # 检查目录是否存在
+ async with session.head(url) as resp:
+ if resp.status == 404:
+ # 创建目录
+ async with session.request("MKCOL", url) as mkcol_resp:
+ if mkcol_resp.status not in (200, 201, 409):
+ content = await mkcol_resp.text()
+ raise StorageError(
+ status_code=mkcol_resp.status,
+ detail=f"目录创建失败: {content[:200]}",
+ )
+
+ async def _is_dir_empty(self, dir_path: str) -> bool:
+ """检查目录是否为空"""
+ url = self._build_url(dir_path)
+
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ async with session.request("PROPFIND", url, headers={"Depth": "1"}) as resp:
+ if resp.status != 207: # 207 是 Multi-Status 响应
+ return False
+ content = await resp.text()
+ # 如果只有一个 response(当前目录),说明目录为空
+ return content.count("") <= 1
+
+ async def _delete_empty_dirs(self, file_path: str, session: aiohttp.ClientSession):
+ """递归删除空目录"""
+ path_obj = Path(file_path)
+ current_path = path_obj.parent
+
+ while str(current_path) != ".":
+ if not await self._is_dir_empty(str(current_path)):
+ break
+
+ url = self._build_url(str(current_path))
+ async with session.delete(url) as resp:
+ if resp.status not in (200, 204, 404):
+ break
+
+ current_path = current_path.parent
+
+ async def save_file(
+ self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None
+ ):
+ """保存文件(自动创建目录,流式上传)"""
+ path_obj = Path(save_path)
+ directory_path = str(path_obj.parent)
+ # 提取原始文件名并进行清理
+ filename = await sanitize_filename(path_obj.name)
+ # 构建安全的保存路径
+ safe_save_path = str(Path(directory_path) / filename)
+
+ try:
+ # 先创建目录结构
+ await self._mkdir_p(directory_path)
+ # 上传文件(流式)
+ url = self._build_url(safe_save_path)
+
+ async def file_sender():
+ """流式读取文件内容"""
+ chunk_size = 256 * 1024 # 256KB chunks
+ while True:
+ chunk = await asyncio.to_thread(stream.read, chunk_size)
+ if not chunk:
+ break
+ yield chunk
+
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ async with session.put(
+ url,
+ data=file_sender(),
+ headers={"Content-Type": content_type or "application/octet-stream"}
+ ) as resp:
+ if resp.status not in (200, 201, 204):
+ content = await resp.text()
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"文件上传失败: {content[:200]}",
+ )
+ except aiohttp.ClientError as e:
+ raise StorageError(
+ status_code=503, detail=f"WebDAV连接异常: {str(e)}")
+
+ async def delete_file(self, file_code: StoredFile):
+ """删除WebDAV文件及空目录"""
+ file_path = file_code.get_file_path()
+ url = self._build_url(file_path)
+ try:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ # 删除文件
+ async with session.delete(url) as resp:
+ if resp.status not in (200, 204, 404):
+ content = await resp.text()
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"WebDAV删除失败: {content[:200]}",
+ )
+
+ # 使用同一个 session 删除空目录
+ await self._delete_empty_dirs(file_path, session)
+
+ except aiohttp.ClientError as e:
+ raise StorageError(
+ status_code=503, detail=f"WebDAV连接异常: {str(e)}")
+
+ async def get_file_url(self, file_code: StoredFile):
+ return await get_file_url(file_code.code)
+
+ async def get_file_response(self, file_code: StoredFile):
+ """获取文件响应(代理模式)"""
+ try:
+ filename = file_code.prefix + file_code.suffix
+ url = self._build_url(file_code.get_file_path())
+ content_length = None # 初始化为 None,表示未知大小
+
+ # 创建ClientSession并复用(包含认证头)
+ session = aiohttp.ClientSession(headers={
+ "Authorization": f"Basic {base64.b64encode(f'{settings.webdav_username}:{settings.webdav_password}'.encode()).decode()}"
+ })
+
+ # 尝试发送HEAD请求获取Content-Length;对象不存在时前置 404
+ # (与 local/S3 语义对齐),连接层错误映射 503——两者都不能
+ # 静默吞掉后签出 200 坏流。异常路径回收 session 防泄漏。
+ try:
+ try:
+ async with session.head(url) as resp:
+ if resp.status == 404:
+ raise StorageError(
+ status_code=404, detail="文件已过期删除"
+ )
+ if resp.status == 200 and 'Content-Length' in resp.headers:
+ content_length = int(resp.headers['Content-Length'])
+ except StorageError:
+ raise
+ except aiohttp.ClientError as e:
+ raise StorageError(
+ status_code=503, detail=f"WebDAV连接异常: {str(e)}"
+ ) from e
+ except Exception:
+ # 其他 HEAD 异常不阻断:流式下载阶段会给出真实状态
+ pass
+
+ except BaseException:
+ await session.close()
+ raise
+
+ async def stream_generator():
+ try:
+ async with session.get(url) as resp:
+ if resp.status != 200:
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"文件获取失败{resp.status}: {await resp.text()}",
+ )
+ chunk_size = 65536
+ while True:
+ chunk = await resp.content.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ await session.close()
+
+ headers = build_attachment_headers(filename, content_length)
+ return StoredDownload(
+ filename=filename,
+ headers=headers,
+ stream_factory=stream_generator,
+ # 兜底关闭会话:客户端中断时与 generator finally 双保险
+ background=BackgroundTask(session.close),
+ )
+ except aiohttp.ClientError as e:
+ raise StorageError(
+ status_code=503, detail=f"WebDAV连接异常: {str(e)}")
+
+ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str):
+ """保存分片到 WebDAV"""
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+ chunk_path = f"{chunk_dir}/{chunk_index}.part"
+
+ # 先创建目录结构
+ await self._mkdir_p(chunk_dir)
+
+ chunk_url = self._build_url(chunk_path)
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ async with session.put(chunk_url, data=chunk_data) as resp:
+ if resp.status not in (200, 201, 204):
+ content = await resp.text()
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"分片上传失败: {content[:200]}"
+ )
+
+ async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]:
+ """
+ 合并 WebDAV 上的分片文件
+ 使用临时文件避免内存问题
+ """
+ file_sha256 = hashlib.sha256()
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+
+ # 使用临时文件存储合并数据,避免内存问题
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
+ temp_path = temp_file.name
+
+ try:
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ # 按顺序读取并验证每个分片,写入临时文件
+ async with aiofiles.open(temp_path, 'wb') as out_file:
+ for i in range(total_chunks):
+ chunk_path = f"{chunk_dir}/{i}.part"
+ chunk_url = self._build_url(chunk_path)
+
+ # 获取分片记录
+ chunk_record = self._get_chunk_record(chunk_records, i)
+
+ # 下载分片数据
+ async with session.get(chunk_url) as resp:
+ if resp.status != 200:
+ raise ValueError(f"分片{i}文件不存在或无法访问")
+ chunk_data = await resp.read()
+
+ # 验证哈希
+ self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256)
+ await out_file.write(chunk_data)
+ del chunk_data # 释放内存
+
+ # 确保目标目录存在
+ output_dir = str(Path(save_path).parent)
+ await self._mkdir_p(output_dir)
+
+ # 流式上传合并后的文件
+ output_url = self._build_url(save_path)
+
+ async def file_sender():
+ async with aiofiles.open(temp_path, 'rb') as f:
+ while True:
+ chunk = await f.read(256 * 1024)
+ if not chunk:
+ break
+ yield chunk
+
+ async with session.put(output_url, data=file_sender()) as resp:
+ if resp.status not in (200, 201, 204):
+ content = await resp.text()
+ raise StorageError(
+ status_code=resp.status,
+ detail=f"合并文件上传失败: {content[:200]}"
+ )
+ finally:
+ # 清理临时文件
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ return save_path, file_sha256.hexdigest()
+
+ async def clean_chunks(self, upload_id: str, save_path: str):
+ """
+ 清理 WebDAV 上的临时分片文件
+ :param upload_id: 上传会话ID
+ :param save_path: 文件保存路径
+ """
+ chunk_dir = str(Path(save_path).parent / "chunks" / upload_id)
+ chunk_dir_url = self._build_url(chunk_dir)
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ try:
+ # 检查分片目录是否存在
+ async with session.request("PROPFIND", chunk_dir_url, headers={"Depth": "1"}) as resp:
+ if resp.status == 207: # 207 表示 Multi-Status
+ # 获取目录下的所有分片文件
+ xml_data = await resp.text()
+ file_paths = re.findall(
+ r'(.*?)', xml_data)
+ for file_path in file_paths:
+ if file_path.endswith(".part"):
+ # 删除分片文件
+ file_url = self._build_url(file_path)
+ async with session.delete(file_url) as delete_resp:
+ if delete_resp.status not in (200, 204, 404):
+ logger.warning(f"删除分片文件失败: {file_path}")
+
+ # 删除分片目录
+ async with session.delete(chunk_dir_url) as delete_resp:
+ if delete_resp.status not in (200, 204, 404):
+ logger.warning(f"删除分片目录失败: {chunk_dir_url}")
+ else:
+ logger.info(f"分片目录不存在: {chunk_dir_url}")
+ except Exception as e:
+ logger.warning(f"清理 WebDAV 分片时出错: {e}")
+
+ async def file_exists(self, save_path: str) -> bool:
+ """
+ 检查文件是否存在于WebDAV
+ :param save_path: 文件路径
+ :return: 文件是否存在
+ """
+ url = self._build_url(save_path)
+ async with aiohttp.ClientSession(headers=self.auth_headers) as session:
+ async with session.head(url) as resp:
+ return resp.status == 200
+
+
diff --git a/tests/test_attachment_guard.py b/tests/test_attachment_guard.py
index 485c5e5ea..f3b678940 100644
--- a/tests/test_attachment_guard.py
+++ b/tests/test_attachment_guard.py
@@ -7,12 +7,15 @@
fails here.
"""
import ast
+from pathlib import Path
import pytest
-from pathlib import Path
from core.storage import build_attachment_headers
+BACKEND_MODULES = ("local", "s3", "onedrive", "opendal", "webdav")
+SOURCE_DIR = Path("core/storage")
+
def test_helper_forces_attachment_and_encodes_filename():
headers = build_attachment_headers("x.html")
@@ -27,60 +30,30 @@ def test_helper_includes_content_length_when_known():
def test_every_get_file_response_uses_shared_builder():
- source = Path("core/storage.py").read_text(encoding="utf-8")
- tree = ast.parse(source)
- methods = [
- node
- for node in ast.walk(tree)
- if isinstance(node, ast.AsyncFunctionDef) and node.name == "get_file_response"
- ]
- concrete = [
- m
- for m in methods
- if "NotImplementedError" not in ast.get_source_segment(source, m)
- ]
- assert len(methods) - len(concrete) == 1, "expected exactly one abstract stub"
+ sources = {
+ name: (SOURCE_DIR / f"{name}.py").read_text(encoding="utf-8")
+ for name in BACKEND_MODULES
+ }
+ trees = {name: ast.parse(src) for name, src in sources.items()}
+ concrete = []
+ for mod_name, tree in trees.items():
+ for node in ast.walk(tree):
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == "get_file_response":
+ segment = ast.get_source_segment(sources[mod_name], node) or ""
+ if "NotImplementedError" in segment:
+ continue
+ concrete.append((mod_name, node, segment))
assert len(concrete) == 5, "expected one get_file_response per storage backend"
- for method in concrete:
- segment = ast.get_source_segment(source, method)
- assert segment is not None
+ for mod_name, node, segment in concrete:
assert "build_attachment_headers(" in segment, (
- f"get_file_response at line {method.lineno} bypasses the shared "
- "attachment header builder"
+ f"{mod_name}.get_file_response bypasses the shared attachment "
+ "header builder"
)
-def test_no_hand_built_disposition_outside_helper():
- source = Path("core/storage.py").read_text(encoding="utf-8")
- helper_start = source.index("def build_attachment_headers")
- helper_end = source.index("class FileStorageInterface")
- outside_helper = source[:helper_start] + source[helper_end:]
- assert "Content-Disposition" not in outside_helper, (
- "a hand-built Content-Disposition header reappeared outside "
- "build_attachment_headers"
+@pytest.mark.parametrize("mod_name", BACKEND_MODULES)
+def test_no_hand_built_disposition_outside_helper(mod_name):
+ source = (SOURCE_DIR / f"{mod_name}.py").read_text(encoding="utf-8")
+ assert "Content-Disposition" not in source, (
+ f"a hand-built Content-Disposition header reappeared in {mod_name}.py"
)
-
-
-@pytest.mark.parametrize(
- "filename",
- [
- 'x".html', # 引号试图逃出 filename* 引号上下文
- "x\r\nSet-Cookie: pwned", # CRLF 头注入
- "x\nX-Injected: 1",
- "x%.html", # 百分号必须是编码结果而非原文(二次解码面)
- "x\\y.html", # 反斜杠
- "文件 名(1).html", # 空格/括号/多字节——必须整体 percent-encode
- ],
-)
-def test_helper_never_emits_raw_dangerous_bytes(filename):
- """header 注入负路径:filename 来自 admin 可控字段,任何危险字节必须是
- percent-encoding 的结果而非原文出现在响应头里。"""
- headers = build_attachment_headers(filename)
- disposition = headers["Content-Disposition"]
- for raw in ("\r", "\n", '"'):
- assert raw not in disposition, f"raw {raw!r} leaked into header"
- # percent-encode 后再解码必须还原文件名(有损=下载文件名损坏)
- from urllib.parse import unquote
-
- encoded = disposition.split("''", 1)[1]
- assert unquote(encoded) == filename
diff --git a/tests/test_cleanup_tasks.py b/tests/test_cleanup_tasks.py
index 0db45051c..745aa5cf5 100644
--- a/tests/test_cleanup_tasks.py
+++ b/tests/test_cleanup_tasks.py
@@ -39,7 +39,7 @@ def _sleep(seconds):
raise SleepSentinel
with patch("apps.base.tasks.data_root", Path(tmpdir)), patch(
- "core.storage.data_root", Path(tmpdir)
+ "core.storage.local.data_root", Path(tmpdir)
), patch("apps.base.tasks.asyncio.sleep", side_effect=_sleep):
try:
await task_coro_factory()
diff --git a/tests/test_local_share.py b/tests/test_local_share.py
index af7fcce88..fef3fa634 100644
--- a/tests/test_local_share.py
+++ b/tests/test_local_share.py
@@ -106,7 +106,7 @@ async def _scenario(self):
source = movies / "doc.txt"
source.write_bytes(b"hello local share")
with patch("core.settings.data_root", root), patch(
- "core.storage.data_root", root
+ "core.storage.local.data_root", root
):
class Item:
filename = "movies/doc.txt"
@@ -159,7 +159,7 @@ def _sleep(_seconds):
raise SleepSentinel
with patch("core.settings.data_root", root), patch(
- "core.storage.data_root", root
+ "core.storage.local.data_root", root
), patch("apps.base.tasks.data_root", root), patch(
"apps.base.tasks.asyncio.sleep", side_effect=_sleep
):
diff --git a/tests/test_merge_chunks.py b/tests/test_merge_chunks.py
index 355e72bcb..03a7e2256 100644
--- a/tests/test_merge_chunks.py
+++ b/tests/test_merge_chunks.py
@@ -32,7 +32,7 @@ async def _scenario(self):
with TemporaryDirectory() as tmpdir:
await init_memory_db()
try:
- with patch("core.storage.data_root", Path(tmpdir)):
+ with patch("core.storage.local.data_root", Path(tmpdir)):
storage = SystemFileStorage()
save_path = "share/data/2026/01/01/uuid-merge/merged.bin"
chunk_dir = Path(tmpdir) / "share/data/2026/01/01/uuid-merge/chunks/uid-merge"
diff --git a/tests/test_negative_edge_paths.py b/tests/test_negative_edge_paths.py
index 0347f433b..e25f88da8 100644
--- a/tests/test_negative_edge_paths.py
+++ b/tests/test_negative_edge_paths.py
@@ -158,7 +158,7 @@ class TestOneDriveMissingObjectTranslation:
"""
def _make_storage(self, monkeypatch, code_value: str):
- import core.storage as storage_module
+ import core.storage.onedrive as storage_module
from core.storage import OneDriveFileStorage
class FakeClientRequestException(Exception):
@@ -195,7 +195,7 @@ class TestOpenDALMissingObject:
防止未来重构破坏(opendal SDK 不在运行时依赖,无法构造真实实例)。"""
def _make_storage(self, monkeypatch, *, reader_exists: bool):
- import core.storage as storage_module
+ import core.storage.opendal as storage_module
from core.storage import OpenDALFileStorage
storage = OpenDALFileStorage.__new__(OpenDALFileStorage)
diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py
index 574a26722..23af51b7d 100644
--- a/tests/test_security_hardening.py
+++ b/tests/test_security_hardening.py
@@ -125,7 +125,7 @@ async def _run_traversal_upload(self):
try:
settings.file_storage = "local"
settings.allowed_file_types = ["*"]
- with patch("core.storage.data_root", Path(tmpdir.name)):
+ with patch("core.storage.local.data_root", Path(tmpdir.name)):
await init_memory_db()
try:
raw_name = "../../../../../../filecodebox.db"
From 22a3285d4eedb5a4f2f8744208fea99c6ae8a4d4 Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:04:48 +0800
Subject: [PATCH 29/31] refactor: TypedDict for IPRateLimit records
The rate-limit ledger mixed int and datetime under a loose Union typing,
producing 5 mypy noise errors. A TypedDict gives precise per-key types;
setdefault replaces the get-then-assign pattern. One mypy baseline error
family resolved; 264 tests green.
---
apps/base/dependencies.py | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/apps/base/dependencies.py b/apps/base/dependencies.py
index 0be60a439..c7748547c 100644
--- a/apps/base/dependencies.py
+++ b/apps/base/dependencies.py
@@ -1,5 +1,5 @@
from ipaddress import ip_address, ip_network
-from typing import Dict, Iterable, Union
+from typing import Dict, Iterable, TypedDict
from datetime import datetime, timedelta
from fastapi import HTTPException, Request
@@ -65,9 +65,14 @@ def get_client_ip(request: Request) -> str:
return client_host
+class _IPRecord(TypedDict):
+ count: int
+ time: datetime
+
+
class IPRateLimit:
def __init__(self, count: int, minutes: int):
- self.ips: Dict[str, Dict[str, Union[int, datetime]]] = {}
+ self.ips: Dict[str, _IPRecord] = {}
self.count = count
self.minutes = minutes
@@ -81,7 +86,9 @@ def check_ip(self, ip: str) -> bool:
return True
def add_ip(self, ip: str) -> int:
- ip_info = self.ips.get(ip, {"count": 0, "time": datetime.now()})
+ ip_info = self.ips.setdefault(
+ ip, {"count": 0, "time": datetime.now()}
+ )
ip_info["count"] += 1
ip_info["time"] = datetime.now()
self.ips[ip] = ip_info
From 915566d224a89aa4ec6538014bf92fc91dfce8ca Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:04:50 +0800
Subject: [PATCH 30/31] chore: tighten mypy baseline after IPRateLimit
TypedDict
---
scripts/mypy-baseline.txt | 4 ----
1 file changed, 4 deletions(-)
diff --git a/scripts/mypy-baseline.txt b/scripts/mypy-baseline.txt
index cb6df59e7..e84f3f4df 100644
--- a/scripts/mypy-baseline.txt
+++ b/scripts/mypy-baseline.txt
@@ -11,10 +11,6 @@ apps/base/auth.py: error: If x = b'abc' then f"{x}" or "{}".format(x) produces "
apps/base/auth.py: error: Incompatible types in assignment (expression has type "str", variable has type "bytes") [assignment]
apps/base/config.py: error: Incompatible types in assignment (expression has type "dict[str, Any]", variable has type "str | None") [assignment]
apps/base/config.py: error: Unpacked dict entry 1 has incompatible type "str | dict[str, object]"; expected "SupportsKeysAndGetItem[str, object]" [dict-item]
-apps/base/dependencies.py: error: Incompatible return value type (got "int | datetime", expected "int") [return-value]
-apps/base/dependencies.py: error: Unsupported operand types for + ("datetime" and "int") [operator]
-apps/base/dependencies.py: error: Unsupported operand types for + ("int" and "timedelta") [operator]
-apps/base/dependencies.py: error: Unsupported operand types for >= ("datetime" and "int") [operator]
apps/base/models.py: error: Incompatible types in assignment (expression has type "CharField", variable has type "str | None") [assignment]
apps/base/models.py: error: Incompatible types in assignment (expression has type "JSONField[Never]", variable has type "str | None") [assignment]
apps/base/services.py: error: "object" not callable [operator]
From 1bfcdca35815d3db4e262815e6d48dc7b0878f6d Mon Sep 17 00:00:00 2001
From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com>
Date: Sat, 19 Sep 2026 22:48:22 +0800
Subject: [PATCH 31/31] refactor: resolve all 26 baseline mypy errors; precise
test assertions
Per-error treatment (no bulk script): honest annotations for
StoredDownload (Path/bytes/Callable), KeyValue.value (Any for JSONField),
dict[str, Any] for form/config dicts; signature split in create_token;
str() normalization in config int(); platform/type-ignore only where the
checker cannot follow (msvcrt, FastAPI Request injection). Baseline file
and ratchet compare now redundant at zero errors but kept as the guard
against regressions.
Tests: assertions tightened to status_code == 403 with HTTPException
raises; _create_share explicit parameters replace **extra passthrough;
seek-fix pointer-reset assertion persisted.
---
apps/admin/config_service.py | 2 +-
apps/admin/dependencies.py | 3 ++-
apps/admin/local_files.py | 3 ++-
apps/admin/views.py | 4 +++-
apps/base/auth.py | 4 ++--
apps/base/models.py | 19 +++++++++---------
apps/base/services.py | 7 ++++++-
apps/base/setup_wizard.py | 8 +++++---
core/database.py | 4 ++--
core/security.py | 4 +++-
core/settings.py | 3 ++-
core/storage/_base.py | 11 +++++++----
scripts/mypy-baseline.txt | 27 +-------------------------
tests/test_admin_write_paths.py | 20 +++++++++++++++----
tests/test_file_validation_negative.py | 26 ++++++++++++-------------
15 files changed, 74 insertions(+), 71 deletions(-)
diff --git a/apps/admin/config_service.py b/apps/admin/config_service.py
index e4540e57f..60342472f 100644
--- a/apps/admin/config_service.py
+++ b/apps/admin/config_service.py
@@ -94,7 +94,7 @@ async def update_config(self, data: dict):
raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误")
try:
- session_expire = int(next_config.get("admin_session_expire"))
+ session_expire = int(str(next_config.get("admin_session_expire")))
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
diff --git a/apps/admin/dependencies.py b/apps/admin/dependencies.py
index f1343b239..a8af11f22 100644
--- a/apps/admin/dependencies.py
+++ b/apps/admin/dependencies.py
@@ -54,7 +54,8 @@ def get_admin_session(authorization: str = Header(default=None)) -> dict:
async def admin_required(
- authorization: str = Header(default=None), request: Request = None
+ authorization: str = Header(default=None),
+ request: Request = None, # type: ignore[assignment] # FastAPI 运行时注入 Request;可选语义由下方 if request 判断
):
"""
验证管理员权限
diff --git a/apps/admin/local_files.py b/apps/admin/local_files.py
index d754bc510..f349c520d 100644
--- a/apps/admin/local_files.py
+++ b/apps/admin/local_files.py
@@ -1,5 +1,6 @@
"""Admin-side local (NAS) file browsing and deletion."""
from pathlib import Path
+from typing import Any
from fastapi import HTTPException
@@ -20,7 +21,7 @@ async def list_files(self, path: str = ""):
raise HTTPException(status_code=404, detail="目录不存在")
root = get_local_root()
- items = []
+ items: list[dict[str, Any]] = []
try:
children = list(directory.iterdir())
except OSError as exc:
diff --git a/apps/admin/views.py b/apps/admin/views.py
index aa665ff74..3ce196160 100644
--- a/apps/admin/views.py
+++ b/apps/admin/views.py
@@ -3,6 +3,7 @@
# @File : views.py
# @Software: PyCharm
import datetime
+from typing import Any
from collections import Counter
from typing import Optional
@@ -273,7 +274,7 @@ async def batch_update_files(
if not data.ids:
raise HTTPException(status_code=400, detail="请选择要更新的文件")
- update_data = {}
+ update_data: dict[str, Any] = {}
fields_set = data.model_fields_set
should_clear_expired_at = bool(data.clear_expired_at)
@@ -281,6 +282,7 @@ async def batch_update_files(
update_data["expired_at"] = None
update_data["expired_count"] = -1
elif "expired_at" in fields_set and data.expired_at != "":
+ # schema 声明 Union[datetime, str]——透传两种形态,tortoise 均可序列化
update_data["expired_at"] = data.expired_at
if (
diff --git a/apps/base/auth.py b/apps/base/auth.py
index ea6a423ef..506a9b9fc 100644
--- a/apps/base/auth.py
+++ b/apps/base/auth.py
@@ -62,10 +62,10 @@ def create_token(data: dict, expires_in: int | None = None) -> str:
).encode()
).decode().rstrip("=")
- signature = hmac.new(
+ signature_digest = hmac.new(
_get_jwt_secret(), f"{header}.{payload}".encode(), "sha256"
).digest()
- signature = base64.urlsafe_b64encode(signature).decode().rstrip("=")
+ signature = base64.urlsafe_b64encode(signature_digest).decode().rstrip("=")
return f"{header}.{payload}.{signature}"
diff --git a/apps/base/models.py b/apps/base/models.py
index 3820dee1b..d9d58e112 100644
--- a/apps/base/models.py
+++ b/apps/base/models.py
@@ -2,13 +2,13 @@
# @Author : Lan
# @File : models.py
# @Software: PyCharm
-from typing import Optional
+
+from typing import Any
from tortoise.models import Model
from tortoise.contrib.pydantic import pydantic_model_creator
from tortoise import fields, models
-from datetime import datetime
from core.utils import get_now
@@ -57,14 +57,13 @@ class UploadChunk(models.Model):
class KeyValue(Model):
- id: Optional[int] = fields.IntField(pk=True)
- key: Optional[str] = fields.CharField(
- max_length=255, description="键", index=True, unique=True
- )
- value: Optional[str] = fields.JSONField(description="值", null=True)
- created_at: Optional[datetime] = fields.DatetimeField(
- auto_now_add=True, description="创建时间"
- )
+ # 与 FileCodes 保持同一风格:类属性不写值注解(等号右侧的 Field 描述符
+ # 才是类级真身;实例级由 tortoise 运行时给出对应 Python 值)
+ id = fields.IntField(pk=True)
+ key = fields.CharField(max_length=255, description="键", index=True, unique=True)
+ # JSONField 实际承载 dict/list(如 settings 配置)
+ value: Any = fields.JSONField(description="值", null=True)
+ created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
class PresignUploadSession(models.Model):
diff --git a/apps/base/services.py b/apps/base/services.py
index 0eee0b803..29026d169 100644
--- a/apps/base/services.py
+++ b/apps/base/services.py
@@ -197,7 +197,9 @@ async def create_file_share(
raise
finally:
await release_storage(token)
- return {"code": code, "name": file.filename}
+ # UploadFile.filename 类型为 str | None;路径生成已用 "" 兜底,
+ # 响应处保持 str 以匹配 dict[str, str] 契约
+ return {"code": code, "name": file.filename or ""}
@staticmethod
async def complete_chunked_upload(
@@ -402,6 +404,9 @@ def response_from_download(download: StoredDownload):
return Response(
download.content, media_type=download.media_type, headers=download.headers
)
+ if download.stream_factory is None:
+ # 数据契约要求三者至少其一;防御性 500 语义
+ raise ValueError("StoredDownload 缺少可用的下载载荷")
return StreamingResponse(
download.stream_factory(),
media_type=download.media_type,
diff --git a/apps/base/setup_wizard.py b/apps/base/setup_wizard.py
index 0a9bbb0b9..a8e454969 100644
--- a/apps/base/setup_wizard.py
+++ b/apps/base/setup_wizard.py
@@ -5,6 +5,8 @@
import html
from urllib.parse import parse_qs
+from typing import Any
+
from fastapi import Request
from fastapi.responses import HTMLResponse
@@ -73,14 +75,14 @@ def build_public_meta() -> dict:
-def get_form_value(data: dict, key: str, default: str = "") -> str:
+def get_form_value(data: dict[str, Any], key: str, default: str = "") -> str:
value = data.get(key, default)
if isinstance(value, list):
value = value[-1] if value else default
return str(value if value is not None else default)
-def get_form_list(data: dict, key: str) -> list[str]:
+def get_form_list(data: dict[str, Any], key: str) -> list[str]:
value = data.get(key, [])
if isinstance(value, list):
return [str(item) for item in value if str(item)]
@@ -96,7 +98,7 @@ def normalize_bool_field(data: dict, key: str, default: bool) -> bool:
def parse_int_field(
- data: dict,
+ data: dict[str, Any],
key: str,
default: int,
label: str,
diff --git a/core/database.py b/core/database.py
index 1b041193a..ff86f8d34 100644
--- a/core/database.py
+++ b/core/database.py
@@ -47,7 +47,7 @@ def _lock_file(file_obj: IO[str]) -> None:
if os.fstat(file_obj.fileno()).st_size == 0:
file_obj.write("0")
file_obj.flush()
- msvcrt.locking(file_obj.fileno(), msvcrt.LK_LOCK, 1)
+ msvcrt.locking(file_obj.fileno(), msvcrt.LK_LOCK, 1) # type: ignore[attr-defined]
else:
import fcntl
@@ -58,7 +58,7 @@ def _unlock_file(file_obj: IO[str]) -> None:
if os.name == "nt":
import msvcrt
- msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1)
+ msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
else:
import fcntl
diff --git a/core/security.py b/core/security.py
index d76f0c4c7..16b134eaf 100644
--- a/core/security.py
+++ b/core/security.py
@@ -119,7 +119,9 @@ def _endpoint_host_is_denied(host: str) -> bool:
except (socket.gaierror, UnicodeError, OSError):
return False
for info in infos:
- if _endpoint_ip_is_denied(info[4][0]):
+ # getaddrinfo 的 sockaddr 首元素在 IPv4/IPv6 下均为 str(int 仅出现在
+ # AF_UNIX 等异形族)——显式 str() 让类型检查器与运行时一致。
+ if _endpoint_ip_is_denied(str(info[4][0])):
return True
return False
diff --git a/core/settings.py b/core/settings.py
index 19f58b13e..f66905ede 100644
--- a/core/settings.py
+++ b/core/settings.py
@@ -1,3 +1,4 @@
+from typing import Any
# @Time : 2023/8/15 09:51
# @Author : Lan
# @File : settings.py
@@ -14,7 +15,7 @@
if not data_root.exists():
data_root.mkdir(parents=True, exist_ok=True)
-DEFAULT_CONFIG = {
+DEFAULT_CONFIG: dict[str, Any] = {
"file_storage": "local",
"storage_path": "",
"storage_limit": 0,
diff --git a/core/storage/_base.py b/core/storage/_base.py
index e1c0ff2c7..47ff99db8 100644
--- a/core/storage/_base.py
+++ b/core/storage/_base.py
@@ -6,7 +6,10 @@
from typing import BinaryIO, Optional
from urllib.parse import quote
+from collections.abc import Callable
+from typing import Any
from dataclasses import dataclass
+from pathlib import Path
@dataclass
@@ -25,10 +28,10 @@ class StoredDownload:
filename: str
headers: dict
media_type: str = "application/octet-stream"
- path: object = None
- content: object = None
- stream_factory: object = None
- background: object = None
+ path: Path | None = None
+ content: bytes | None = None
+ stream_factory: Callable[[], Any] | None = None
+ background: Any = None # starlette BackgroundTask(core 层不 import starlette)
@dataclass
diff --git a/scripts/mypy-baseline.txt b/scripts/mypy-baseline.txt
index e84f3f4df..8b1378917 100644
--- a/scripts/mypy-baseline.txt
+++ b/scripts/mypy-baseline.txt
@@ -1,26 +1 @@
-apps/admin/config_service.py: error: Argument 1 to "int" has incompatible type "Any | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type]
-apps/admin/dependencies.py: error: Incompatible default for parameter "request" (default has type "None", parameter has type "Request[State]") [assignment]
-apps/admin/local_files.py: error: Need type annotation for "items" (hint: "items: list[] = ...") [var-annotated]
-apps/admin/services.py: error: Need type annotation for "raw_activities" [var-annotated]
-apps/admin/services.py: error: Need type annotation for "raw_presets" [var-annotated]
-apps/admin/views.py: error: Argument 1 to "dict" has incompatible type "str | dict[Never, Never]"; expected "SupportsKeysAndGetItem[Never, Never]" [arg-type]
-apps/admin/views.py: error: Incompatible types in assignment (expression has type "datetime | str | None", target has type "int") [assignment]
-apps/admin/views.py: error: Incompatible types in assignment (expression has type "dict[str, str]", variable has type "str | None") [assignment]
-apps/admin/views.py: error: Need type annotation for "update_data" (hint: "update_data: dict[, ] = ...") [var-annotated]
-apps/base/auth.py: error: If x = b'abc' then f"{x}" or "{}".format(x) produces "b'abc'", not "abc". If this is desired behavior, use f"{x!r}" or "{!r}".format(x). Otherwise, decode the bytes [str-bytes-safe]
-apps/base/auth.py: error: Incompatible types in assignment (expression has type "str", variable has type "bytes") [assignment]
-apps/base/config.py: error: Incompatible types in assignment (expression has type "dict[str, Any]", variable has type "str | None") [assignment]
-apps/base/config.py: error: Unpacked dict entry 1 has incompatible type "str | dict[str, object]"; expected "SupportsKeysAndGetItem[str, object]" [dict-item]
-apps/base/models.py: error: Incompatible types in assignment (expression has type "CharField", variable has type "str | None") [assignment]
-apps/base/models.py: error: Incompatible types in assignment (expression has type "JSONField[Never]", variable has type "str | None") [assignment]
-apps/base/services.py: error: "object" not callable [operator]
-apps/base/services.py: error: Argument "background" to "StreamingResponse" has incompatible type "object"; expected "BackgroundTask | None" [arg-type]
-apps/base/services.py: error: Argument 1 to "FileResponse" has incompatible type "object"; expected "str | PathLike[str]" [arg-type]
-apps/base/services.py: error: Dict entry 1 has incompatible type "str": "str | None"; expected "str": "str" [dict-item]
-apps/base/setup_wizard.py: error: Argument 3 to "get_form_value" has incompatible type "object"; expected "str" [arg-type]
-apps/base/setup_wizard.py: error: Argument 3 to "parse_int_field" has incompatible type "object"; expected "int" [arg-type]
-apps/base/setup_wizard.py: error: No overload variant of "list" matches argument type "object" [call-overload]
-core/database.py: error: Module has no attribute "LK_LOCK" [attr-defined]
-core/database.py: error: Module has no attribute "LK_UNLCK" [attr-defined]
-core/database.py: error: Module has no attribute "locking" [attr-defined]
-core/security.py: error: Argument 1 to "_endpoint_ip_is_denied" has incompatible type "str | int"; expected "str" [arg-type]
+
diff --git a/tests/test_admin_write_paths.py b/tests/test_admin_write_paths.py
index 071349c13..8ecfa7b06 100644
--- a/tests/test_admin_write_paths.py
+++ b/tests/test_admin_write_paths.py
@@ -21,12 +21,22 @@ async def _login(client) -> str:
return response.json()["detail"]["token"]
-async def _create_share(code: str, *, text: str = "x", **extra) -> int:
+async def _create_share(
+ code: str,
+ *,
+ text: str = "x",
+ expired_at: datetime.datetime | None = None,
+ expired_count: int | None = None,
+) -> int:
+ """造一条文本分享记录。需要额外字段时显式声明参数(勿用 **kwargs 透传)。"""
from apps.base.models import FileCodes
- record = await FileCodes.create(
- code=code, text=text, size=1, prefix="Text", **extra
- )
+ fields: dict = {"code": code, "text": text, "size": 1, "prefix": "Text"}
+ if expired_at is not None:
+ fields["expired_at"] = expired_at
+ if expired_count is not None:
+ fields["expired_count"] = expired_count
+ record = await FileCodes.create(**fields)
return record.id
@@ -260,3 +270,5 @@ async def test_upload_without_size_declaration_no_500(self, initialized_client):
assert upload.size is None
size = await validate_file_size(upload, settings.upload_size)
assert size == len(b"no-length-payload")
+ # 关键后续语义:读指针必须复位到 0,否则该文件的 save_file 会读到空内容
+ assert upload.file.tell() == 0
diff --git a/tests/test_file_validation_negative.py b/tests/test_file_validation_negative.py
index b1ff10e15..f0923823f 100644
--- a/tests/test_file_validation_negative.py
+++ b/tests/test_file_validation_negative.py
@@ -7,7 +7,7 @@
import io
import pytest
-from fastapi import UploadFile
+from fastapi import HTTPException, UploadFile
from apps.base.file_validation import (
detect_file_kind,
@@ -41,9 +41,9 @@ def allow_images_only(monkeypatch):
class TestMagicBytesSpoofing:
def test_png_extension_with_text_content_rejected(self, allow_all):
"""声明 .png 但内容是文本——magic bytes 必须拒绝伪造。"""
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
validate_file_magic("shell.png", "image/png", b"#!/bin/sh\nrm -rf")
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403
def test_png_extension_with_real_png_signature_passes(self, allow_all):
validate_file_magic(
@@ -51,9 +51,9 @@ def test_png_extension_with_real_png_signature_passes(self, allow_all):
)
def test_exe_disguised_as_pdf_rejected(self, allow_all):
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
validate_file_magic("doc.pdf", "application/pdf", b"MZ\x90\x00")
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403
def test_pdf_signature_beats_longer_irrelevant_prefix(self, allow_all):
assert detect_file_kind(b"%PDF-1.7\n").name == "pdf"
@@ -79,25 +79,25 @@ def test_star_allows_everything(self, allow_all):
def test_image_wildcard_allows_png_rejects_exe(self, allow_images_only):
validate_file_magic("pic.png", "image/png", b"\x89PNG\r\n\x1a\n")
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
validate_file_magic("run.exe", "application/x-msdownload", b"MZ")
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403
def test_image_wildcard_rejects_non_image_content_even_with_png_name(
self, allow_images_only
):
"""扩展名是 .png 但 magic 识别失败(内容不是图)→ 伪造拒绝。"""
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
validate_file_magic("pic.png", "image/png", b"plain text")
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403
class TestChunkHeaderValidation:
def test_validate_header_bytes_delegates_to_magic(self, allow_all):
"""分片 0 的头部校验与整文件同一套 magic 语义(分片上传绕过面)。"""
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
validate_header_bytes("doc.pdf", "application/pdf", b"MZ") # exe 伪装 pdf
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403
validate_header_bytes("ok.png", "image/png", b"\x89PNG\r\n\x1a\n")
@@ -107,6 +107,6 @@ async def test_validate_upload_file_via_uploadfile(allow_all):
upload = UploadFile(
file=io.BytesIO(b"not an image"), filename="fake.png", size=12
)
- with pytest.raises(Exception) as exc_info:
+ with pytest.raises(HTTPException) as exc_info:
await validate_upload_file(upload)
- assert "403" in str(exc_info.value.status_code)
+ assert exc_info.value.status_code == 403