diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..95146693d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + # Runtime deps. requirements.txt pins direct deps; requirements.lock.txt is + # the hashed, transitive-resolved lockfile. Dependabot raises version PRs + # against requirements.txt; regenerate the lockfile in the same PR + # (uv pip compile requirements.txt --generate-hashes --universal -o requirements.lock.txt). + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..81b3cbd3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: + - master + - dev + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Lint and test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # The hashed lockfile is the deploy artifact; install from it with + # --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 + + - name: Ruff + run: pipx run ruff==0.16.6 check . + + - 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 + # the Docker build on old versions. + run: | + python - <<'PY' + import re, sys + pins = dict(re.findall(r'^([\w-]+)==([\w.]+)$', open('requirements.txt').read(), re.M)) + lock = open('requirements.lock.txt').read() + stale = [] + for name, ver in pins.items(): + m = re.search(rf'(?mi)^{re.escape(name)}==([\w.]+)\b', lock) + if m is None or m.group(1) != ver: + stale.append((name, ver, m.group(1) if m else 'ABSENT')) + for name, req_ver, lock_ver in stale: + print(f'STALE LOCKFILE: {name} requirements.txt={req_ver} lock={lock_ver}') + sys.exit(1 if stale else 0) + PY + + - name: Run tests + run: pytest -q diff --git a/.gitignore b/.gitignore index ebe2f8769..c18189018 100644 --- a/.gitignore +++ b/.gitignore @@ -80,8 +80,8 @@ docs/_build/ .pybuilder/ target/ *.db -./filecodebox.db-shm -./filecodebox.db-wal +*.db-shm +*.db-wal # Jupyter Notebook .ipynb_checkpoints @@ -147,13 +147,8 @@ cython_debug/ # Project .vscode .DS_Store -for_test.py .html -/evaluate/temp.py -/evaluation/back.json data/.env -.backup/ -/cloc-1.64.exe # Ignore node_modules node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..ca117ca6b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +# First-time setup: pre-commit install +# Behind a proxy: HTTPS_PROXY=http://: pre-commit run --all-files +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.6 + hooks: + - id: ruff-check + args: [--fix] diff --git a/Dockerfile b/Dockerfile index f1440b0a1..eedf04f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,11 +58,12 @@ COPY --from=frontend-builder /build/fronted-2024/dist ./themes/2024 COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023 # 安装系统安全更新 + Python 依赖 +# 依赖从带哈希的锁定文件安装(--require-hashes),保证构建可复现、防供应链篡改。 # 清理 apt 缓存,降低镜像噪音与扫描面 RUN apt-get update \ && apt-get upgrade -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir --require-hashes -r requirements.lock.txt \ && pip cache purge || true # 环境变量配置 diff --git a/apps/admin/dependencies.py b/apps/admin/dependencies.py index c4ca8541f..45dcebedd 100644 --- a/apps/admin/dependencies.py +++ b/apps/admin/dependencies.py @@ -2,7 +2,7 @@ # @Author : Lan # @File : depends.py # @Software: PyCharm -from fastapi import Header, HTTPException, Depends +from fastapi import Header, HTTPException from fastapi.requests import Request import base64 import hmac @@ -27,7 +27,7 @@ def _get_jwt_secret() -> bytes: def get_admin_session_expire_seconds() -> int: try: expires_in = int( - getattr(settings, "adminSessionExpire", ADMIN_SESSION_EXPIRE_DEFAULT) + getattr(settings, "admin_session_expire", ADMIN_SESSION_EXPIRE_DEFAULT) ) except (TypeError, ValueError): return ADMIN_SESSION_EXPIRE_DEFAULT @@ -155,14 +155,14 @@ async def share_required_login(authorization: str = Header(default=None)): """ 验证分享上传权限 - 当settings.openUpload为False时,要求用户必须登录并具有管理员权限 - 当settings.openUpload为True时,允许游客上传 + 当 settings.open_upload 为False时,要求用户必须登录并具有管理员权限 + 当 settings.open_upload 为True时,允许游客上传 :param authorization: 认证头信息 :param request: 请求对象 :return: 验证结果 """ - if not settings.openUpload: + if not settings.open_upload: if not authorization or not authorization.startswith("Bearer "): raise HTTPException( status_code=403, detail="本站未开启游客上传,如需上传请先登录后台" diff --git a/apps/admin/schemas.py b/apps/admin/schemas.py index 72334d210..30b1815a6 100644 --- a/apps/admin/schemas.py +++ b/apps/admin/schemas.py @@ -39,21 +39,18 @@ class BatchUpdateFileData(BaseModel): ids: list[int] expired_at: Optional[Union[datetime.datetime, str]] = None expired_count: Optional[int] = None - clearExpiredAt: Optional[bool] = None clear_expired_at: Optional[bool] = None class FilePolicyActionData(BaseModel): id: int action: str - downloadLimit: Optional[int] = None download_limit: Optional[int] = None class BatchFilePolicyActionData(BaseModel): ids: list[int] action: str - downloadLimit: Optional[int] = None download_limit: Optional[int] = None diff --git a/apps/admin/services.py b/apps/admin/services.py index 20388aba2..97da8f847 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -1,4 +1,6 @@ +import asyncio import hashlib +import io from pathlib import Path import os import time @@ -7,20 +9,26 @@ from typing import Any, Optional from core.response import APIResponse -from core.storage import FileStorageInterface, storages +from core.storage import FileStorageInterface, StoredFile, storages from core.settings import ( ADMIN_SESSION_EXPIRE_MAX, ADMIN_SESSION_EXPIRE_MIN, settings, ) -from core.config import refresh_settings +from apps.base.config import refresh_settings +from apps.base.services import response_from_download, stored_file_of from core.security import INTERNAL_CONFIG_KEYS, generate_jwt_secret from apps.base.models import FileCodes, KeyValue from apps.base.utils import get_expire_info, get_file_path_name from apps.base.quota import release_storage, reserve_storage from fastapi import HTTPException from core.settings import data_root -from core.utils import get_now, hash_password, is_password_hashed +from core.utils import get_now, hash_password, is_password_hashed, validate_background_url + +# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写; +# 进程内写锁串行化这三个写路径,避免并发管理操作互相覆盖(last-writer-wins)。 +# 多进程部署下锁不跨进程——文档已锁定单 worker 部署。 +keyvalue_write_lock = asyncio.Lock() class FileService: @@ -91,7 +99,7 @@ def _file_metadata_key(self, file_id: int) -> str: async def _delete_file_code(self, file_code: FileCodes): if file_code.text is None: - await self.file_storage.delete_file(file_code) + await self.file_storage.delete_file(stored_file_of(file_code)) await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete() await file_code.delete() @@ -131,24 +139,19 @@ async def delete_files(self, file_ids: list[int]): target_type="file", count=len(deleted), meta={ - "requestedCount": len(file_ids), - "uniqueCount": len(unique_ids), + "requested_count": len(file_ids), + "unique_count": len(unique_ids), "deleted": deleted, "missing": missing, - "failedCount": len(failed), + "failed_count": len(failed), }, ) return { - "requestedCount": len(file_ids), "requested_count": len(file_ids), - "uniqueCount": len(unique_ids), "unique_count": len(unique_ids), - "deletedCount": len(deleted), "deleted_count": len(deleted), - "missingCount": len(missing), "missing_count": len(missing), - "failedCount": len(failed), "failed_count": len(failed), "deleted": deleted, "missing": missing, @@ -180,30 +183,65 @@ async def update_files(self, file_ids: list[int], update_data: dict[str, Any]): count=len(updated), meta={ "fields": sorted(update_data.keys()), - "requestedCount": len(file_ids), - "uniqueCount": len(unique_ids), + "requested_count": len(file_ids), + "unique_count": len(unique_ids), "updated": updated, "missing": missing, - "failedCount": len(failed), + "failed_count": len(failed), }, ) return { - "requestedCount": len(file_ids), "requested_count": len(file_ids), - "uniqueCount": len(unique_ids), "unique_count": len(unique_ids), - "updatedCount": len(updated), "updated_count": len(updated), - "missingCount": len(missing), "missing_count": len(missing), - "failedCount": len(failed), "failed_count": len(failed), "updated": updated, "missing": missing, "failed": failed, } + async def update_file( + self, + file_id: int, + code: Optional[str] = None, + prefix: Optional[str] = None, + suffix: Optional[str] = None, + expired_at: Optional[Any] = None, + expired_count: Optional[int] = None, + ) -> dict[str, Any]: + file_code = await FileCodes.filter(id=file_id).first() + if not file_code: + raise HTTPException(status_code=404, detail="文件不存在") + + update_data: dict[str, Any] = {} + if code is not None and code != file_code.code: + if await FileCodes.filter(code=code).first(): + raise HTTPException(status_code=400, detail="code已存在") + update_data["code"] = code + if prefix is not None and prefix != file_code.prefix: + update_data["prefix"] = prefix + if suffix is not None and suffix != file_code.suffix: + update_data["suffix"] = suffix + if expired_at is not None and expired_at != "" and expired_at != file_code.expired_at: + update_data["expired_at"] = expired_at + if expired_count is not None and expired_count != file_code.expired_count: + update_data["expired_count"] = expired_count + + if update_data: + target_name = self._build_file_activity_name(file_code) + await file_code.update_from_dict(update_data).save() + await self.record_admin_activity( + action="file.update", + target_type="file", + target_id=file_id, + target_name=target_name, + count=1, + meta={"fields": sorted(update_data.keys())}, + ) + return {"updated": bool(update_data), "fields": sorted(update_data.keys())} + async def apply_file_policy_action( self, file_id: int, @@ -230,7 +268,7 @@ async def apply_file_policy_action( target_id=file_id, target_name=self._build_file_activity_name(file_code), count=1, - meta={"policyAction": action}, + meta={"policy_action": action}, ) return await self.get_file_detail(file_id) @@ -259,7 +297,6 @@ async def update_file_metadata( now = await get_now() updated_at = now.isoformat() - next_metadata["updatedAt"] = updated_at next_metadata["updated_at"] = updated_at await KeyValue.update_or_create( key=self._file_metadata_key(file_id), @@ -272,9 +309,9 @@ async def update_file_metadata( target_name=self._build_file_activity_name(file_code), count=1, meta={ - "updateNote": update_note, - "updateTags": update_tags, - "tagCount": len(next_metadata["tags"]), + "update_note": update_note, + "update_tags": update_tags, + "tag_count": len(next_metadata["tags"]), }, ) return await self.get_file_detail(file_id) @@ -283,7 +320,6 @@ async def list_file_view_presets(self) -> dict[str, Any]: presets = await self._get_file_view_presets() return { "presets": presets, - "items": presets, "total": len(presets), } @@ -293,44 +329,40 @@ async def save_file_view_preset( name: str, filters: dict[str, Any], ) -> dict[str, Any]: - presets = await self._get_file_view_presets() - normalized_name = self._normalize_file_view_preset_name(name) - normalized_filters = self._normalize_file_view_preset_filters(filters) - now = await get_now() - updated_at = now.isoformat() + async with keyvalue_write_lock: + presets = await self._get_file_view_presets() + normalized_name = self._normalize_file_view_preset_name(name) + normalized_filters = self._normalize_file_view_preset_filters(filters) + now = await get_now() + updated_at = now.isoformat() - target_index = next( - (index for index, preset in enumerate(presets) if preset["id"] == preset_id), - -1, - ) - is_update = target_index >= 0 - if is_update: - preset = presets[target_index] - next_preset = { - **preset, - "name": normalized_name, - "filters": normalized_filters, - "params": normalized_filters, - "updatedAt": updated_at, - "updated_at": updated_at, - } - presets[target_index] = next_preset - else: - if len(presets) >= self.MAX_VIEW_PRESETS: - raise HTTPException(status_code=400, detail="视图预设数量已达上限") - next_preset = { - "id": preset_id or self._build_file_view_preset_id(normalized_name, now), - "name": normalized_name, - "filters": normalized_filters, - "params": normalized_filters, - "createdAt": updated_at, - "created_at": updated_at, - "updatedAt": updated_at, - "updated_at": updated_at, - } - presets.append(next_preset) + target_index = next( + (index for index, preset in enumerate(presets) if preset["id"] == preset_id), + -1, + ) + is_update = target_index >= 0 + if is_update: + preset = presets[target_index] + next_preset = { + **preset, + "name": normalized_name, + "filters": normalized_filters, + "updated_at": updated_at, + } + presets[target_index] = next_preset + else: + if len(presets) >= self.MAX_VIEW_PRESETS: + raise HTTPException(status_code=400, detail="视图预设数量已达上限") + next_preset = { + "id": preset_id or self._build_file_view_preset_id(normalized_name, now), + "name": normalized_name, + "filters": normalized_filters, + "created_at": updated_at, + "updated_at": updated_at, + } + presets.append(next_preset) - await self._save_file_view_presets(presets) + await self._save_file_view_presets(presets) await self.record_admin_activity( action="file.view_preset_update" if is_update else "file.view_preset_create", target_type="view_preset", @@ -346,16 +378,17 @@ async def delete_file_view_preset(self, preset_id: str) -> dict[str, Any]: if not preset_id: raise HTTPException(status_code=400, detail="请选择要删除的视图预设") - presets = await self._get_file_view_presets() - deleted_preset = next( - (preset for preset in presets if preset["id"] == preset_id), - None, - ) - next_presets = [preset for preset in presets if preset["id"] != preset_id] - if len(next_presets) == len(presets): - raise HTTPException(status_code=404, detail="视图预设不存在") + async with keyvalue_write_lock: + presets = await self._get_file_view_presets() + deleted_preset = next( + (preset for preset in presets if preset["id"] == preset_id), + None, + ) + next_presets = [preset for preset in presets if preset["id"] != preset_id] + if len(next_presets) == len(presets): + raise HTTPException(status_code=404, detail="视图预设不存在") - await self._save_file_view_presets(next_presets) + await self._save_file_view_presets(next_presets) await self.record_admin_activity( action="file.view_preset_delete", target_type="view_preset", @@ -364,8 +397,6 @@ async def delete_file_view_preset(self, preset_id: str) -> dict[str, Any]: count=1, ) return { - "deleted": preset_id, - "deletedPresetId": preset_id, "deleted_preset_id": preset_id, "total": len(next_presets), } @@ -415,25 +446,20 @@ async def apply_files_policy_action( target_type="file", count=len(updated), meta={ - "policyAction": action, - "requestedCount": len(file_ids), - "uniqueCount": len(unique_ids), + "policy_action": action, + "requested_count": len(file_ids), + "unique_count": len(unique_ids), "updated": updated, "missing": missing, - "failedCount": len(failed), + "failed_count": len(failed), }, ) return { - "requestedCount": len(file_ids), "requested_count": len(file_ids), - "uniqueCount": len(unique_ids), "unique_count": len(unique_ids), - "updatedCount": len(updated), "updated_count": len(updated), - "missingCount": len(missing), "missing_count": len(missing), - "failedCount": len(failed), "failed_count": len(failed), "action": action, "updated": updated, @@ -465,29 +491,29 @@ async def list_files( now = await get_now() enriched_files = [] summary = { - "totalFiles": len(all_files), - "activeCount": 0, - "expiredCount": 0, - "textCount": 0, - "fileCount": 0, - "chunkedCount": 0, + "total_files": len(all_files), + "active_count": 0, + "expired_count": 0, + "text_count": 0, + "file_count": 0, + "chunked_count": 0, **self._empty_health_summary(), - "storageUsed": sum(file_code.size for file_code in all_files), - "usedCount": sum(file_code.used_count for file_code in all_files), + "storage_used": sum(file_code.size for file_code in all_files), + "used_count": sum(file_code.used_count for file_code in all_files), } for file_code in all_files: item = await self._build_admin_file_item(file_code, now=now) - if item["isExpired"]: - summary["expiredCount"] += 1 + if item["is_expired"]: + summary["expired_count"] += 1 else: - summary["activeCount"] += 1 - if item["isText"]: - summary["textCount"] += 1 + summary["active_count"] += 1 + if item["is_text"]: + summary["text_count"] += 1 else: - summary["fileCount"] += 1 - if item["isChunked"]: - summary["chunkedCount"] += 1 + summary["file_count"] += 1 + if item["is_chunked"]: + summary["chunked_count"] += 1 self._accumulate_health_summary(summary, item) if not self._match_admin_file(item, keyword, status, file_type, health): @@ -503,38 +529,38 @@ async def list_files( def _empty_health_summary(self) -> dict[str, int]: return { - "healthAttentionCount": 0, - "healthDangerCount": 0, - "healthWarningCount": 0, - "expiringSoonCount": 0, - "storageIssueCount": 0, - "neverRetrievedCount": 0, - "healthyCount": 0, - "permanentCount": 0, + "health_attention_count": 0, + "health_danger_count": 0, + "health_warning_count": 0, + "expiring_soon_count": 0, + "storage_issue_count": 0, + "never_retrieved_count": 0, + "healthy_count": 0, + "permanent_count": 0, } def _accumulate_health_summary(self, summary: dict[str, Any], item: dict[str, Any]) -> None: - status_insights = item.get("statusInsights") or {} + status_insights = item.get("status_insights") or {} reasons = status_insights.get("reasons") or [] severity = status_insights.get("severity") state = status_insights.get("state") if severity in {"danger", "warning"}: - summary["healthAttentionCount"] += 1 + summary["health_attention_count"] += 1 if severity == "danger": - summary["healthDangerCount"] += 1 + summary["health_danger_count"] += 1 if severity == "warning": - summary["healthWarningCount"] += 1 + summary["health_warning_count"] += 1 if severity == "success": - summary["healthyCount"] += 1 + summary["healthy_count"] += 1 if state == "permanent": - summary["permanentCount"] += 1 + summary["permanent_count"] += 1 if "expires_soon" in reasons: - summary["expiringSoonCount"] += 1 + summary["expiring_soon_count"] += 1 if "storage_metadata_incomplete" in reasons: - summary["storageIssueCount"] += 1 + summary["storage_issue_count"] += 1 if "never_retrieved" in reasons: - summary["neverRetrievedCount"] += 1 + summary["never_retrieved_count"] += 1 async def build_file_health_summary( self, file_codes: list[FileCodes], now: Optional[datetime] = None @@ -583,21 +609,13 @@ async def _build_admin_file_item( "name": name, "type": "text" if is_text else "file", "status": "expired" if is_expired else "active", - "isText": is_text, "is_text": is_text, - "isExpired": is_expired, "is_expired": is_expired, - "isChunked": file_code.is_chunked, "is_chunked": file_code.is_chunked, - "remainingDownloads": remaining_downloads, "remaining_downloads": remaining_downloads, - "usedCount": file_code.used_count, "used_count": file_code.used_count, - "createdAt": file_code.created_at, "created_at": file_code.created_at, - "expiredAt": file_code.expired_at, "expired_at": file_code.expired_at, - "fileHash": file_code.file_hash, "file_hash": file_code.file_hash, } ) @@ -611,7 +629,6 @@ async def _build_admin_file_item( ) data.update( { - "statusInsights": status_insights, "status_insights": status_insights, } ) @@ -649,54 +666,32 @@ async def get_file_detail(self, file_id: int): detail.update( { "filename": detail["name"], - "displayName": detail["name"], "display_name": detail["name"], - "isPermanent": is_permanent, "is_permanent": is_permanent, - "hasDownloadLimit": has_download_limit, "has_download_limit": has_download_limit, - "hasExpirationTime": file_code.expired_at is not None, "has_expiration_time": file_code.expired_at is not None, - "textLength": text_length, "text_length": text_length, - "canPreviewText": is_text, "can_preview_text": is_text, - "canDownload": can_download, "can_download": can_download, - "storageBackend": settings.file_storage, "storage_backend": settings.file_storage, - "filePath": file_code.file_path, "file_path": file_code.file_path, - "uuidFileName": file_code.uuid_file_name, "uuid_file_name": file_code.uuid_file_name, - "uploadId": file_code.upload_id, "upload_id": file_code.upload_id, "policy": { - "expiredAt": file_code.expired_at, "expired_at": file_code.expired_at, - "expiredCount": file_code.expired_count, "expired_count": file_code.expired_count, - "remainingDownloads": detail["remainingDownloads"], "remaining_downloads": detail["remaining_downloads"], - "isExpired": detail["isExpired"], "is_expired": detail["is_expired"], - "isPermanent": is_permanent, "is_permanent": is_permanent, }, "storage": { "backend": settings.file_storage, - "filePath": file_code.file_path, "file_path": file_code.file_path, - "uuidFileName": file_code.uuid_file_name, "uuid_file_name": file_code.uuid_file_name, - "fileHash": file_code.file_hash, "file_hash": file_code.file_hash, - "isChunked": file_code.is_chunked, "is_chunked": file_code.is_chunked, - "uploadId": file_code.upload_id, "upload_id": file_code.upload_id, }, - "statusInsights": status_insights, "status_insights": status_insights, "timeline": timeline, } @@ -705,10 +700,8 @@ async def get_file_detail(self, file_id: int): detail.update( { "metadata": metadata, - "meta": metadata, "note": metadata["note"], "tags": metadata["tags"], - "metadataUpdatedAt": metadata["updatedAt"], "metadata_updated_at": metadata["updated_at"], } ) @@ -747,11 +740,10 @@ def _normalize_file_metadata(self, metadata: Any) -> dict[str, Any]: if not isinstance(metadata, dict): metadata = {} - updated_at = metadata.get("updatedAt") or metadata.get("updated_at") + updated_at = metadata.get("updated_at") or metadata.get("updatedAt") return { "note": self._normalize_metadata_note(metadata.get("note")), "tags": self._normalize_metadata_tags(metadata.get("tags")), - "updatedAt": updated_at, "updated_at": updated_at, } @@ -779,23 +771,18 @@ async def list_admin_activities( ) visible_activities = filtered_activities[:limit] action_options = self._build_admin_activity_options(activities, "action") - target_type_options = self._build_admin_activity_options(activities, "targetType") + target_type_options = self._build_admin_activity_options(activities, "target_type") return { "activities": visible_activities, - "items": visible_activities, "total": len(filtered_activities), - "storedTotal": len(activities), "stored_total": len(activities), "limit": limit, "filters": { "action": normalized_action, - "targetType": normalized_target_type, "target_type": normalized_target_type, "keyword": normalized_keyword, }, - "actionOptions": action_options, "action_options": action_options, - "targetTypeOptions": target_type_options, "target_type_options": target_type_options, } @@ -821,27 +808,24 @@ async def record_admin_activity( timestamp=now, ), "action": action, - "targetType": target_type, "target_type": target_type, - "targetId": target_id, "target_id": target_id, - "targetName": target_name, "target_name": target_name, "count": count, "meta": meta or {}, - "createdAt": created_at, "created_at": created_at, } ) if not activity: return None - activities = await self._get_admin_activities() - next_activities = [ - activity, - *[item for item in activities if item["id"] != activity["id"]], - ][: self.MAX_ADMIN_ACTIVITIES] - await self._save_admin_activities(next_activities) + async with keyvalue_write_lock: + activities = await self._get_admin_activities() + next_activities = [ + activity, + *[item for item in activities if item["id"] != activity["id"]], + ][: self.MAX_ADMIN_ACTIVITIES] + await self._save_admin_activities(next_activities) return activity except Exception: return None @@ -867,7 +851,7 @@ async def _get_admin_activities(self) -> list[dict[str, Any]]: if len(activities) >= self.MAX_ADMIN_ACTIVITIES: break - activities.sort(key=lambda item: item.get("createdAt") or "", reverse=True) + activities.sort(key=lambda item: item.get("created_at") or "", reverse=True) return activities async def _save_admin_activities(self, activities: list[dict[str, Any]]) -> None: @@ -882,24 +866,24 @@ def _normalize_admin_activity(self, activity: Any) -> Optional[dict[str, Any]]: action = self._normalize_admin_activity_text(activity.get("action")) target_type = self._normalize_admin_activity_text( - activity.get("targetType") or activity.get("target_type") or "system" + activity.get("target_type") or activity.get("targetType") or "system" ) if not action: return None target_name = self._normalize_admin_activity_text( - activity.get("targetName") or activity.get("target_name") + activity.get("target_name") or activity.get("targetName") ) - created_at = activity.get("createdAt") or activity.get("created_at") + created_at = activity.get("created_at") or activity.get("createdAt") if isinstance(created_at, datetime): created_at = created_at.isoformat() created_at = str(created_at or "") if not created_at: return None - target_id = activity.get("targetId") + target_id = activity.get("target_id") if target_id is None: - target_id = activity.get("target_id") + target_id = activity.get("targetId") count = activity.get("count", 1) try: @@ -925,15 +909,11 @@ def _normalize_admin_activity(self, activity: Any) -> Optional[dict[str, Any]]: return { "id": activity_id, "action": action, - "targetType": target_type, "target_type": target_type, - "targetId": target_id, "target_id": target_id, - "targetName": target_name, "target_name": target_name, "count": count, "meta": meta, - "createdAt": created_at, "created_at": created_at, } @@ -951,7 +931,7 @@ def _filter_admin_activities( for activity in activities: if action and str(activity.get("action") or "").lower() != action: continue - if target_type and str(activity.get("targetType") or "").lower() != target_type: + if target_type and str(activity.get("target_type") or "").lower() != target_type: continue if keyword and not self._activity_matches_keyword(activity, keyword): continue @@ -961,11 +941,11 @@ def _filter_admin_activities( def _activity_matches_keyword(self, activity: dict[str, Any], keyword: str) -> bool: searchable_values = [ activity.get("action"), - activity.get("targetType"), activity.get("target_type"), - activity.get("targetId"), + activity.get("target_type"), activity.get("target_id"), - activity.get("targetName"), + activity.get("target_id"), + activity.get("target_name"), activity.get("target_name"), ] meta = activity.get("meta") @@ -1064,17 +1044,14 @@ def _normalize_file_view_preset(self, preset: Any) -> Optional[dict[str, Any]]: filters = preset.get("filters") or preset.get("params") or {} normalized_filters = self._normalize_file_view_preset_filters(filters) - created_at = preset.get("createdAt") or preset.get("created_at") - updated_at = preset.get("updatedAt") or preset.get("updated_at") + created_at = preset.get("created_at") + updated_at = preset.get("updated_at") or preset.get("updatedAt") return { "id": preset_id, "name": name, "filters": normalized_filters, - "params": normalized_filters, - "createdAt": created_at, "created_at": created_at, - "updatedAt": updated_at, "updated_at": updated_at, } @@ -1117,8 +1094,8 @@ def _normalize_file_view_preset_filters(self, filters: Any) -> dict[str, Any]: "health": self._normalize_file_view_preset_choice( filters.get("health"), self.VIEW_PRESET_HEALTH_VALUES ), - "sortBy": sort_by, - "sortOrder": sort_order, + "sort_by": sort_by, + "sort_order": sort_order, "size": min(max(size, 1), 100), } @@ -1144,12 +1121,12 @@ def _build_file_status_insights( is_permanent: bool, can_download: bool, ) -> dict[str, Any]: - remaining_downloads = detail["remainingDownloads"] + remaining_downloads = detail["remaining_downloads"] seconds_until_expiration = self._seconds_between(now, file_code.expired_at) age_seconds = self._seconds_between(file_code.created_at, now) reasons = [] - if detail["isExpired"]: + if detail["is_expired"]: reasons.append("expired") if has_download_limit and remaining_downloads == 0: reasons.append("download_limit_exhausted") @@ -1165,7 +1142,7 @@ def _build_file_status_insights( severity = "success" state = "available" next_action = "monitor" - if detail["isExpired"] or (has_download_limit and remaining_downloads == 0): + if detail["is_expired"] or (has_download_limit and remaining_downloads == 0): severity = "danger" state = "expired" next_action = "extend_or_delete" @@ -1184,17 +1161,12 @@ def _build_file_status_insights( return { "severity": severity, "state": state, - "nextAction": next_action, "next_action": next_action, "reasons": reasons, "metrics": { - "ageSeconds": max(age_seconds or 0, 0), "age_seconds": max(age_seconds or 0, 0), - "secondsUntilExpiration": seconds_until_expiration, "seconds_until_expiration": seconds_until_expiration, - "remainingDownloads": remaining_downloads, "remaining_downloads": remaining_downloads, - "usedCount": file_code.used_count, "used_count": file_code.used_count, }, } @@ -1208,7 +1180,7 @@ def _build_file_timeline( is_permanent: bool, is_text: bool, ) -> list[dict[str, Any]]: - remaining_downloads = detail["remainingDownloads"] + remaining_downloads = detail["remaining_downloads"] seconds_until_expiration = self._seconds_between(now, file_code.expired_at) timeline = [ { @@ -1351,15 +1323,15 @@ def _match_admin_file( file_type: str, health: str, ) -> bool: - if status == "active" and item["isExpired"]: + if status == "active" and item["is_expired"]: return False - if status == "expired" and not item["isExpired"]: + if status == "expired" and not item["is_expired"]: return False - if file_type == "text" and not item["isText"]: + if file_type == "text" and not item["is_text"]: return False - if file_type == "file" and item["isText"]: + if file_type == "file" and item["is_text"]: return False - if file_type == "chunked" and not item["isChunked"]: + if file_type == "chunked" and not item["is_chunked"]: return False if not self._match_admin_file_health(item, health): return False @@ -1371,7 +1343,7 @@ def _match_admin_file( item.get("name"), item.get("prefix"), item.get("suffix"), - item.get("fileHash"), + item.get("file_hash"), item.get("text"), ] return any(keyword in str(value).lower() for value in search_values if value) @@ -1380,7 +1352,7 @@ def _match_admin_file_health(self, item: dict[str, Any], health: str) -> bool: if not health or health == "all": return True - status_insights = item.get("statusInsights") or {} + status_insights = item.get("status_insights") or {} severity = status_insights.get("severity") state = status_insights.get("state") reasons = set(status_insights.get("reasons") or []) @@ -1392,7 +1364,7 @@ def _match_admin_file_health(self, item: dict[str, Any], health: str) -> bool: if health == "warning": return severity == "warning" if health == "expired": - return state == "expired" or item.get("isExpired") is True + return state == "expired" or item.get("is_expired") is True if health == "expiring_soon": return "expires_soon" in reasons if health == "storage_issue": @@ -1421,14 +1393,14 @@ def date_value(value: Any) -> float: return 0 sort_map = { - "created_at": date_value(item.get("createdAt")), - "createdat": date_value(item.get("createdAt")), - "expired_at": date_value(item.get("expiredAt")), - "expiredat": date_value(item.get("expiredAt")), + "created_at": date_value(item.get("created_at")), + "createdat": date_value(item.get("created_at")), + "expired_at": date_value(item.get("expired_at")), + "expiredat": date_value(item.get("expired_at")), "name": item.get("name") or "", "size": item.get("size") or 0, - "used_count": item.get("usedCount") or 0, - "usedcount": item.get("usedCount") or 0, + "used_count": item.get("used_count") or 0, + "usedcount": item.get("used_count") or 0, "code": item.get("code") or "", } return sort_map.get(sort_by) @@ -1440,7 +1412,7 @@ async def download_file(self, file_id: int): if file_code.text: return APIResponse(detail=file_code.text) else: - return await self.file_storage.get_file_response(file_code) + return response_from_download(await self.file_storage.get_file_response(stored_file_of(file_code))) async def preview_file(self, file_id: int, max_chars: int = 4000): max_chars = min(max(max_chars, 1), 20000) @@ -1459,14 +1431,10 @@ async def preview_file(self, file_id: int, max_chars: int = 4000): "type": "text", "content": preview, "length": len(content), - "previewLength": len(preview), "preview_length": len(preview), "truncated": len(content) > max_chars, - "maxChars": max_chars, "max_chars": max_chars, - "createdAt": file_code.created_at, "created_at": file_code.created_at, - "expiredAt": file_code.expired_at, "expired_at": file_code.expired_at, } @@ -1478,14 +1446,14 @@ async def share_local_file(self, item): reservation_token = f"local:{uuid.uuid4().hex}" await reserve_storage(reservation_token, local_file.size, ttl_seconds=3600) try: - text = await local_file.read() + data = await local_file.read() # bytes(read 内部用 with 关闭句柄) expired_at, expired_count, used_count, code = await get_expire_info( item.expire_value, item.expire_style ) path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name( item ) - await self.file_storage.save_file(text, save_path) + await self.file_storage.save_file(io.BytesIO(data), save_path) try: await FileCodes.create( code=code, @@ -1500,7 +1468,7 @@ async def share_local_file(self, item): ) except Exception: await self.file_storage.delete_file( - FileCodes(file_path=path, uuid_file_name=uuid_file_name) + StoredFile(file_path=path, uuid_file_name=uuid_file_name) ) raise finally: @@ -1514,24 +1482,24 @@ async def share_local_file(self, item): class ConfigService: INT_FIELDS = { - "adminSessionExpire", - "enableChunk", - "errorCount", - "errorMinute", - "loginCount", - "loginMinute", + "admin_session_expire", + "enable_chunk", + "error_count", + "error_minute", + "login_count", + "login_minute", "max_save_seconds", "onedrive_proxy", - "openUpload", + "open_upload", "port", "s3_proxy", - "serverPort", - "serverWorkers", - "showAdminAddr", - "storageLimit", - "uploadCount", - "uploadMinute", - "uploadSize", + "server_port", + "server_workers", + "show_admin_addr", + "storage_limit", + "upload_count", + "upload_minute", + "upload_size", "webdav_proxy", } FLOAT_FIELDS = {"opacity"} @@ -1577,11 +1545,11 @@ async def update_config(self, data: dict): raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误") try: - session_expire = int(next_config.get("adminSessionExpire")) + session_expire = int(next_config.get("admin_session_expire")) except (TypeError, ValueError): raise HTTPException( status_code=400, - detail="adminSessionExpire 配置值格式错误", + detail="admin_session_expire 配置值格式错误", ) if ( not ADMIN_SESSION_EXPIRE_MIN <= session_expire <= ADMIN_SESSION_EXPIRE_MAX @@ -1589,21 +1557,27 @@ async def update_config(self, data: dict): ): raise HTTPException( status_code=400, - detail="adminSessionExpire 必须是 1 到 365 个整天", + detail="admin_session_expire 必须是 1 到 365 个整天", ) - next_config["adminSessionExpire"] = session_expire + next_config["admin_session_expire"] = session_expire - if int(next_config.get("storageLimit", 0)) < 0: + if int(next_config.get("storage_limit", 0)) < 0: raise HTTPException( status_code=400, - detail="storageLimit 不能小于 0", + detail="storage_limit 不能小于 0", ) + try: + validate_background_url(next_config.get("background", "")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if admin_password_changed: next_config["jwt_secret"] = generate_jwt_secret() - await KeyValue.update_or_create(key="settings", defaults={"value": next_config}) - await refresh_settings() + async with keyvalue_write_lock: + await KeyValue.update_or_create(key="settings", defaults={"value": next_config}) + await refresh_settings(force=True) class LocalFileService: @@ -1667,8 +1641,9 @@ def __init__(self, file): self.ctime = None self.size = None - async def read(self): - return open(self.path, "rb") + async def read(self) -> bytes: + with open(self.path, "rb") as fh: + return fh.read() async def write(self, data): with open(self.path, "w") as f: diff --git a/apps/admin/views.py b/apps/admin/views.py index 9016e115b..dcbd61ab2 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -31,13 +31,16 @@ ) from core.response import APIResponse from apps.base.models import FileCodes, KeyValue +from tortoise.expressions import Q +from tortoise.functions import Count, Sum from apps.admin.dependencies import ( create_token, get_admin_session_expire_seconds, verify_token, ) +from core.logger import logger from core.settings import settings -from core.utils import get_now, verify_password +from core.utils import get_now, hash_password, password_needs_rehash, verify_password from apps.base.utils import ip_limit admin_api = APIRouter( @@ -55,11 +58,25 @@ def _pick_query_text(*values: Optional[str]) -> Optional[str]: @admin_api.post("/login") async def login(data: LoginData, ip: str = Depends(ip_limit["login"])): - # 登录失败计入 IP 频率限制,超过 loginCount/loginMinute 后暂时锁定 + # 登录失败计入 IP 频率限制,超过 login_count/login_minute 后暂时锁定 if not verify_password(data.password, settings.admin_token): ip_limit["login"].add_ip(ip) raise HTTPException(status_code=401, detail="密码错误") + # 透明重哈希:存量 sha256/明文口令在登录成功(已证明持有口令)时升级为 scrypt + if password_needs_rehash(settings.admin_token): + try: + record = await KeyValue.filter(key="settings").first() + if record: + stored = dict(record.value or {}) + stored["admin_token"] = hash_password(data.password) + record.value = stored + await record.save() + settings.admin_token = stored["admin_token"] + logger.info("管理员口令已升级为 scrypt 哈希") + except Exception: + logger.warning("口令哈希升级失败,保持旧格式", exc_info=True) + expires_in = get_admin_session_expire_seconds() token = create_token({"is_admin": True}, expires_in=expires_in) return APIResponse( @@ -93,81 +110,99 @@ async def build_dashboard_recent_file(file_code: FileCodes) -> dict: "suffix": file_code.suffix, "size": file_code.size, "text": file_code.text is not None, - "expiredAt": file_code.expired_at, - "expiredCount": file_code.expired_count, - "usedCount": file_code.used_count, - "createdAt": file_code.created_at, - "isExpired": is_expired, + "expired_at": file_code.expired_at, + "expired_count": file_code.expired_count, + "used_count": file_code.used_count, + "created_at": file_code.created_at, + "is_expired": is_expired, } +def _expired_predicate(now: datetime.datetime) -> Q: + """SQL version of FileCodes.is_expired: + expired_at IS NOT NULL AND ((expired_count < 0 AND expired_at < now) OR expired_count = 0) + """ + return Q(expired_at__isnull=False) & ( + Q(expired_count__lt=0, expired_at__lt=now) | Q(expired_count=0) + ) + + +async def _sum_size(queryset) -> int: + rows = await queryset.annotate(total=Sum("size")).values("total") + return rows[0]["total"] or 0 + + @admin_api.get("/dashboard") async def dashboard(file_service: FileService = Depends(get_file_service)): - all_codes = await FileCodes.all() - all_size = sum([code.size for code in all_codes]) sys_start = await KeyValue.filter(key="sys_start").first() now = await get_now() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) yesterday_start = today_start - datetime.timedelta(days=1) yesterday_end = today_start - datetime.timedelta(microseconds=1) - yesterday_codes = FileCodes.filter( + + # 简单计数全部走 SQL 聚合,避免整表载入内存(D3)。 + # 健康摘要依赖逐行 rules engine(_build_file_status_insights),保留一次遍历。 + total_files = await FileCodes.all().count() + expired_count = await FileCodes.filter(_expired_predicate(now)).count() + all_size = await _sum_size(FileCodes.all()) + used_count = ( + await FileCodes.all().annotate(total=Sum("used_count")).values("total") + )[0]["total"] or 0 + text_count = await FileCodes.filter(text__isnull=False).count() + chunked_count = await FileCodes.filter(is_chunked=True).count() + yesterday_count = await FileCodes.filter( created_at__gte=yesterday_start, created_at__lte=yesterday_end + ).count() + yesterday_size = await _sum_size( + FileCodes.filter(created_at__gte=yesterday_start, created_at__lte=yesterday_end) + ) + today_count = await FileCodes.filter(created_at__gte=today_start).count() + today_size = await _sum_size(FileCodes.filter(created_at__gte=today_start)) + + suffix_rows = ( + await FileCodes.filter(text__isnull=True) + .annotate(count=Count("id")) + .values("suffix", "count") ) - today_codes = FileCodes.filter(created_at__gte=today_start) - yesterday_file_codes = await yesterday_codes - today_file_codes = await today_codes - expired_count = 0 - for file_code in all_codes: - if await file_code.is_expired(): - expired_count += 1 - health_summary = await file_service.build_file_health_summary(all_codes, now=now) - - text_count = sum(1 for file_code in all_codes if file_code.text is not None) - chunked_count = sum(1 for file_code in all_codes if file_code.is_chunked) - used_count = sum([file_code.used_count for file_code in all_codes]) suffix_counter = Counter( - "Text" if file_code.text is not None else (file_code.suffix or "file") - for file_code in all_codes + {((row["suffix"] or "file") or "file"): row["count"] for row in suffix_rows} + ) + + recent_file_codes = await FileCodes.all().order_by("-created_at").limit(8) + health_summary = await file_service.build_file_health_summary( + await FileCodes.all(), now=now ) - recent_file_codes = sorted( - all_codes, - key=lambda file_code: file_code.created_at.timestamp() - if file_code.created_at - else 0, - reverse=True, - )[:8] recent_activities = await file_service.list_admin_activities(limit=8) return APIResponse( detail={ - "totalFiles": len(all_codes), - "storageUsed": str(all_size), - "sysUptime": sys_start.value if sys_start else None, - "yesterdayCount": len(yesterday_file_codes), - "yesterdaySize": str(sum([code.size for code in yesterday_file_codes])), - "todayCount": len(today_file_codes), - "todaySize": str(sum([code.size for code in today_file_codes])), - "activeCount": len(all_codes) - expired_count, - "expiredCount": expired_count, - "textCount": text_count, - "fileCount": len(all_codes) - text_count, - "chunkedCount": chunked_count, - "usedCount": used_count, - "storageBackend": settings.file_storage, - "uploadSizeLimit": settings.uploadSize, - "openUpload": settings.openUpload, - "enableChunk": settings.enableChunk, - "maxSaveSeconds": settings.max_save_seconds, + "total_files": total_files, + "storage_used": str(all_size), + "sys_uptime": sys_start.value if sys_start else None, + "yesterday_count": yesterday_count, + "yesterday_size": str(yesterday_size), + "today_count": today_count, + "today_size": str(today_size), + "active_count": total_files - expired_count, + "expired_count": expired_count, + "text_count": text_count, + "file_count": total_files - text_count, + "chunked_count": chunked_count, + "used_count": used_count, + "storage_backend": settings.file_storage, + "upload_size_limit": settings.upload_size, + "open_upload": settings.open_upload, + "enable_chunk": settings.enable_chunk, + "max_save_seconds": settings.max_save_seconds, **health_summary, - "healthSummary": health_summary, - "topSuffixes": [ + "health_summary": health_summary, + "top_suffixes": [ {"suffix": suffix, "count": count} for suffix, count in suffix_counter.most_common(8) ], - "recentFiles": [ + "recent_files": [ await build_dashboard_recent_file(file_code) for file_code in recent_file_codes ], - "recentActivities": recent_activities["activities"], "recent_activities": recent_activities["activities"], } ) @@ -235,7 +270,7 @@ async def batch_update_files( update_data = {} fields_set = data.model_fields_set - should_clear_expired_at = bool(data.clearExpiredAt or data.clear_expired_at) + should_clear_expired_at = bool(data.clear_expired_at) if should_clear_expired_at: update_data["expired_at"] = None @@ -277,9 +312,7 @@ async def apply_file_policy_action( data: FilePolicyActionData, file_service: FileService, ): - download_limit = data.downloadLimit - if download_limit is None: - download_limit = data.download_limit + download_limit = data.download_limit detail = await file_service.apply_file_policy_action( file_id=data.id, @@ -312,9 +345,7 @@ async def apply_batch_file_policy_action( if not data.ids: raise HTTPException(status_code=400, detail="请选择要更新的文件") - download_limit = data.downloadLimit - if download_limit is None: - download_limit = data.download_limit + download_limit = data.download_limit result = await file_service.apply_files_policy_action( file_ids=data.ids, @@ -348,8 +379,8 @@ async def file_list( status: str = "", type: str = "", health: str = "", - sortBy: str = "created_at", - sortOrder: str = "desc", + sort_by: str = "created_at", + sort_order: str = "desc", file_service: FileService = Depends(get_file_service), ): page = max(page, 1) @@ -361,8 +392,8 @@ async def file_list( status=status, file_type=type, health=health, - sort_by=sortBy, - sort_order=sortOrder, + sort_by=sort_by, + sort_order=sort_order, ) return APIResponse( detail={ @@ -503,7 +534,7 @@ async def update_config( config_service: ConfigService = Depends(get_config_service), file_service: FileService = Depends(get_file_service), ): - data.pop("themesChoices", None) + data.pop("themes_choices", None) await config_service.update_config(data) await file_service.record_admin_activity( action="config.update", @@ -527,10 +558,10 @@ async def file_download( @admin_api.get("/file/preview") async def file_preview( id: int, - maxChars: int = 4000, + max_chars: int = 4000, file_service: FileService = Depends(get_file_service), ): - preview = await file_service.preview_file(id, maxChars) + preview = await file_service.preview_file(id, max_chars) return APIResponse(detail=preview) @@ -572,8 +603,8 @@ async def share_local_file( target_name=item.filename, count=1, meta={ - "expireValue": item.expire_value, - "expireStyle": item.expire_style, + "expire_value": item.expire_value, + "expire_style": item.expire_style, }, ) return APIResponse(detail=share_info) @@ -584,38 +615,12 @@ async def update_file( data: UpdateFileData, file_service: FileService = Depends(get_file_service), ): - file_code = await FileCodes.filter(id=data.id).first() - if not file_code: - raise HTTPException(status_code=404, detail="文件不存在") - target_name = file_service._build_file_activity_name(file_code) - update_data = {} - - if data.code is not None and data.code != file_code.code: - # 判断code是否存在 - if await FileCodes.filter(code=data.code).first(): - raise HTTPException(status_code=400, detail="code已存在") - update_data["code"] = data.code - if data.prefix is not None and data.prefix != file_code.prefix: - update_data["prefix"] = data.prefix - if data.suffix is not None and data.suffix != file_code.suffix: - update_data["suffix"] = data.suffix - if ( - data.expired_at is not None - and data.expired_at != "" - and data.expired_at != file_code.expired_at - ): - update_data["expired_at"] = data.expired_at - if data.expired_count is not None and data.expired_count != file_code.expired_count: - update_data["expired_count"] = data.expired_count - - await file_code.update_from_dict(update_data).save() - if update_data: - await file_service.record_admin_activity( - action="file.update", - target_type="file", - target_id=data.id, - target_name=target_name, - count=1, - meta={"fields": sorted(update_data.keys())}, - ) + await file_service.update_file( + file_id=data.id, + code=data.code, + prefix=data.prefix, + suffix=data.suffix, + expired_at=data.expired_at, + expired_count=data.expired_count, + ) return APIResponse(detail="更新成功") diff --git a/core/config.py b/apps/base/config.py similarity index 66% rename from core/config.py rename to apps/base/config.py index 4c13f531b..2d1734e56 100644 --- a/core/config.py +++ b/apps/base/config.py @@ -16,18 +16,18 @@ SETUP_CONFIG_FIELDS = { "allowed_file_types", "code_generate_type", - "enableChunk", - "errorCount", - "errorMinute", - "loginCount", - "loginMinute", - "expireStyle", + "enable_chunk", + "error_count", + "error_minute", + "login_count", + "login_minute", + "expire_style", "max_save_seconds", "name", - "openUpload", - "uploadCount", - "uploadMinute", - "uploadSize", + "open_upload", + "upload_count", + "upload_minute", + "upload_size", } @@ -60,25 +60,45 @@ async def ensure_security_settings() -> None: logger.info("已将管理员密码迁移为哈希存储") if security_config.jwt_secret_rotated: logger.info("已生成独立 JWT 签名密钥") - await refresh_settings() + await refresh_settings(force=True) def _sync_ip_limits() -> None: - ip_limit["error"].minutes = settings.errorMinute - ip_limit["error"].count = settings.errorCount - ip_limit["metadata"].minutes = settings.errorMinute - ip_limit["metadata"].count = settings.errorCount - ip_limit["upload"].minutes = settings.uploadMinute - ip_limit["upload"].count = settings.uploadCount - ip_limit["login"].minutes = settings.loginMinute - ip_limit["login"].count = settings.loginCount - - -async def refresh_settings() -> None: - """从数据库读取最新配置并应用到运行时。""" + ip_limit["error"].minutes = settings.error_minute + ip_limit["error"].count = settings.error_count + ip_limit["metadata"].minutes = settings.error_minute + ip_limit["metadata"].count = settings.error_count + ip_limit["upload"].minutes = settings.upload_minute + ip_limit["upload"].count = settings.upload_count + ip_limit["login"].minutes = settings.login_minute + ip_limit["login"].count = settings.login_count + + +# Per-process config cache: the middleware refreshes settings on every request, +# which otherwise means one DB read per request. Writes call refresh_settings(force=True) +# so same-process visibility is immediate; other processes converge within the TTL +# (documented deployment is a single worker). +CONFIG_CACHE_TTL_SECONDS = 2.0 +_config_cached_until = 0.0 + + +async def refresh_settings(force: bool = False) -> None: + """从数据库读取最新配置并应用到运行时。 + + 默认带短 TTL 缓存(避免每请求查库);写配置的代码路径必须传 force=True。 + """ + global _config_cached_until + import time as _time + + if not force and _time.monotonic() < _config_cached_until: + return config_record = await KeyValue.filter(key="settings").first() settings.user_config = config_record.value if config_record and config_record.value else {} + unknown = settings.unknown_keys(settings.user_config) + if unknown: + logger.warning("配置中存在未登记的键(将被忽略其类型语义,仅透传): %s", unknown) _sync_ip_limits() + _config_cached_until = _time.monotonic() + CONFIG_CACHE_TTL_SECONDS def is_runtime_initialized() -> bool: @@ -117,4 +137,4 @@ async def initialize_system( next_config["jwt_secret"] = generate_jwt_secret() await KeyValue.update_or_create(key="settings", defaults={"value": next_config}) - await refresh_settings() + await refresh_settings(force=True) diff --git a/apps/base/dependencies.py b/apps/base/dependencies.py index 7235bde7e..0be60a439 100644 --- a/apps/base/dependencies.py +++ b/apps/base/dependencies.py @@ -7,7 +7,7 @@ def _iter_trusted_proxies() -> Iterable[str]: - trusted_proxies = getattr(settings, "trustedProxies", []) + trusted_proxies = getattr(settings, "trusted_proxies", []) if isinstance(trusted_proxies, str): trusted_proxies = [item.strip() for item in trusted_proxies.split(",")] return [item for item in trusted_proxies if item] diff --git a/apps/base/migrations/migrations_007.py b/apps/base/migrations/migrations_007.py new file mode 100644 index 000000000..50d719cbc --- /dev/null +++ b/apps/base/migrations/migrations_007.py @@ -0,0 +1,51 @@ +import json + +from tortoise import connections + +# 配置键驼峰 -> snake_case 统一(2.5)。settings 存于 KeyValue 表的 JSON 值中, +# 迁移在 Python 层读取、改写键名后写回,保证存量部署升级后键名与代码一致。 + +KEY_RENAMES = { + "adminSessionExpire": "admin_session_expire", + "enableChunk": "enable_chunk", + "errorCount": "error_count", + "errorMinute": "error_minute", + "expireStyle": "expire_style", + "loginCount": "login_count", + "loginMinute": "login_minute", + "openUpload": "open_upload", + "robotsText": "robots_text", + "serverHost": "server_host", + "serverPort": "server_port", + "serverWorkers": "server_workers", + "showAdminAddr": "show_admin_addr", + "storageLimit": "storage_limit", + "themesChoices": "themes_choices", + "themesSelect": "themes_select", + "trustedProxies": "trusted_proxies", + "uploadCount": "upload_count", + "uploadMinute": "upload_minute", + "uploadSize": "upload_size", +} + + +async def migrate(): + conn = connections.get("default") + rows = await conn.execute_query_dict( + "SELECT id, value FROM keyvalue WHERE key = 'settings'" + ) + for row in rows: + value = row["value"] + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + continue + if not isinstance(value, dict): + continue + renamed = {KEY_RENAMES.get(k, k): v for k, v in value.items()} + if renamed != value: + await conn.execute_query( + "UPDATE keyvalue SET value = ? WHERE id = ?", + [json.dumps(renamed, ensure_ascii=False), row["id"]], + ) diff --git a/apps/base/models.py b/apps/base/models.py index a43aa71e8..3820dee1b 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -22,7 +22,9 @@ class FileCodes(models.Model): size = fields.BigIntField(default=0) text = fields.TextField(null=True) expired_at = fields.DatetimeField(null=True) - expired_count = fields.IntField(default=0) + # -1 = 时间式(看 expired_at),<=0 的 0 = 次数耗尽。默认 -1 而非 0: + # 0 语义是"次数已用完即过期",忘传该字段的创建路径会得到立即过期的记录。 + expired_count = fields.IntField(default=-1) used_count = fields.IntField(default=0) created_at = fields.DatetimeField(auto_now_add=True) file_hash = fields.CharField(max_length=64, null=True) diff --git a/apps/base/pages.py b/apps/base/pages.py new file mode 100644 index 000000000..5bd174324 --- /dev/null +++ b/apps/base/pages.py @@ -0,0 +1,137 @@ +"""Public-facing pages and routes: setup wizard, theme assets, index, +robots.txt, and the public config endpoints.""" +import html + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse + +from apps.base.config import initialize_system, is_runtime_initialized +from apps.base.setup_wizard import ( + build_public_config, + build_public_meta, + build_setup_page, + build_setup_success_page, + parse_setup_options, + read_setup_payload, + setup_response, +) +from core.response import APIResponse +from core.settings import BASE_DIR, DEFAULT_CONFIG, settings +from core.version import APP_VERSION + +router = APIRouter() + + +@router.get("/setup", include_in_schema=False) +@router.get("/setup/", include_in_schema=False) +async def setup_page(): + if is_runtime_initialized(): + return RedirectResponse(url="/", status_code=303) + return setup_response(build_setup_page()) + + +@router.post("/setup", include_in_schema=False) +@router.post("/setup/", include_in_schema=False) +async def setup_submit(request: Request): + if is_runtime_initialized(): + return RedirectResponse(url="/", status_code=303) + + data = await read_setup_payload(request) + admin_password = str(data.get("admin_password") or "") + confirm_password = str(data.get("confirm_password") or "") + site_name = str(data.get("site_name") or "") + + if admin_password != confirm_password: + return setup_response(build_setup_page("两次输入的管理员密码不一致", data), 400) + + try: + setup_options = parse_setup_options(data) + await initialize_system( + admin_password=admin_password, + site_name=site_name, + setup_options=setup_options, + ) + except ValueError as exc: + return setup_response(build_setup_page(str(exc), data), 400) + + if "application/json" in request.headers.get("accept", ""): + return APIResponse(detail={"ok": True, "admin": "/#/admin"}) + return setup_response(build_setup_success_page()) + +def resolve_theme_root(): + themes_root = (BASE_DIR / "themes").resolve() + theme_root = (BASE_DIR / str(settings.themes_select)).resolve() + try: + theme_root.relative_to(themes_root) + except ValueError: + theme_root = (BASE_DIR / DEFAULT_CONFIG["themes_select"]).resolve() + if not theme_root.exists(): + theme_root = (BASE_DIR / DEFAULT_CONFIG["themes_select"]).resolve() + return theme_root + + +def resolve_theme_file(*parts: str): + theme_root = resolve_theme_root() + file_path = theme_root.joinpath(*parts).resolve() + # 防止通过 /assets/../ 读取主题目录外的文件。 + try: + file_path.relative_to(theme_root) + except ValueError: + raise HTTPException(status_code=404, detail="资源不存在") + if not file_path.is_file(): + raise HTTPException(status_code=404, detail="资源不存在") + return file_path + + +@router.get("/assets/{asset_path:path}", include_in_schema=False) +async def theme_asset(asset_path: str): + return FileResponse(resolve_theme_file("assets", asset_path)) + + +@router.get("/") +async def index(request=None, exc=None): + # Site config is admin input (and during the setup window anyone can claim it); + # always escape before injecting into the theme template to prevent stored XSS + # (mirrors the setup page). + return HTMLResponse( + content=resolve_theme_file("index.html") + .read_text(encoding="utf-8") + .replace("{{title}}", html.escape(str(settings.name))) + .replace("{{description}}", html.escape(str(settings.description))) + .replace("{{keywords}}", html.escape(str(settings.keywords))) + .replace("{{opacity}}", html.escape(str(settings.opacity))) + .replace("{{background}}", html.escape(str(settings.background))), + media_type="text/html", + headers={"Cache-Control": "no-cache"}, + ) + +@router.get("/robots.txt") +async def robots(): + return HTMLResponse(content=settings.robots_text, media_type="text/plain") + + +@router.post("/") +async def get_config(): + return APIResponse(detail=build_public_config()) + + +@router.get("/api/v1/config") +async def get_public_config(): + return APIResponse( + detail={ + "config": build_public_config(), + "meta": build_public_meta(), + } + ) + + +@router.get("/health") +async def health_check(): + return APIResponse( + detail={ + "status": "ok", + "version": APP_VERSION, + "storage": settings.file_storage, + "theme": settings.themes_select, + } + ) diff --git a/apps/base/quota.py b/apps/base/quota.py index 2e673dd01..988ffc796 100644 --- a/apps/base/quota.py +++ b/apps/base/quota.py @@ -2,6 +2,7 @@ from fastapi import HTTPException from tortoise import connections +from tortoise.functions import Sum from apps.base.models import FileCodes, StorageReservation from core.settings import settings @@ -47,20 +48,21 @@ def _sql_placeholders(count: int) -> list[str]: def get_storage_limit() -> int: try: - return max(0, int(getattr(settings, "storageLimit", 0))) + return max(0, int(getattr(settings, "storage_limit", 0))) except (TypeError, ValueError): return 0 async def get_storage_usage() -> dict[str, int | None]: now = await get_now() - used = await FileCodes.all().values_list("size", flat=True) - reserved = await StorageReservation.filter(expires_at__gt=now).values_list( - "size", flat=True - ) + # SQL 聚合:此函数在每次上传配额检查时调用,禁止全表拉取(D3) + used_rows = await FileCodes.all().annotate(total=Sum("size")).values("total") + reserved_rows = await StorageReservation.filter(expires_at__gt=now).annotate( + total=Sum("size") + ).values("total") limit = get_storage_limit() - used_bytes = sum(used) - reserved_bytes = sum(reserved) + used_bytes = used_rows[0]["total"] or 0 + reserved_bytes = reserved_rows[0]["total"] or 0 return { "limit": limit, "used": used_bytes, diff --git a/apps/base/schemas.py b/apps/base/schemas.py index efe8025fd..f4cf4a405 100644 --- a/apps/base/schemas.py +++ b/apps/base/schemas.py @@ -1,5 +1,4 @@ from pydantic import BaseModel -from typing import Optional class SelectFileModel(BaseModel): diff --git a/apps/base/services.py b/apps/base/services.py new file mode 100644 index 000000000..095c46935 --- /dev/null +++ b/apps/base/services.py @@ -0,0 +1,399 @@ +"""apps.base service layer: upload orchestration shared by share/chunk/presign. + +Handlers in views.py keep HTTP concerns (form parsing, session lookup, status +codes for malformed requests, rate limiting); this module owns the business +workflow: quota reservation, storage writes, share-record creation, and +failure rollback/cleanup. +""" +import os + +from fastapi import HTTPException, UploadFile +from fastapi.responses import FileResponse, Response, StreamingResponse + +from core.logger import logger +from core.settings import settings +from core.storage import FileStorageInterface, StoredDownload, StoredFile, storages + +from apps.base.file_validation import validate_upload_file +from apps.base.models import FileCodes, PresignUploadSession, UploadChunk +from apps.base.quota import release_storage, reserve_storage +from apps.base.utils import build_file_path, get_expire_info + +import uuid + +# 预签名上传会话有效期(秒) +PRESIGN_SESSION_EXPIRES = 900 # 15分钟 + + +def stored_file_of(code: FileCodes) -> StoredFile: + """Adapter: project an ORM FileCodes row onto the storage-layer contract.""" + return StoredFile( + file_path=code.file_path, + uuid_file_name=code.uuid_file_name, + code=code.code, + prefix=code.prefix, + suffix=code.suffix, + text=code.text or "", + ) + + +async def rollback_saved_file( + storage: FileStorageInterface, + file_path: str, + uuid_file_name: str, + *, + context: str, + upload_id: str | None = None, +) -> None: + """Best-effort delete of a stored file after a failed upload workflow. + + Rollback failures are logged (with traceback) and swallowed: the original + error must propagate; the file, if left behind, is cleaned by the expiry + task once its orphaned record ages out. + """ + try: + await storage.delete_file( + StoredFile(file_path=file_path, uuid_file_name=uuid_file_name) + ) + except Exception: + logger.warning( + "%s:回滚删除已保存文件失败%s", + context, + f" upload_id={upload_id}" if upload_id else "", + exc_info=True, + ) + + +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] + size = file.file.tell() + await file.seek(0) + if size > max_size: + max_size_mb = max_size / (1024 * 1024) + raise HTTPException( + status_code=403, detail=f"大小超过限制,最大为{max_size_mb:.2f} MB" + ) + return size + + +def chunk_reservation_ttl() -> int: + ttl = max(1, int(getattr(settings, "chunk_expire_hours", 24))) * 3600 + return ttl + + +class FileUploadService: + """统一的文件上传服务""" + + @staticmethod + def _storage() -> FileStorageInterface: + return storages[settings.file_storage]() + + @staticmethod + async def generate_file_path( + file_name: str, upload_id: str | None = None + ) -> tuple[str, str, str, str, str]: + """Delegates path generation to apps.base.utils.build_file_path.""" + return await build_file_path(file_name, upload_id or uuid.uuid4().hex) + + @staticmethod + async def create_file_record( + file_name: str, + file_size: int, + file_path: str, + expire_value: int, + expire_style: str, + **extra_fields, + ) -> str: + """统一创建FileCodes记录,返回code""" + expired_at, expired_count, used_count, code = await get_expire_info( + expire_value, expire_style + ) + prefix, suffix = os.path.splitext(file_name) + + await FileCodes.create( + code=code, + prefix=prefix, + suffix=suffix, + uuid_file_name=file_name, + file_path=file_path, + size=file_size, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + **extra_fields, + ) + return code + + @staticmethod + async def create_text_share( + text: str, expire_value: int, expire_style: str + ) -> str: + """文本分享:配额预留 → 建分享记录 → 释放配额。""" + text_size = len(text.encode("utf-8")) + token = f"text:{uuid.uuid4().hex}" + await reserve_storage(token, text_size, ttl_seconds=300) + try: + expired_at, expired_count, used_count, code = await get_expire_info( + expire_value, expire_style + ) + await FileCodes.create( + code=code, + text=text, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + size=text_size, + prefix="Text", + ) + finally: + await release_storage(token) + return code + + @staticmethod + async def create_file_share( + file: UploadFile, *, size: int, expire_value: int, expire_style: str + ) -> dict[str, str]: + """文件分享:路径生成 → 配额预留 → 存储写入 → 建分享记录,失败回滚已存文件。""" + path, suffix, prefix, uuid_file_name, save_path = ( + await FileUploadService.generate_file_path(file.filename or "") + ) + token = f"file:{uuid.uuid4().hex}" + await reserve_storage(token, size, ttl_seconds=3600) + storage = FileUploadService._storage() + try: + expired_at, expired_count, used_count, code = await get_expire_info( + expire_value, expire_style + ) + await storage.save_file(file.file, save_path, file.content_type) + await FileCodes.create( + code=code, + prefix=prefix, + suffix=suffix, + uuid_file_name=uuid_file_name, + file_path=path, + size=size, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + ) + except Exception: + await rollback_saved_file( + storage, path, uuid_file_name, context="分享上传" + ) + raise + finally: + await release_storage(token) + return {"code": code, "name": file.filename} + + @staticmethod + async def complete_chunked_upload( + upload_id: str, chunk_info: UploadChunk, expire_value: int, expire_style: str + ) -> dict[str, str]: + """分片合并:配额 → 完整性/大小校验 → 合并 → 建分享记录 → 清理分片。 + + 失败路径的配额释放与清理范围与原实现逐一对齐: + 完整性校验失败仅抛 400(预留由 TTL 兜底);合并失败清理分片文件后抛 500。 + """ + storage = FileUploadService._storage() + await reserve_storage( + f"chunk:{upload_id}", chunk_info.file_size, ttl_seconds=chunk_reservation_ttl() + ) + + completed_chunks = await UploadChunk.filter( + upload_id=upload_id, completed=True + ).all() + if len(completed_chunks) != chunk_info.total_chunks: + raise HTTPException(400, "分片不完整") + + # 用分片数 * chunk_size 校验最大可能大小 + max_total_size = len(completed_chunks) * chunk_info.chunk_size + if max_total_size > settings.upload_size: + save_path = chunk_info.save_path + if save_path: + try: + await storage.clean_chunks(upload_id, save_path) + except Exception: + logger.warning( + "分片超限中止:清理分片文件失败 upload_id=%s", + upload_id, + exc_info=True, + ) + await UploadChunk.filter(upload_id=upload_id).delete() + await release_storage(f"chunk:{upload_id}") + max_size_mb = settings.upload_size / (1024 * 1024) + raise HTTPException( + 403, f"实际上传大小超过限制,最大为 {max_size_mb:.2f} MB" + ) + + save_path = chunk_info.save_path + path = os.path.dirname(save_path) if save_path else "" + safe_file_name = os.path.basename(save_path) if save_path else "" + prefix, suffix = os.path.splitext(safe_file_name) + + try: + records = {r.chunk_index: r for r in completed_chunks} + _, file_hash = await storage.merge_chunks( + upload_id, + chunk_info.total_chunks, + chunk_info.chunk_size, + save_path, + records, + ) + expired_at, expired_count, used_count, code = await get_expire_info( + expire_value, expire_style + ) + await FileCodes.create( + code=code, + file_hash=file_hash, # 使用合并后计算的哈希 + is_chunked=True, + upload_id=upload_id, + size=chunk_info.file_size, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + file_path=path, + uuid_file_name=safe_file_name, + prefix=prefix, + suffix=suffix, + ) + await storage.clean_chunks(upload_id, save_path) + await UploadChunk.filter(upload_id=upload_id).delete() + await release_storage(f"chunk:{upload_id}") + return {"code": code, "name": safe_file_name} + except ValueError as e: + raise HTTPException(400, str(e)) + except Exception as e: + # 合并失败时清理临时文件 + try: + await storage.clean_chunks(upload_id, save_path) + except Exception: + logger.warning( + "分片合并失败:清理临时分片文件失败 upload_id=%s", + upload_id, + exc_info=True, + ) + raise HTTPException(500, f"文件合并失败: {str(e)}") + + @staticmethod + async def commit_proxy_upload( + session: PresignUploadSession, file: UploadFile + ) -> str: + """预签名代理上传:配额 → 大小/类型/一致性校验 → 转存 → 建记录 → 会话清理。 + + 校验失败不释放预留(与原实现一致,由 TTL 兜底)。 + """ + await reserve_storage( + f"presign:{session.upload_id}", + session.file_size, + ttl_seconds=PRESIGN_SESSION_EXPIRES, + ) + + file_size = await validate_file_size(file, settings.upload_size) + await validate_upload_file(file) + if abs(file_size - session.file_size) > 1024: + raise HTTPException(400, "文件大小与声明不符") + + storage = FileUploadService._storage() + try: + await storage.save_file(file.file, session.save_path, file.content_type) + except Exception as e: + raise HTTPException(500, f"文件保存失败: {str(e)}") + + try: + code = await FileUploadService.create_file_record( + session.file_name, + file_size, + os.path.dirname(session.save_path), + session.expire_value, + session.expire_style, + ) + except Exception: + await rollback_saved_file( + storage, + os.path.dirname(session.save_path), + os.path.basename(session.save_path), + context="预签名代理上传:记录创建失败", + upload_id=session.upload_id, + ) + raise + + await session.delete() + await release_storage(f"presign:{session.upload_id}") + return code + + @staticmethod + async def confirm_direct_upload(session: PresignUploadSession) -> str: + """预签名直传确认:配额 → 文件存在性 → 建记录 → 会话清理。 + + 预留失败说明配额已耗尽,此时清理远端临时文件与会话后原样抛出。 + """ + try: + await reserve_storage( + f"presign:{session.upload_id}", + session.file_size, + ttl_seconds=PRESIGN_SESSION_EXPIRES, + ) + except HTTPException: + storage = FileUploadService._storage() + try: + if await storage.file_exists(session.save_path): + await storage.delete_file( + StoredFile( + file_path=os.path.dirname(session.save_path), + uuid_file_name=os.path.basename(session.save_path), + ) + ) + finally: + await session.delete() + await release_storage(f"presign:{session.upload_id}") + raise + + storage = FileUploadService._storage() + if not await storage.file_exists(session.save_path): + raise HTTPException(404, "文件未上传或上传失败") + + try: + code = await FileUploadService.create_file_record( + session.file_name, + session.file_size, + os.path.dirname(session.save_path), + session.expire_value, + session.expire_style, + ) + except Exception: + await rollback_saved_file( + storage, + os.path.dirname(session.save_path), + os.path.basename(session.save_path), + context="预签名确认:记录创建失败", + upload_id=session.upload_id, + ) + raise + + await session.delete() + await release_storage(f"presign:{session.upload_id}") + return code + + +def response_from_download(download: StoredDownload): + """Build the starlette Response for a StoredDownload (view-layer duty).""" + if download.path is not None: + return FileResponse( + download.path, + media_type=download.media_type, + headers=download.headers, + filename=download.filename, + ) + if download.content is not None: + return Response( + download.content, media_type=download.media_type, headers=download.headers + ) + return StreamingResponse( + download.stream_factory(), + media_type=download.media_type, + headers=download.headers, + background=download.background, + ) diff --git a/apps/base/setup_wizard.py b/apps/base/setup_wizard.py new file mode 100644 index 000000000..0a9bbb0b9 --- /dev/null +++ b/apps/base/setup_wizard.py @@ -0,0 +1,692 @@ +"""Setup wizard: form parsing rules and the two admin-facing HTML pages. + +Pure presentation/logic — no router here; routes live in apps.base.pages. +""" +import html +from urllib.parse import parse_qs + +from fastapi import Request +from fastapi.responses import HTMLResponse + +from core.settings import DEFAULT_CONFIG, settings +from core.version import APP_VERSION + + +FILE_SIZE_UNITS = {"KB": 1024, "MB": 1024**2, "GB": 1024**3} +SAVE_TIME_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400} +EXPIRE_STYLE_OPTIONS = [ + ("day", "按天"), + ("hour", "按小时"), + ("minute", "按分钟"), + ("forever", "永久"), + ("count", "按取件次数"), +] + + +def normalize_public_flag(value) -> int: + if isinstance(value, str): + return int(value.strip().lower() in {"1", "true", "on", "yes"}) + return int(bool(value)) + + +def build_public_config() -> dict: + return { + "name": settings.name, + "description": settings.description, + "explain": settings.page_explain, + "upload_size": settings.upload_size, + "allowed_file_types": settings.allowed_file_types, + "expire_style": settings.expire_style, + "enable_chunk": settings.enable_chunk, + "open_upload": settings.open_upload, + "notify_title": settings.notify_title, + "notify_content": settings.notify_content, + "show_admin_address": normalize_public_flag(settings.show_admin_addr), + "max_save_seconds": settings.max_save_seconds, + } + + +def build_public_meta() -> dict: + return { + "version": APP_VERSION, + "api": { + "legacy_config": "/", + "public_config": "/api/v1/config", + "health": "/health", + }, + "features": { + "chunk_upload": bool(settings.enable_chunk), + "guest_upload": bool(settings.open_upload), + "admin_address_visible": bool(normalize_public_flag(settings.show_admin_addr)), + "expiration_modes": settings.expire_style, + }, + "limits": { + "upload_size": settings.upload_size, + "allowed_file_types": settings.allowed_file_types, + "max_save_seconds": settings.max_save_seconds, + "upload_window_minutes": settings.upload_minute, + "upload_window_count": settings.upload_count, + }, + } + + + + + +def get_form_value(data: dict, 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]: + value = data.get(key, []) + if isinstance(value, list): + return [str(item) for item in value if str(item)] + if value: + return [str(value)] + return [] + + +def normalize_bool_field(data: dict, key: str, default: bool) -> bool: + if key not in data: + return default + return get_form_value(data, key).lower() in {"1", "true", "on", "yes"} + + +def parse_int_field( + data: dict, + key: str, + default: int, + label: str, + min_value: int = 0, + max_value: int | None = None, +) -> int: + raw_value = get_form_value(data, key, str(default)).strip() + try: + value = int(raw_value) + except ValueError: + raise ValueError(f"{label} 必须是整数") + if value < min_value: + raise ValueError(f"{label} 不能小于 {min_value}") + if max_value is not None and value > max_value: + raise ValueError(f"{label} 不能大于 {max_value}") + return value + + +def parse_allowed_file_types(value: str) -> list[str]: + items = [item.strip() for item in value.split(",") if item.strip()] + return items or ["*"] + + +def parse_setup_options(data: dict) -> dict: + upload_size_unit = get_form_value(data, "upload_size_unit", "MB").upper() + if upload_size_unit not in FILE_SIZE_UNITS: + raise ValueError("文件大小单位不正确") + upload_size_value = parse_int_field( + data, "upload_size_value", 10, "文件大小限制", min_value=1 + ) + + save_time_unit = get_form_value(data, "save_time_unit", "day") + if save_time_unit not in SAVE_TIME_UNITS: + raise ValueError("最长保存时间单位不正确") + save_time_value = parse_int_field( + data, "save_time_value", 0, "最长保存时间", min_value=0 + ) + + expire_styles = get_form_list(data, "expire_style") + valid_expire_styles = {style for style, _label in EXPIRE_STYLE_OPTIONS} + expire_styles = [style for style in expire_styles if style in valid_expire_styles] + if not expire_styles: + raise ValueError("至少需要选择一种过期方式") + + code_generate_type = get_form_value( + data, "code_generate_type", DEFAULT_CONFIG["code_generate_type"] + ) + if code_generate_type not in {"number", "secret"}: + raise ValueError("提取码类型不正确") + + return { + "allowed_file_types": parse_allowed_file_types( + get_form_value(data, "allowed_file_types", "*") + ), + "code_generate_type": code_generate_type, + "enable_chunk": int(normalize_bool_field(data, "enable_chunk", False)), + "error_count": parse_int_field( + data, "error_count", DEFAULT_CONFIG["error_count"], "取件错误次数限制", 1 + ), + "error_minute": parse_int_field( + data, "error_minute", DEFAULT_CONFIG["error_minute"], "取件错误检测窗口", 1 + ), + "login_count": parse_int_field( + data, "login_count", DEFAULT_CONFIG["login_count"], "登录失败次数限制", 1 + ), + "login_minute": parse_int_field( + data, "login_minute", DEFAULT_CONFIG["login_minute"], "登录失败检测窗口", 1 + ), + "expire_style": expire_styles, + "max_save_seconds": save_time_value * SAVE_TIME_UNITS[save_time_unit], + "open_upload": int(normalize_bool_field(data, "open_upload", True)), + "upload_count": parse_int_field( + data, "upload_count", DEFAULT_CONFIG["upload_count"], "上传次数限制", 1 + ), + "upload_minute": parse_int_field( + data, "upload_minute", DEFAULT_CONFIG["upload_minute"], "上传检测窗口", 1 + ), + "upload_size": upload_size_value * FILE_SIZE_UNITS[upload_size_unit], + } + +def build_expire_style_inputs(selected_styles: list[str]) -> str: + inputs = [] + selected = set(selected_styles) + for style, label in EXPIRE_STYLE_OPTIONS: + checked = " checked" if style in selected else "" + inputs.append( + f'' + ) + return "\n ".join(inputs) + +def build_setup_page(error: str = "", form: dict | None = None) -> str: + form = form or {} + escaped_error = html.escape(error) + escaped_site_name = html.escape( + get_form_value(form, "site_name", DEFAULT_CONFIG["name"]) + ) + escaped_allowed_types = html.escape(get_form_value(form, "allowed_file_types", "*")) + upload_size_value = html.escape(get_form_value(form, "upload_size_value", "10")) + upload_size_unit = get_form_value(form, "upload_size_unit", "MB").upper() + save_time_value = html.escape(get_form_value(form, "save_time_value", "0")) + save_time_unit = get_form_value(form, "save_time_unit", "day") + upload_minute = html.escape( + get_form_value(form, "upload_minute", str(DEFAULT_CONFIG["upload_minute"])) + ) + upload_count = html.escape( + get_form_value(form, "upload_count", str(DEFAULT_CONFIG["upload_count"])) + ) + error_minute = html.escape( + get_form_value(form, "error_minute", str(DEFAULT_CONFIG["error_minute"])) + ) + error_count = html.escape( + get_form_value(form, "error_count", str(DEFAULT_CONFIG["error_count"])) + ) + login_minute = html.escape( + get_form_value(form, "login_minute", str(DEFAULT_CONFIG["login_minute"])) + ) + login_count = html.escape( + get_form_value(form, "login_count", str(DEFAULT_CONFIG["login_count"])) + ) + open_upload_checked = ( + " checked" if normalize_bool_field(form, "open_upload", True) else "" + ) + chunk_checked = ( + " checked" if normalize_bool_field(form, "enable_chunk", False) else "" + ) + code_generate_type = get_form_value( + form, "code_generate_type", DEFAULT_CONFIG["code_generate_type"] + ) + selected_expire_styles = get_form_list(form, "expire_style") or list( + DEFAULT_CONFIG["expire_style"] + ) + expire_style_inputs = build_expire_style_inputs(selected_expire_styles) + size_unit_options = "\n".join( + f'' + for unit in FILE_SIZE_UNITS + ) + save_time_unit_options = "\n".join( + f'' + for unit, label in [ + ("second", "秒"), + ("minute", "分钟"), + ("hour", "小时"), + ("day", "天"), + ] + ) + code_type_options = "\n".join( + f'' + for value, label in [("number", "数字"), ("secret", "随机字符")] + ) + error_block = ( + f'' if escaped_error else "" + ) + return f""" + + + + + 初始化 FileCodeBox + + + +
+
+
+
FCB
+
+

初始化 FileCodeBox

+

首次配置管理员密码、上传限制和取件策略,后续可在后台调整。

+
+
+ 首次配置向导 +
+
+ {error_block} +
+
+
基础设置
+ + + + + + + + +
+ +
+
上传设置
+ +
+ + +
+ + +
+ + +
+ +
+ + + + +
+
+ +
+
取件与保存
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ +
+
+
可用策略
+ +
+ {expire_style_inputs} +
+
+ +
+ + +
+
+
+ +
+
+ +""" + + +def build_setup_success_page() -> str: + return """ + + + + + + 初始化完成 + + + +
+

初始化完成

+

管理员密码已设置,请使用刚才的密码登录后台。

+ 进入后台 +
+ +""" + +def setup_response(content: str, status_code: int = 200) -> HTMLResponse: + return HTMLResponse( + content=content, + status_code=status_code, + media_type="text/html", + headers={"Cache-Control": "no-store"}, + ) + + +def is_setup_path(path: str) -> bool: + return path.rstrip("/") == "/setup" + + +def wants_html_response(request: Request) -> bool: + if request.method not in {"GET", "HEAD"}: + return False + accept = request.headers.get("accept", "") + return not accept or "text/html" in accept or "*/*" in accept + + +async def read_setup_payload(request: Request) -> dict: + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + data = await request.json() + return data if isinstance(data, dict) else {} + + body = (await request.body()).decode("utf-8") + return { + key: values if len(values) > 1 else values[-1] + for key, values in parse_qs(body).items() + } diff --git a/core/tasks.py b/apps/base/tasks.py similarity index 94% rename from core/tasks.py rename to apps/base/tasks.py index bb57d5f19..4a682877e 100644 --- a/core/tasks.py +++ b/apps/base/tasks.py @@ -15,10 +15,11 @@ UploadChunk, ) from apps.base.utils import ip_limit, get_chunk_file_path_name -from core.config import refresh_settings +from apps.base.config import refresh_settings from core.logger import logger from core.settings import settings, data_root -from core.storage import FileStorageInterface, storages +from core.storage import FileStorageInterface, StoredFile, storages +from apps.base.services import stored_file_of from core.utils import get_now @@ -41,7 +42,7 @@ async def delete_expire_files(): ).all() for exp in expire_data: try: - await file_storage.delete_file(exp) + await file_storage.delete_file(stored_file_of(exp)) except Exception as e: logger.error(f"删除过期文件失败 code={exp.code}: {e}") try: @@ -110,7 +111,7 @@ async def clean_expired_presign_sessions(): try: if await storage.file_exists(session.save_path): await storage.delete_file( - FileCodes( + StoredFile( file_path=os.path.dirname(session.save_path), uuid_file_name=os.path.basename(session.save_path), ) diff --git a/apps/base/utils.py b/apps/base/utils.py index 2f1dba7df..6ac03895c 100644 --- a/apps/base/utils.py +++ b/apps/base/utils.py @@ -1,5 +1,4 @@ import datetime -import hashlib import os import uuid from urllib.parse import unquote @@ -21,34 +20,37 @@ def validate_expire_style(expire_style: str) -> str: """校验过期方式是否在管理员配置的白名单内。""" - if expire_style not in settings.expireStyle: + if expire_style not in settings.expire_style: raise HTTPException(status_code=400, detail="过期时间类型错误") return expire_style -async def get_file_path_name(file: UploadFile) -> Tuple[str, str, str, str, str]: +async def build_file_path( + file_name: str, file_uuid: str +) -> Tuple[str, str, str, str, str]: + """Single source of storage path generation (date dir + UUID), shared by + regular, chunked, and presigned uploads. + + Always use get_now() (UTC+8); do not switch to server-local time. + """ today = await get_now() storage_path = settings.storage_path.strip("/") - file_uuid = uuid.uuid4().hex - filename = await sanitize_filename(unquote(file.filename or "")) + filename = await sanitize_filename(unquote(file_name or "")) base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" path = f"{storage_path}/{base_path}" if storage_path else base_path prefix, suffix = os.path.splitext(filename) - save_path = f"{path}/{filename}" + save_path = f"{path}/{prefix}{suffix}" return path, suffix, prefix, filename, save_path +async def get_file_path_name(file: UploadFile) -> Tuple[str, str, str, str, str]: + return await build_file_path(file.filename or "", uuid.uuid4().hex) + + async def get_chunk_file_path_name( file_name: str, upload_id: str ) -> Tuple[str, str, str, str, str]: - today = await get_now() - storage_path = settings.storage_path.strip("/") - file_name = await sanitize_filename(unquote(file_name or "")) - base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{upload_id}" - path = f"{storage_path}/{base_path}" if storage_path else base_path - prefix, suffix = os.path.splitext(file_name) - save_path = f"{path}/{prefix}{suffix}" - return path, suffix, prefix, file_name, save_path + return await build_file_path(file_name, upload_id) async def get_expire_info( @@ -121,21 +123,9 @@ async def get_random_code(style: str | None = None) -> str: return str(code) -async def calculate_file_hash(file: UploadFile, chunk_size=1024 * 1024) -> str: - sha = hashlib.sha256() - await file.seek(0) - while True: - chunk = await file.read(chunk_size) - if not chunk: - break - sha.update(chunk) - await file.seek(0) - return sha.hexdigest() - - ip_limit = { - "error": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute), - "metadata": IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute), - "upload": IPRateLimit(count=settings.uploadCount, minutes=settings.uploadMinute), - "login": IPRateLimit(count=settings.loginCount, minutes=settings.loginMinute), + "error": IPRateLimit(count=settings.error_count, minutes=settings.error_minute), + "metadata": IPRateLimit(count=settings.error_count, minutes=settings.error_minute), + "upload": IPRateLimit(count=settings.upload_count, minutes=settings.upload_minute), + "login": IPRateLimit(count=settings.login_count, minutes=settings.login_minute), } diff --git a/apps/base/views.py b/apps/base/views.py index aad50a9a5..ef54a84b4 100644 --- a/apps/base/views.py +++ b/apps/base/views.py @@ -1,4 +1,3 @@ -import datetime import hashlib import os import uuid @@ -16,6 +15,15 @@ from apps.admin.dependencies 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 ( + PRESIGN_SESSION_EXPIRES, + FileUploadService, + response_from_download, + stored_file_of, + validate_file_size, +) +from core.storage import StoredFile +from core.logger import logger from apps.base.schemas import ( SelectFileModel, InitChunkUploadModel, @@ -24,8 +32,6 @@ ) from apps.base.file_validation import validate_file_type, validate_upload_file, validate_header_bytes from apps.base.utils import ( - get_expire_info, - get_file_path_name, ip_limit, get_chunk_file_path_name, validate_expire_style, @@ -44,72 +50,6 @@ # ============ 公共服务层 ============ -class FileUploadService: - """统一的文件上传服务""" - - @staticmethod - async def generate_file_path( - file_name: str, upload_id: Optional[str] = None - ) -> tuple[str, str, str, str, str]: - """统一的路径生成""" - today = datetime.datetime.now() - storage_path = settings.storage_path.strip("/") - file_uuid = upload_id or uuid.uuid4().hex - filename = await sanitize_filename(unquote(file_name)) - base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}" - path = f"{storage_path}/{base_path}" if storage_path else base_path - prefix, suffix = os.path.splitext(filename) - save_path = f"{path}/{filename}" - return path, suffix, prefix, filename, save_path - - @staticmethod - async def create_file_record( - file_name: str, - file_size: int, - file_path: str, - expire_value: int, - expire_style: str, - **extra_fields, - ) -> str: - """统一创建FileCodes记录,返回code""" - expired_at, expired_count, used_count, code = await get_expire_info( - expire_value, expire_style - ) - prefix, suffix = os.path.splitext(file_name) - - await FileCodes.create( - code=code, - prefix=prefix, - suffix=suffix, - uuid_file_name=file_name, - file_path=file_path, - size=file_size, - expired_at=expired_at, - expired_count=expired_count, - used_count=used_count, - **extra_fields, - ) - return code - - -async def validate_file_size(file: UploadFile, max_size: int) -> int: - size = file.size - if size is None: - await file.seek(0, 2) # type: ignore[arg-type] - size = file.file.tell() - await file.seek(0) - if size > max_size: - max_size_mb = max_size / (1024 * 1024) - raise HTTPException( - status_code=403, detail=f"大小超过限制,最大为{max_size_mb:.2f} MB" - ) - return size - - - - -async def create_file_code(code, **kwargs): - return await FileCodes.create(code=code, **kwargs) def normalize_share_code(code: str) -> str: @@ -129,23 +69,7 @@ async def share_text( if text_size > max_txt_size: raise HTTPException(status_code=403, detail="内容过多,建议采用文件形式") - reservation_token = f"text:{uuid.uuid4().hex}" - await reserve_storage(reservation_token, text_size, ttl_seconds=300) - try: - expired_at, expired_count, used_count, code = await get_expire_info( - expire_value, expire_style - ) - await create_file_code( - code=code, - text=text, - expired_at=expired_at, - expired_count=expired_count, - used_count=used_count, - size=text_size, - prefix="Text", - ) - finally: - await release_storage(reservation_token) + code = await FileUploadService.create_text_share(text, expire_value, expire_style) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code}) @@ -157,41 +81,14 @@ async def share_file( file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"]), ): - file_size = await validate_file_size(file, settings.uploadSize) + file_size = await validate_file_size(file, settings.upload_size) await validate_upload_file(file) validate_expire_style(expire_style) - path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file) - reservation_token = f"file:{uuid.uuid4().hex}" - await reserve_storage(reservation_token, file_size, ttl_seconds=3600) - file_storage: FileStorageInterface = storages[settings.file_storage]() - try: - expired_at, expired_count, used_count, code = await get_expire_info( - expire_value, expire_style - ) - await file_storage.save_file(file, save_path) - await create_file_code( - code=code, - prefix=prefix, - suffix=suffix, - uuid_file_name=uuid_file_name, - file_path=path, - size=file_size, - expired_at=expired_at, - expired_count=expired_count, - used_count=used_count, - ) - except Exception: - try: - await file_storage.delete_file( - FileCodes(file_path=path, uuid_file_name=uuid_file_name) - ) - except Exception: - pass - raise - finally: - await release_storage(reservation_token) + detail = await FileUploadService.create_file_share( + file, size=file_size, expire_value=expire_value, expire_style=expire_style + ) ip_limit["upload"].add_ip(ip) - return APIResponse(detail={"code": code, "name": file.filename}) + return APIResponse(detail=detail) async def get_code_file_by_code( @@ -242,7 +139,6 @@ def build_file_metadata(file_code: FileCodes) -> dict: "is_text": is_text, "created_at": file_code.created_at, "expired_at": file_code.expired_at, - "expires_at": file_code.expired_at, "expired_count": file_code.expired_count, "used_count": file_code.used_count, "remaining_downloads": remaining_downloads, @@ -259,7 +155,7 @@ async def build_select_detail( # 有次数限制的文件必须经过下载接口,第三方直链无法阻止重复使用。 download_url = await get_proxy_file_url(file_code.code) else: - download_url = await file_storage.get_file_url(file_code) + download_url = await file_storage.get_file_url(stored_file_of(file_code)) content = file_code.text if file_code.text is not None else None return { **metadata, @@ -317,7 +213,7 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): ) }, ) - return await file_storage.get_file_response(file_code) + return response_from_download(await file_storage.get_file_response(stored_file_of(file_code))) @share_api.post("/select/") @@ -362,7 +258,7 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"] return ( APIResponse(detail=file_code.text) if file_code.text - else await file_storage.get_file_response(file_code) + else response_from_download(await file_storage.get_file_response(stored_file_of(file_code))) ) @@ -398,27 +294,12 @@ async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chun # 服务端校验:根据 total_chunks * chunk_size 计算理论最大上传量 total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size max_possible_size = total_chunks * data.chunk_size - if max_possible_size > settings.uploadSize: - max_size_mb = settings.uploadSize / (1024 * 1024) + if max_possible_size > settings.upload_size: + max_size_mb = settings.upload_size / (1024 * 1024) raise HTTPException( status_code=403, detail=f"文件大小超过限制,最大为 {max_size_mb:.2f} MB" ) - # # 秒传检查 - # existing = await FileCodes.filter(file_hash=data.file_hash).first() - # if existing: - # if await existing.is_expired(): - # file_storage: FileStorageInterface = storages[settings.file_storage]( - # ) - # await file_storage.delete_file(existing) - # await existing.delete() - # else: - # return APIResponse(detail={ - # "code": existing.code, - # "existed": True, - # "name": f'{existing.prefix}{existing.suffix}' - # }) - # 断点续传:检查是否存在相同文件的未完成上传会话 existing_session = await UploadChunk.filter( chunk_hash=data.file_hash, @@ -539,8 +420,8 @@ async def upload_chunk( ).count() # 已上传分片的最大可能大小 + 当前分片 max_uploaded_size = uploaded_count * chunk_info.chunk_size + chunk_size - if max_uploaded_size > settings.uploadSize: - max_size_mb = settings.uploadSize / (1024 * 1024) + if max_uploaded_size > settings.upload_size: + max_size_mb = settings.upload_size / (1024 * 1024) raise HTTPException( status_code=403, detail=f"累计上传大小超过限制,最大为 {max_size_mb:.2f} MB" ) @@ -591,8 +472,8 @@ async def cancel_upload(upload_id: str): if save_path: try: await storage.clean_chunks(upload_id, save_path) - except Exception as e: - pass + except Exception: + logger.warning("取消分片上传:清理分片文件失败 upload_id=%s", upload_id, exc_info=True) # 清理数据库记录 await UploadChunk.filter(upload_id=upload_id).delete() @@ -641,87 +522,16 @@ async def complete_upload( if not chunk_info: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="上传会话不存在") validate_expire_style(data.expire_style) - await reserve_storage( - f"chunk:{upload_id}", - chunk_info.file_size, - ttl_seconds=max(1, int(getattr(settings, "chunk_expire_hours", 24))) * 3600, + detail = await FileUploadService.complete_chunked_upload( + upload_id, chunk_info, data.expire_value, data.expire_style ) - - storage = storages[settings.file_storage]() - # 验证所有分片 - completed_chunks_list = await UploadChunk.filter( - upload_id=upload_id, completed=True - ).all() - if len(completed_chunks_list) != chunk_info.total_chunks: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="分片不完整") - - # 用分片数 * chunk_size 校验最大可能大小 - max_total_size = len(completed_chunks_list) * chunk_info.chunk_size - if max_total_size > settings.uploadSize: - save_path = chunk_info.save_path - if save_path: - try: - await storage.clean_chunks(upload_id, save_path) - except Exception: - pass - await UploadChunk.filter(upload_id=upload_id).delete() - await release_storage(f"chunk:{upload_id}") - max_size_mb = settings.uploadSize / (1024 * 1024) - raise HTTPException( - status_code=403, detail=f"实际上传大小超过限制,最大为 {max_size_mb:.2f} MB" - ) - - save_path = chunk_info.save_path - path = os.path.dirname(save_path) if save_path else "" - safe_file_name = os.path.basename(save_path) if save_path else "" - prefix, suffix = os.path.splitext(safe_file_name) - - try: - # 合并文件并计算哈希 - _, file_hash = await storage.merge_chunks(upload_id, chunk_info, save_path) - # 创建文件记录 - expired_at, expired_count, used_count, code = await get_expire_info( - data.expire_value, data.expire_style - ) - await FileCodes.create( - code=code, - file_hash=file_hash, # 使用合并后计算的哈希 - is_chunked=True, - upload_id=upload_id, - size=chunk_info.file_size, - expired_at=expired_at, - expired_count=expired_count, - used_count=used_count, - file_path=path, - uuid_file_name=safe_file_name, - prefix=prefix, - suffix=suffix, - ) - # 清理临时文件 - await storage.clean_chunks(upload_id, save_path) - # 清理数据库中的分片记录 - await UploadChunk.filter(upload_id=upload_id).delete() - await release_storage(f"chunk:{upload_id}") - ip_limit["upload"].add_ip(ip) - return APIResponse(detail={"code": code, "name": safe_file_name}) - except ValueError as e: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) - except Exception as e: - # 合并失败时清理临时文件 - try: - await storage.clean_chunks(upload_id, save_path) - except Exception: - pass - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件合并失败: {str(e)}" - ) + ip_limit["upload"].add_ip(ip) + return APIResponse(detail=detail) # ============ 预签名上传API ============ presign_api = APIRouter(prefix="/presign", tags=["预签名上传"]) -PRESIGN_SESSION_EXPIRES = 900 # 15分钟 - def build_proxy_upload_urls(upload_id: str) -> dict: proxy_upload_url = f"/presign/upload/proxy/{upload_id}" @@ -753,10 +563,10 @@ async def presign_upload_init( ): """初始化预签名上传,S3返回直传URL,其他存储返回代理URL""" validate_file_type(data.file_name) - if data.file_size > settings.uploadSize: + if data.file_size > settings.upload_size: raise HTTPException( 403, - f"文件大小超过限制,最大为 {settings.uploadSize / (1024 * 1024):.2f} MB", + f"文件大小超过限制,最大为 {settings.upload_size / (1024 * 1024):.2f} MB", ) validate_expire_style(data.expire_style) @@ -813,45 +623,7 @@ async def presign_upload_proxy( ): """代理模式上传,服务器转存到存储后端""" session = await _get_valid_session(upload_id, expected_mode="proxy") - await reserve_storage( - f"presign:{upload_id}", - session.file_size, - ttl_seconds=PRESIGN_SESSION_EXPIRES, - ) - - file_size = await validate_file_size(file, settings.uploadSize) - await validate_upload_file(file) - if abs(file_size - session.file_size) > 1024: - raise HTTPException(400, "文件大小与声明不符") - - storage: FileStorageInterface = storages[settings.file_storage]() - try: - await storage.save_file(file, session.save_path) - except Exception as e: - raise HTTPException(500, f"文件保存失败: {str(e)}") - - try: - code = await FileUploadService.create_file_record( - session.file_name, - file_size, - os.path.dirname(session.save_path), - session.expire_value, - session.expire_style, - ) - except Exception: - try: - await storage.delete_file( - FileCodes( - file_path=os.path.dirname(session.save_path), - uuid_file_name=os.path.basename(session.save_path), - ) - ) - except Exception: - pass - raise - - await session.delete() - await release_storage(f"presign:{upload_id}") + code = await FileUploadService.commit_proxy_upload(session, file) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @@ -862,53 +634,7 @@ async def presign_upload_proxy( async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upload"])): """直传确认,客户端完成S3直传后调用获取分享码""" session = await _get_valid_session(upload_id, expected_mode="direct") - try: - await reserve_storage( - f"presign:{upload_id}", - session.file_size, - ttl_seconds=PRESIGN_SESSION_EXPIRES, - ) - except HTTPException: - storage: FileStorageInterface = storages[settings.file_storage]() - try: - if await storage.file_exists(session.save_path): - await storage.delete_file( - FileCodes( - file_path=os.path.dirname(session.save_path), - uuid_file_name=os.path.basename(session.save_path), - ) - ) - finally: - await session.delete() - await release_storage(f"presign:{upload_id}") - raise - - storage: FileStorageInterface = storages[settings.file_storage]() - if not await storage.file_exists(session.save_path): - raise HTTPException(404, "文件未上传或上传失败") - - try: - code = await FileUploadService.create_file_record( - session.file_name, - session.file_size, - os.path.dirname(session.save_path), - session.expire_value, - session.expire_style, - ) - except Exception: - try: - await storage.delete_file( - FileCodes( - file_path=os.path.dirname(session.save_path), - uuid_file_name=os.path.basename(session.save_path), - ) - ) - except Exception: - pass - raise - - await session.delete() - await release_storage(f"presign:{upload_id}") + code = await FileUploadService.confirm_direct_upload(session) ip_limit["upload"].add_ip(ip) return APIResponse(detail={"code": code, "name": session.file_name}) @@ -946,13 +672,13 @@ async def presign_upload_cancel(upload_id: str): storage: FileStorageInterface = storages[settings.file_storage]() try: if await storage.file_exists(session.save_path): - temp_file_code = FileCodes( + temp_file_code = StoredFile( file_path=os.path.dirname(session.save_path), uuid_file_name=os.path.basename(session.save_path), ) await storage.delete_file(temp_file_code) except Exception: - pass + logger.warning("取消预签名会话:清理临时文件失败 upload_id=%s", upload_id, exc_info=True) await session.delete() await release_storage(f"presign:{upload_id}") diff --git a/core/errors.py b/core/errors.py new file mode 100644 index 000000000..5a4085d4e --- /dev/null +++ b/core/errors.py @@ -0,0 +1,13 @@ +"""Framework-neutral errors raised by storage backends. + +main.py registers an exception handler that renders these exactly like +FastAPI's HTTPException responses ({"detail": ...} with the same status +code), so callers need no per-site wrapping while core/ stays framework-free. +""" + + +class StorageError(Exception): + def __init__(self, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(detail) diff --git a/core/response.py b/core/response.py index 1a93e23bc..2e9bca54f 100644 --- a/core/response.py +++ b/core/response.py @@ -1,7 +1,3 @@ -# @Time : 2023/8/14 11:48 -# @Author : Lan -# @File : response.py -# @Software: PyCharm from typing import Generic, Optional, TypeVar from pydantic import BaseModel @@ -12,9 +8,4 @@ class APIResponse(BaseModel, Generic[T]): code: int = 200 message: str = "ok" - msg: Optional[str] = None detail: Optional[T] = None - - def model_post_init(self, __context) -> None: - if self.msg is None: - self.msg = self.message diff --git a/core/security.py b/core/security.py index 7625287eb..1e8055632 100644 --- a/core/security.py +++ b/core/security.py @@ -29,9 +29,18 @@ def is_valid_jwt_secret(secret: Any) -> bool: def is_config_initialized(config: dict[str, Any]) -> bool: + """Cheap initialization probe — runs on EVERY request via the middleware. + + Must never run a slow hash: a scrypt-stored token cannot be the legacy + default (which only ever existed as plaintext or sha256), so those tokens + are initialized without any verification. Only plaintext/sha256 tokens go + through the legacy-default comparison, which is microseconds. + """ admin_token = str(config.get("admin_token") or "") if not admin_token: return False + if is_password_hashed(admin_token) and admin_token.startswith("scrypt$"): + return True return not verify_password(LEGACY_DEFAULT_ADMIN_TOKEN, admin_token) diff --git a/core/settings.py b/core/settings.py index 0fabae97d..19f58b13e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -17,7 +17,7 @@ DEFAULT_CONFIG = { "file_storage": "local", "storage_path": "", - "storageLimit": 0, + "storage_limit": 0, "name": "文件快递柜 - FileCodeBox", "description": "开箱即用的文件快传系统", "notify_title": "系统通知", @@ -45,21 +45,21 @@ "webdav_proxy": 0, "admin_token": "", "jwt_secret": "", - "adminSessionExpire": ADMIN_SESSION_EXPIRE_DEFAULT, - "openUpload": 1, - "uploadSize": 1024 * 1024 * 10, + "admin_session_expire": ADMIN_SESSION_EXPIRE_DEFAULT, + "open_upload": 1, + "upload_size": 1024 * 1024 * 10, "allowed_file_types": ["*"], - "expireStyle": ["day", "hour", "minute", "forever", "count"], + "expire_style": ["day", "hour", "minute", "forever", "count"], "code_generate_type": "secret", - "uploadMinute": 1, - "enableChunk": 0, + "upload_minute": 1, + "enable_chunk": 0, "webdav_url": "", "webdav_password": "", "webdav_username": "", "opacity": 0.9, "background": "", - "uploadCount": 10, - "themesChoices": [ + "upload_count": 10, + "themes_choices": [ { "name": "2023", "key": "themes/2023", @@ -73,17 +73,19 @@ "version": "1.0", }, ], - "themesSelect": "themes/2024", - "errorMinute": 1, - "errorCount": 10, - "loginCount": 5, - "loginMinute": 15, - "serverWorkers": 1, - "serverHost": "0.0.0.0", - "serverPort": 12345, - "showAdminAddr": 0, - "robotsText": "User-agent: *\nDisallow: /", - "trustedProxies": [], + "themes_select": "themes/2024", + "error_minute": 1, + "error_count": 10, + "login_count": 5, + "login_minute": 15, + "server_workers": 1, + "server_host": "0.0.0.0", + "server_port": 12345, + "show_admin_addr": 0, + "robots_text": "User-agent: *\nDisallow: /", + "trusted_proxies": [], + "chunk_expire_hours": 24, + "opendal_scheme": "s3", } @@ -107,6 +109,14 @@ def __setattr__(self, key, value): else: self.user_config[key] = value + def unknown_keys(self, config: dict) -> list[str]: + """Keys in `config` that DEFAULT_CONFIG does not define. + + Unknown keys silently fall through __getattr__ and crash later at the + usage site; callers (refresh_settings) surface them at load time. + """ + return sorted(k for k in config if k not in self.default_config) + def items(self): return {**self.default_config, **self.user_config}.items() diff --git a/core/storage.py b/core/storage.py index daf14a7f6..c5ab29066 100644 --- a/core/storage.py +++ b/core/storage.py @@ -8,7 +8,7 @@ import tempfile from core.logger import logger import shutil -from typing import Optional +from typing import BinaryIO, Optional from urllib.parse import quote, unquote import aiofiles @@ -16,33 +16,98 @@ import asyncio from pathlib import Path import datetime +from dataclasses import dataclass import re import aioboto3 from botocore.config import Config -from fastapi import HTTPException, Response, UploadFile -from core.response import APIResponse +from core.errors import StorageError from core.settings import data_root, settings -from apps.base.models import FileCodes, UploadChunk from core.utils import get_file_url, sanitize_filename -from fastapi.responses import FileResponse, StreamingResponse 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}" + + class FileStorageInterface: - async def save_file(self, file: UploadFile, save_path: str): - """ - 保存文件 + @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: FileCodes): + async def delete_file(self, file_code: StoredFile): """ 删除文件 """ raise NotImplementedError - async def get_file_url(self, file_code: FileCodes): + async def get_file_url(self, file_code: StoredFile): """ 获取文件分享的url @@ -51,7 +116,7 @@ async def get_file_url(self, file_code: FileCodes): """ raise NotImplementedError - async def get_file_response(self, file_code: FileCodes): + async def get_file_response(self, file_code: StoredFile): """ 获取文件响应 @@ -71,7 +136,7 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, """ raise NotImplementedError - async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + 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 @@ -132,7 +197,9 @@ def _save(self, file, save_path): f.write(chunk) chunk = file.read(self.chunk_size) - async def save_file(self, file: UploadFile, save_path: str): + 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("/") # 提取原始文件名并进行清理 @@ -142,20 +209,20 @@ async def save_file(self, file: UploadFile, save_path: str): # 确保目录存在 if not safe_save_path.parent.exists(): safe_save_path.parent.mkdir(parents=True) - await asyncio.to_thread(self._save, file.file, safe_save_path) + await asyncio.to_thread(self._save, stream, safe_save_path) - async def delete_file(self, file_code: FileCodes): - save_path = self._resolve_safe_path(await file_code.get_file_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: FileCodes): + 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: FileCodes): - file_path = self._resolve_safe_path(await file_code.get_file_path()) + 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(): - return APIResponse(code=404, detail="文件已过期删除") + 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}" @@ -169,11 +236,10 @@ async def get_file_response(self, file_code: FileCodes): # 如果获取文件大小失败,则不提供 Content-Length pass - return FileResponse( - file_path, - media_type="application/octet-stream", + return StoredDownload( + filename=filename, headers=headers, - filename=filename # 保留原始文件名以备某些场景使用 + path=file_path, ) async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, chunk_hash: str, save_path: str): @@ -204,7 +270,7 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, temp_path.unlink() raise e - async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + 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 @@ -223,20 +289,15 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: temp_output = output_path.with_suffix('.merging') try: async with aiofiles.open(temp_output, "wb") as out_file: - for i in range(chunk_info.total_chunks): + for i in range(total_chunks): # 获取分片记录 - chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() - if not chunk_record: - raise ValueError(f"分片{i}记录不存在") + 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() - current_hash = hashlib.sha256(chunk_data).hexdigest() - if current_hash != chunk_record.chunk_hash: - raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") - file_sha256.update(chunk_data) + self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256) await out_file.write(chunk_data) # 原子重命名 temp_output.rename(output_path) @@ -321,23 +382,25 @@ def _client(self): config=self._client_config(), ) - async def save_file(self, file: UploadFile, save_path: str): + 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( - file.file, + stream, self.bucket_name, save_path, - ExtraArgs={"ContentType": file.content_type or "application/octet-stream"}, + ExtraArgs={"ContentType": content_type or "application/octet-stream"}, ) - async def delete_file(self, file_code: FileCodes): + async def delete_file(self, file_code: StoredFile): async with self._client() as s3: await s3.delete_object( - Bucket=self.bucket_name, Key=await file_code.get_file_path() + Bucket=self.bucket_name, Key=file_code.get_file_path() ) - async def get_file_response(self, file_code: FileCodes): + async def get_file_response(self, file_code: StoredFile): try: filename = file_code.prefix + file_code.suffix content_length = None # 初始化为 None,表示未知大小 @@ -347,7 +410,7 @@ async def get_file_response(self, file_code: FileCodes): try: head_response = await s3.head_object( Bucket=self.bucket_name, - Key=await file_code.get_file_path() + Key=file_code.get_file_path() ) # 从HEAD响应中获取Content-Length if 'ContentLength' in head_response: @@ -362,7 +425,7 @@ async def get_file_response(self, file_code: FileCodes): "get_object", Params={ "Bucket": self.bucket_name, - "Key": await file_code.get_file_path(), + "Key": file_code.get_file_path(), }, ExpiresIn=3600, ) @@ -374,7 +437,7 @@ async def stream_generator(): try: async with session.get(link) as resp: if resp.status != 200: - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"从S3获取文件失败: {resp.status}" ) @@ -388,26 +451,25 @@ async def stream_generator(): finally: await session.close() - from fastapi.responses import StreamingResponse 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 StreamingResponse( - stream_generator(), - media_type="application/octet-stream", + return StoredDownload( + filename=filename, headers=headers, + stream_factory=stream_generator, # 兜底关闭会话:客户端中断时与 generator finally 双保险 background=BackgroundTask(session.close), ) - except HTTPException: + except StorageError: raise except Exception: - raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试") + raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试") - async def get_file_url(self, file_code: FileCodes): + async def get_file_url(self, file_code: StoredFile): if file_code.prefix == "文本分享": return file_code.text if self.proxy: @@ -418,7 +480,7 @@ async def get_file_url(self, file_code: FileCodes): "get_object", Params={ "Bucket": self.bucket_name, - "Key": await file_code.get_file_path(), + "Key": file_code.get_file_path(), }, ExpiresIn=3600, ) @@ -442,7 +504,7 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, } ) - async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + 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 实现流式合并,避免内存问题 @@ -462,11 +524,9 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: try: # 按顺序读取、验证并上传每个分片 - for i in range(chunk_info.total_chunks): + for i in range(total_chunks): chunk_key = f"{chunk_dir}/{i}.part" - chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() - if not chunk_record: - raise ValueError(f"分片{i}记录不存在") + chunk_record = self._get_chunk_record(chunk_records, i) try: response = await s3.get_object( @@ -477,11 +537,7 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: except Exception as e: raise ValueError(f"分片{i}文件不存在: {e}") - current_hash = hashlib.sha256(chunk_data).hexdigest() - if current_hash != chunk_record.chunk_hash: - raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") - - file_sha256.update(chunk_data) + self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256) # 上传分片到 multipart upload part_response = await s3.upload_part( @@ -650,14 +706,32 @@ def _get_path_str(self, path): path[-1] = path[-1].split(".")[0] return "/".join(path) - def _save(self, file, save_path): - content = file.file.read() - name = save_path(file.filename) - path = self._get_path_str(save_path) - self.root_path.get_by_path(path).upload(name, content).execute_query() + 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]) - async def save_file(self, file: UploadFile, save_path: str): - await asyncio.to_thread(self._save, file, save_path) + 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) @@ -669,8 +743,8 @@ def _delete(self, save_path): else: raise e - async def delete_file(self, file_code: FileCodes): - await asyncio.to_thread(self._delete, await file_code.get_file_path()) + 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) @@ -691,11 +765,11 @@ def _get_file_url(self, save_path, name): ).execute_query() return self._convert_link_to_download_link(permission.link.webUrl) - async def get_file_response(self, file_code: FileCodes): + 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, await file_code.get_file_path(), filename + self._get_file_url, file_code.get_file_path(), filename ) content_length = None # 初始化为 None,表示未知大小 @@ -716,7 +790,7 @@ async def stream_generator(): try: async with session.get(link) as resp: if resp.status != 200: - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"从OneDrive获取文件失败: {resp.status}" ) @@ -735,25 +809,25 @@ async def stream_generator(): } if content_length is not None: headers["Content-Length"] = str(content_length) - return StreamingResponse( - stream_generator(), - media_type="application/octet-stream", + return StoredDownload( + filename=filename, headers=headers, + stream_factory=stream_generator, # 兜底关闭会话:客户端中断时与 generator finally 双保险 background=BackgroundTask(session.close), ) - except HTTPException: + except StorageError: raise except Exception: - raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试") + raise StorageError(status_code=503, detail="服务代理下载异常,请稍后再试") - async def get_file_url(self, file_code: FileCodes): + 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, - await file_code.get_file_path(), + file_code.get_file_path(), f"{file_code.prefix}{file_code.suffix}", ) @@ -809,7 +883,7 @@ def _upload_merged(self, save_path: str, data: bytes): current_folder.upload(filename, data).execute_query() - async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + 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) @@ -820,22 +894,16 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: try: async with aiofiles.open(temp_path, 'wb') as out_file: - for i in range(chunk_info.total_chunks): + for i in range(total_chunks): chunk_path = f"{chunk_dir}/{i}.part" - chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() - if not chunk_record: - raise ValueError(f"分片{i}记录不存在") + 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}") - current_hash = hashlib.sha256(chunk_data).hexdigest() - if current_hash != chunk_record.chunk_hash: - raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") - - file_sha256.update(chunk_data) + self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256) await out_file.write(chunk_data) del chunk_data # 释放内存 @@ -903,25 +971,27 @@ def __init__(self): settings.opendal_scheme, **service_settings ) - async def save_file(self, file: UploadFile, save_path: str): + async def save_file( + self, stream: BinaryIO, save_path: str, content_type: Optional[str] = None + ): # 使用 asyncio.to_thread 避免阻塞事件循环 - content = await asyncio.to_thread(file.file.read) + content = await asyncio.to_thread(stream.read) await self.operator.write(save_path, content) - async def delete_file(self, file_code: FileCodes): - await self.operator.delete(await file_code.get_file_path()) + 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: FileCodes): + 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: FileCodes): + 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(await file_code.get_file_path()) + 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: @@ -933,18 +1003,20 @@ async def get_file_response(self, file_code: FileCodes): # 尝试使用流式读取器 try: # OpenDAL 可能提供 reader 方法返回一个异步读取器 - reader = await self.operator.reader(await file_code.get_file_path()) + reader = await self.operator.reader(file_code.get_file_path()) except AttributeError: # 如果 reader 方法不存在,回退到全量读取(兼容旧版本) - content = await self.operator.read(await file_code.get_file_path()) + 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) - return Response( - content, headers=headers, media_type="application/octet-stream" + return StoredDownload( + filename=filename, + headers=headers, + content=content, ) async def stream_generator(): @@ -961,21 +1033,21 @@ async def stream_generator(): } if content_length is not None: headers["Content-Length"] = str(content_length) - return StreamingResponse( - stream_generator(), - media_type="application/octet-stream", - headers=headers + return StoredDownload( + filename=filename, + headers=headers, + stream_factory=stream_generator, ) except Exception as e: logger.info(e) - raise HTTPException(status_code=404, detail="文件已过期删除") + 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, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + 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) @@ -986,22 +1058,16 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: try: async with aiofiles.open(temp_path, 'wb') as out_file: - for i in range(chunk_info.total_chunks): + for i in range(total_chunks): chunk_path = f"{chunk_dir}/{i}.part" - chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() - if not chunk_record: - raise ValueError(f"分片{i}记录不存在") + 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}") - current_hash = hashlib.sha256(chunk_data).hexdigest() - if current_hash != chunk_record.chunk_hash: - raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") - - file_sha256.update(chunk_data) + self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256) await out_file.write(chunk_data) del chunk_data # 释放内存 @@ -1071,7 +1137,7 @@ async def _mkdir_p(self, directory_path: str): async with session.request("MKCOL", url) as mkcol_resp: if mkcol_resp.status not in (200, 201, 409): content = await mkcol_resp.text() - raise HTTPException( + raise StorageError( status_code=mkcol_resp.status, detail=f"目录创建失败: {content[:200]}", ) @@ -1104,7 +1170,9 @@ async def _delete_empty_dirs(self, file_path: str, session: aiohttp.ClientSessio current_path = current_path.parent - async def save_file(self, file: UploadFile, save_path: str): + 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) @@ -1123,7 +1191,7 @@ async def file_sender(): """流式读取文件内容""" chunk_size = 256 * 1024 # 256KB chunks while True: - chunk = await asyncio.to_thread(file.file.read, chunk_size) + chunk = await asyncio.to_thread(stream.read, chunk_size) if not chunk: break yield chunk @@ -1132,21 +1200,21 @@ async def file_sender(): async with session.put( url, data=file_sender(), - headers={"Content-Type": file.content_type or "application/octet-stream"} + headers={"Content-Type": content_type or "application/octet-stream"} ) as resp: if resp.status not in (200, 201, 204): content = await resp.text() - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"文件上传失败: {content[:200]}", ) except aiohttp.ClientError as e: - raise HTTPException( + raise StorageError( status_code=503, detail=f"WebDAV连接异常: {str(e)}") - async def delete_file(self, file_code: FileCodes): + async def delete_file(self, file_code: StoredFile): """删除WebDAV文件及空目录""" - file_path = await file_code.get_file_path() + file_path = file_code.get_file_path() url = self._build_url(file_path) try: async with aiohttp.ClientSession(auth=self.auth) as session: @@ -1154,7 +1222,7 @@ async def delete_file(self, file_code: FileCodes): async with session.delete(url) as resp: if resp.status not in (200, 204, 404): content = await resp.text() - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"WebDAV删除失败: {content[:200]}", ) @@ -1163,17 +1231,17 @@ async def delete_file(self, file_code: FileCodes): await self._delete_empty_dirs(file_path, session) except aiohttp.ClientError as e: - raise HTTPException( + raise StorageError( status_code=503, detail=f"WebDAV连接异常: {str(e)}") - async def get_file_url(self, file_code: FileCodes): + 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: FileCodes): + async def get_file_response(self, file_code: StoredFile): """获取文件响应(代理模式)""" try: filename = file_code.prefix + file_code.suffix - url = self._build_url(await file_code.get_file_path()) + url = self._build_url(file_code.get_file_path()) content_length = None # 初始化为 None,表示未知大小 # 创建ClientSession并复用(包含认证头) @@ -1194,7 +1262,7 @@ async def stream_generator(): try: async with session.get(url) as resp: if resp.status != 200: - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"文件获取失败{resp.status}: {await resp.text()}", ) @@ -1213,15 +1281,15 @@ async def stream_generator(): } if content_length is not None: headers["Content-Length"] = str(content_length) - return StreamingResponse( - stream_generator(), - media_type="application/octet-stream", + return StoredDownload( + filename=filename, headers=headers, + stream_factory=stream_generator, # 兜底关闭会话:客户端中断时与 generator finally 双保险 background=BackgroundTask(session.close), ) except aiohttp.ClientError as e: - raise HTTPException( + 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): @@ -1237,12 +1305,12 @@ async def save_chunk(self, upload_id: str, chunk_index: int, chunk_data: bytes, async with session.put(chunk_url, data=chunk_data) as resp: if resp.status not in (200, 201, 204): content = await resp.text() - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"分片上传失败: {content[:200]}" ) - async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: str) -> tuple[str, str]: + async def merge_chunks(self, upload_id: str, total_chunks: int, chunk_size: int, save_path: str, chunk_records: dict) -> tuple[str, str]: """ 合并 WebDAV 上的分片文件 使用临时文件避免内存问题 @@ -1258,14 +1326,12 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: async with aiohttp.ClientSession(auth=self.auth) as session: # 按顺序读取并验证每个分片,写入临时文件 async with aiofiles.open(temp_path, 'wb') as out_file: - for i in range(chunk_info.total_chunks): + for i in range(total_chunks): chunk_path = f"{chunk_dir}/{i}.part" chunk_url = self._build_url(chunk_path) # 获取分片记录 - chunk_record = await UploadChunk.filter(upload_id=upload_id, chunk_index=i).first() - if not chunk_record: - raise ValueError(f"分片{i}记录不存在") + chunk_record = self._get_chunk_record(chunk_records, i) # 下载分片数据 async with session.get(chunk_url) as resp: @@ -1274,11 +1340,7 @@ async def merge_chunks(self, upload_id: str, chunk_info: UploadChunk, save_path: chunk_data = await resp.read() # 验证哈希 - current_hash = hashlib.sha256(chunk_data).hexdigest() - if current_hash != chunk_record.chunk_hash: - raise ValueError(f"分片{i}哈希不匹配: 期望 {chunk_record.chunk_hash}, 实际 {current_hash}") - - file_sha256.update(chunk_data) + self._verify_and_hash_chunk(i, chunk_record, chunk_data, file_sha256) await out_file.write(chunk_data) del chunk_data # 释放内存 @@ -1300,7 +1362,7 @@ async def file_sender(): async with session.put(output_url, data=file_sender()) as resp: if resp.status not in (200, 201, 204): content = await resp.text() - raise HTTPException( + raise StorageError( status_code=resp.status, detail=f"合并文件上传失败: {content[:200]}" ) diff --git a/core/utils.py b/core/utils.py index 8576e4391..42f0cbee9 100644 --- a/core/utils.py +++ b/core/utils.py @@ -25,6 +25,27 @@ async def get_random_num(): r_s = string.ascii_uppercase + string.digits +def validate_background_url(value) -> str: + """Validate the site background config before it reaches the theme template. + + Themes inject this value into inline CSS ``url('...')`` where html escaping + cannot neutralize a single-quote breakout, so only well-formed http(s) URLs + (or an empty string) are accepted. + """ + value = str(value or "").strip() + if not value: + return "" + from urllib.parse import urlparse + + parsed = urlparse(value) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("background 必须是 http(s) 完整 URL 或留空") + # quote()/whitespace would break out of the CSS url('') quoting context + if any(ch in value for ch in ("'", '"', "(", ")", " ", "\\", ";")): + raise ValueError("background URL 含有不允许的字符") + return value + + async def get_random_string(): """ 获取随机字符串 @@ -105,25 +126,59 @@ def gen_desc_en(value: int, desc: str): return desc_zh, desc_en +# scrypt work factors (OWASP-recommended memory-hard parameters; ~50ms/verify) +SCRYPT_N = 2**14 +SCRYPT_R = 8 +SCRYPT_P = 1 +_SCRYPT_MAXMEM = 64 * 1024 * 1024 # hashlib default cap (32MB) is too low for n=2^14,r=8 + +PASSWORD_SCHEME = "scrypt" # current scheme for new hashes; sha256/plaintext stay verifiable + + def hash_password(password: str) -> str: """ - 使用 SHA256 + salt 哈希密码 - 返回格式: sha256$$ + 使用 scrypt(memory-hard)哈希密码 + 返回格式: scrypt$$$

$$ """ salt = os.urandom(16).hex() - password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest() - return f"sha256${salt}${password_hash}" + password_hash = hashlib.scrypt( + password.encode(), + salt=salt.encode(), + n=SCRYPT_N, + r=SCRYPT_R, + p=SCRYPT_P, + maxmem=_SCRYPT_MAXMEM, + ).hex() + return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${salt}${password_hash}" def verify_password(password: str, hashed: str) -> bool: """ 验证密码是否匹配 - 支持新格式 (sha256$salt$hash) 和旧格式 (明文) + 支持格式: scrypt$n$r$p$salt$hash(现行)、sha256$salt$hash(历史)、明文(最老) """ if not hashed: return False - # 新格式: sha256$salt$hash + if hashed.startswith("scrypt$"): + parts = hashed.split("$") + if len(parts) != 6: + return False + try: + scheme, n, r, p, salt, stored_hash = parts + password_hash = hashlib.scrypt( + password.encode(), + salt=salt.encode(), + n=int(n), + r=int(r), + p=int(p), + maxmem=_SCRYPT_MAXMEM, + ).hex() + except (ValueError, TypeError): + return False + return hmac.compare_digest(password_hash, stored_hash) + + # sha256 格式: sha256$salt$hash(历史数据,验证逻辑保持原样) if hashed.startswith("sha256$"): parts = hashed.split("$") if len(parts) != 3: @@ -138,11 +193,18 @@ def verify_password(password: str, hashed: str) -> bool: def is_password_hashed(password: str) -> bool: """ - 检查密码是否已经是哈希格式 + 检查密码是否已经是哈希格式(现行或历史哈希均视为已哈希) """ + if password.startswith("scrypt$") and len(password.split("$")) == 6: + return True return password.startswith("sha256$") and len(password.split("$")) == 3 +def password_needs_rehash(hashed: str) -> bool: + """True when the stored hash uses a legacy scheme (sha256/plaintext).""" + return bool(hashed) and not hashed.startswith(f"{PASSWORD_SCHEME}$") + + async def sanitize_filename(filename: str) -> str: """ 安全处理文件名: diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b360d6445..efd8ddb1e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -205,6 +205,7 @@ export default defineConfig({ text: 'API Reference', items: [ { text: 'API Overview', link: '/en/api/' }, + { text: 'Presigned Upload', link: '/en/api/presign-upload' }, { text: 'Share API', link: '/en/api/#share-api' }, { text: 'Admin API', link: '/en/api/#admin-api' }, { text: 'Error Response', link: '/en/api/#error-response' }, diff --git a/docs/api/index.md b/docs/api/index.md index aefbde348..f2456cb15 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,6 +1,6 @@ # FileCodeBox API 文档 -## API 版本: 2.1.0 +## API 版本: 2.5.6 ## 目录 - [认证](#认证) @@ -30,7 +30,7 @@ curl -X POST "http://localhost:12345/admin/login" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "token": "xxx.xxx.xxx", "token_type": "Bearer" @@ -74,7 +74,7 @@ curl -X POST "http://localhost:12345/share/text/" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123" } @@ -125,7 +125,7 @@ curl -X POST "http://localhost:12345/share/file/" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt" @@ -157,7 +157,7 @@ curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt", @@ -184,7 +184,7 @@ curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt", @@ -232,15 +232,15 @@ curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { - "totalFiles": 100, - "storageUsed": "1.5GB", - "sysUptime": "10天", - "yesterdayCount": 50, - "yesterdaySize": "500MB", - "todayCount": 30, - "todaySize": "300MB" + "total_files": 100, + "storage_used": "1610612736", + "sys_uptime": 1725489600.0, + "yesterday_count": 50, + "yesterday_size": "524288000", + "today_count": 30, + "today_size": "314572800", } } ``` @@ -258,13 +258,15 @@ curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file | page | integer | 否 | 1 | 当前页码 | | size | integer | 否 | 10 | 每页数量 | | keyword | string | 否 | "" | 搜索关键词 | +| sort_by | string | 否 | "created_at" | 排序字段(created_at/expired_at/name/size/used_count/code) | +| sort_order | string | 否 | "desc" | 排序方向(asc/desc) | **响应示例:** ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "page": 1, "size": 10, diff --git a/docs/api/presign-upload.md b/docs/api/presign-upload.md index d98b20cd5..282c35248 100644 --- a/docs/api/presign-upload.md +++ b/docs/api/presign-upload.md @@ -76,7 +76,6 @@ Content-Type: application/json "upload_id": "a1b2c3d4e5f6...", "upload_url": "https://bucket.s3.amazonaws.com/path?X-Amz-Signature=...", "mode": "direct", - "save_path": "share/data/2024/01/01/uuid/document.pdf", "expires_in": 900 } } @@ -89,7 +88,6 @@ Content-Type: application/json | upload_id | string | 上传会话 ID,后续操作需要 | | upload_url | string | 上传目标 URL | | mode | string | 上传模式:`direct`(直传 S3)或 `proxy`(服务器代理) | -| save_path | string | 文件存储路径 | | expires_in | integer | URL 有效期(秒),默认 900 秒(15 分钟) | **错误响应** @@ -308,7 +306,6 @@ interface PresignInitResponse { upload_id: string upload_url: string mode: 'direct' | 'proxy' - save_path: string expires_in: number } @@ -448,7 +445,7 @@ async function upload() { ## 注意事项 1. **会话有效期**: 上传会话默认 15 分钟后过期,请在有效期内完成上传 -2. **文件大小限制**: 受系统配置 `uploadSize` 限制 +2. **文件大小限制**: 受系统配置 `upload_size` 限制 3. **过期类型**: 支持 `day`、`hour`、`minute`、`forever`、`count` 4. **CORS**: 直传模式下,S3 需要配置正确的 CORS 策略 5. **重试机制**: 建议实现上传失败重试逻辑 diff --git a/docs/en/api/index.md b/docs/en/api/index.md index 1ec342fef..012edf7ea 100644 --- a/docs/en/api/index.md +++ b/docs/en/api/index.md @@ -1,6 +1,6 @@ # FileCodeBox API Documentation -## API Version: 2.1.0 +## API Version: 2.5.6 ## Table of Contents - [Authentication](#authentication) @@ -30,7 +30,7 @@ curl -X POST "http://localhost:12345/admin/login" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "token": "xxx.xxx.xxx", "token_type": "Bearer" @@ -74,7 +74,7 @@ curl -X POST "http://localhost:12345/share/text/" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123" } @@ -125,7 +125,7 @@ curl -X POST "http://localhost:12345/share/file/" \ ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt" @@ -157,7 +157,7 @@ curl -L "http://localhost:12345/share/select/?code=abc123" -o downloaded_file ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt", @@ -184,7 +184,7 @@ Select file by share code. ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "code": "abc123", "name": "example.txt", @@ -232,15 +232,15 @@ Get system dashboard data. ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { - "totalFiles": 100, - "storageUsed": "1.5GB", - "sysUptime": "10 days", - "yesterdayCount": 50, - "yesterdaySize": "500MB", - "todayCount": 30, - "todaySize": "300MB" + "total_files": 100, + "storage_used": "1610612736", + "sys_uptime": 1725489600.0, + "yesterday_count": 50, + "yesterday_size": "524288000", + "today_count": 30, + "today_size": "314572800", } } ``` @@ -258,13 +258,15 @@ Get system file list. | page | integer | No | 1 | Current page | | size | integer | No | 10 | Page size | | keyword | string | No | "" | Search keyword | +| sort_by | string | No | "created_at" | Sort field(created_at/expired_at/name/size/used_count/code) | +| sort_order | string | No | "desc" | Sort direction(asc/desc) | **Response Example:** ```json { "code": 200, - "msg": "success", + "message": "success", "detail": { "page": 1, "size": 10, diff --git a/docs/en/api/presign-upload.md b/docs/en/api/presign-upload.md new file mode 100644 index 000000000..d4d0054ce --- /dev/null +++ b/docs/en/api/presign-upload.md @@ -0,0 +1,389 @@ +# Presigned Upload API + +## Overview + +The presigned upload feature provides a unified file upload interface that automatically picks the optimal upload method based on the configured storage backend: + +- **S3 storage**: returns a presigned URL so the client uploads directly to S3 (reduces server bandwidth usage) +- **Other storage**: returns a proxy upload URL and the file is transferred through the server + +## Upload Flow + +### Flow Diagram + +``` +┌─────────┐ 1. Initialize upload ┌─────────┐ +│ Client │ ───────────────────────▶ │ Server │ +└─────────┘ └─────────┘ + │ │ + │◀──── upload_url + mode ──────────┤ + │ │ + │ ┌─────────────────────────────────────────┐ + │ │ if mode == "direct" (S3 storage) │ + │ │ 2a. PUT file to upload_url (S3) │ + │ │ 3a. POST /confirm to finish │ + │ │ │ + │ │ if mode == "proxy" (other storage) │ + │ │ 2b. PUT file to upload_url (server) │ + │ │ (share code returned automatically, │ + │ │ no confirmation needed) │ + │ └─────────────────────────────────────────┘ + │ + ▼ + Get share code +``` + +--- + +## API Endpoints + +### 1. Initialize Upload + +Initialize a presigned upload session and get the upload URL and mode. + +**Request** + +``` +POST /presign/upload/init +Content-Type: application/json +``` + +**Request Body** + +| Field | Type | Required | Default | Description | +| ------------ | ------- | -------- | ------- | --------------------------------------- | +| file_name | string | ✅ | - | File name (with extension) | +| file_size | integer | ✅ | - | File size in bytes | +| expire_value | integer | ❌ | 1 | Expiration time value | +| expire_style | string | ❌ | "day" | Expiration type: day/hour/minute/forever/count | + +**Request Example** + +```json +{ + "file_name": "document.pdf", + "file_size": 1048576, + "expire_value": 7, + "expire_style": "day" +} +``` + +**Response** + +```json +{ + "code": 200, + "detail": { + "upload_id": "a1b2c3d4e5f6...", + "upload_url": "https://bucket.s3.amazonaws.com/path?X-Amz-Signature=...", + "mode": "direct", + "expires_in": 900 + } +} +``` + +**Response Fields** + +| Field | Type | Description | +| ---------- | ------- | --------------------------------------------------------------- | +| upload_id | string | Upload session ID, required by all subsequent operations | +| upload_url | string | Target upload URL | +| mode | string | Upload mode: `direct` (direct to S3) or `proxy` (via server) | +| expires_in | integer | URL validity in seconds, default 900 seconds (15 minutes) | + +In `proxy` mode the response also includes `proxy_upload_url` (and `legacy_proxy_upload_url`); `upload_url` equals the proxy URL. + +**Error Responses** + +| Status | Description | +| ------ | -------------------------------------------------- | +| 400 | Invalid expiration type | +| 403 | File size exceeds limit / IP rate limit exceeded | + +--- + +### 2a. Direct Mode - Upload File to S3 + +When `mode == "direct"`, the client PUTs the file directly to the returned presigned URL. + +**Request** + +``` +PUT {upload_url} +Content-Type: application/octet-stream + +[binary file content] +``` + +**Notes** + +- Use the returned `upload_url` as-is, do not modify it +- `application/octet-stream` is the recommended Content-Type +- The request goes directly to S3, it does not pass through the server + +**JavaScript Example** + +```javascript +const response = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': 'application/octet-stream', + }, +}) + +if (response.ok) { + // Upload succeeded, call the confirm endpoint +} +``` + +--- + +### 2b. Proxy Mode - Upload File to Server + +When `mode == "proxy"`, the client PUTs the file to the server proxy endpoint. + +**Request** + +``` +PUT /presign/upload/proxy/{upload_id} +Content-Type: multipart/form-data + +file: [file] +``` + +**Path Parameters** + +| Parameter | Description | +| --------- | ----------------------------------------------- | +| upload_id | Upload session ID returned by the init endpoint | + +**Response** + +```json +{ + "code": 200, + "detail": { + "code": "123456", + "name": "document.pdf" + } +} +``` + +**Note**: In proxy mode the share code is returned immediately after upload; no confirmation call is needed. + +**Error Responses** + +| Status | Description | +| ------ | ------------------------------------------------------------------ | +| 400 | File size mismatches declared size / session does not support proxy | +| 404 | Upload session does not exist or has expired | +| 500 | Failed to save file | + +--- + +### 3. Confirm Upload (direct mode only) + +In direct mode, call this endpoint after the S3 upload completes to obtain the share code. + +**Request** + +``` +POST /presign/upload/confirm/{upload_id} +Content-Type: application/json +``` + +**Path Parameters** + +| Parameter | Description | +| --------- | ----------------------------------------------- | +| upload_id | Upload session ID returned by the init endpoint | + +**Response** + +```json +{ + "code": 200, + "detail": { + "code": "123456", + "name": "document.pdf" + } +} +``` + +**Error Responses** + +| Status | Description | +| ------ | ------------------------------------------------------------------------------- | +| 400 | Session does not support direct confirmation | +| 404 | Upload session does not exist or has expired / file not uploaded or upload failed | + +--- + +### 4. Query Upload Status + +Query the current state of an upload session. + +**Request** + +``` +GET /presign/upload/status/{upload_id} +``` + +**Response** + +```json +{ + "code": 200, + "detail": { + "upload_id": "a1b2c3d4e5f6...", + "file_name": "document.pdf", + "file_size": 1048576, + "mode": "direct", + "created_at": "2024-01-01T12:00:00", + "expires_at": "2024-01-01T12:15:00", + "is_expired": false + } +} +``` + +**Error Responses** + +| Status | Description | +| ------ | ---------------------------------- | +| 404 | Upload session does not exist | + +--- + +### 5. Cancel Upload + +Cancel an upload session and clean up related resources. + +**Request** + +``` +DELETE /presign/upload/{upload_id} +``` + +**Response** + +```json +{ + "code": 200, + "detail": { + "message": "Upload session cancelled" + } +} +``` + +**Error Responses** + +| Status | Description | +| ------ | ---------------------------------- | +| 404 | Upload session does not exist | + +--- + +## Frontend Integration Example + +### Complete Upload Flow (JavaScript/TypeScript) + +```typescript +interface PresignInitResponse { + upload_id: string + upload_url: string + mode: 'direct' | 'proxy' + expires_in: number +} + +interface UploadResult { + code: string + name: string +} + +async function uploadFile( + file: File, + expireValue: number = 1, + expireStyle: string = 'day' +): Promise { + // 1. Initialize upload + const initResponse = await fetch('/presign/upload/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_name: file.name, + file_size: file.size, + expire_value: expireValue, + expire_style: expireStyle, + }), + }) + + const initData = await initResponse.json() + if (initData.code !== 200) { + throw new Error(initData.detail) + } + + const { upload_id, upload_url, mode } = initData.detail as PresignInitResponse + + // 2. Upload file based on mode + if (mode === 'direct') { + // Direct mode: upload to S3 + const uploadResponse = await fetch(upload_url, { + method: 'PUT', + body: file, + headers: { 'Content-Type': 'application/octet-stream' }, + }) + + if (!uploadResponse.ok) { + throw new Error('S3 upload failed') + } + + // 3. Confirm upload + const confirmResponse = await fetch( + `/presign/upload/confirm/${upload_id}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + } + ) + + const confirmData = await confirmResponse.json() + if (confirmData.code !== 200) { + throw new Error(confirmData.detail) + } + + return confirmData.detail + } else { + // Proxy mode: upload through the server + const formData = new FormData() + formData.append('file', file) + + const uploadResponse = await fetch(upload_url, { + method: 'PUT', + body: formData, + }) + + const uploadData = await uploadResponse.json() + if (uploadData.code !== 200) { + throw new Error(uploadData.detail) + } + + return uploadData.detail + } +} + +// Usage example +const file = document.querySelector('input[type="file"]').files[0] +const result = await uploadFile(file, 7, 'day') +console.log('Share code:', result.code) +``` + +--- + +## Notes + +1. **Session validity**: upload sessions expire after 15 minutes by default; complete the upload within the window +2. **File size limit**: bounded by the `upload_size` system setting +3. **Expiration types**: `day`, `hour`, `minute`, `forever`, `count` +4. **CORS**: in direct mode, S3 must have a proper CORS policy configured +5. **Retry**: implement upload retry logic on the client side for failed uploads diff --git a/docs/en/guide/configuration.md b/docs/en/guide/configuration.md index a0ee1e5d6..47d10073d 100644 --- a/docs/en/guide/configuration.md +++ b/docs/en/guide/configuration.md @@ -22,9 +22,9 @@ On first startup, the system uses default configuration from `core/settings.py`. | `name` | string | `文件快递柜 - FileCodeBox` | Site name, displayed in page title and navigation bar | | `description` | string | `开箱即用的文件快传系统` | Site description, used for SEO | | `keywords` | string | `FileCodeBox, 文件快递柜...` | Site keywords, used for SEO | -| `serverHost` | string | `0.0.0.0` | Service listening address | -| `serverPort` | int | `12345` | Service listening port | -| `serverWorkers` | int | `1` | Worker count; keep one worker for SQLite deployments | +| `server_host` | string | `0.0.0.0` | Service listening address | +| `server_port` | int | `12345` | Service listening port | +| `server_workers` | int | `1` | Worker count; keep one worker for SQLite deployments | ### Notification Settings @@ -33,7 +33,7 @@ On first startup, the system uses default configuration from `core/settings.py`. | `notify_title` | string | `系统通知` | Notification title | | `notify_content` | string | Welcome message | Notification content, supports HTML | | `page_explain` | string | Legal disclaimer | Footer explanation text | -| `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt content | +| `robots_text` | string | `User-agent: *\nDisallow: /` | robots.txt content | ## Upload Settings @@ -41,21 +41,21 @@ On first startup, the system uses default configuration from `core/settings.py`. | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `openUpload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | -| `uploadSize` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | -| `enableChunk` | int | `0` | Enable chunked upload (1=enabled, 0=disabled) | +| `open_upload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | +| `upload_size` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | +| `enable_chunk` | int | `0` | Enable chunked upload (1=enabled, 0=disabled) | | `allowed_file_types` | list | `["*"]` | Allowed extensions; `*` allows every file type | ::: warning Note -`uploadSize` is in bytes. 10MB = 10 * 1024 * 1024 = 10485760 bytes +`upload_size` is in bytes. 10MB = 10 * 1024 * 1024 = 10485760 bytes ::: ### Upload Rate Limiting | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `uploadMinute` | int | `1` | Upload limit time window (minutes) | -| `uploadCount` | int | `10` | Maximum uploads allowed within the time window | +| `upload_minute` | int | `1` | Upload limit time window (minutes) | +| `upload_count` | int | `10` | Maximum uploads allowed within the time window | Example: Default configuration allows up to 10 uploads per minute. @@ -64,7 +64,7 @@ Example: Default configuration allows up to 10 uploads per minute. | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `expireStyle` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | +| `expire_style` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | | `max_save_seconds` | int | `0` | Maximum file retention time (seconds), 0 means no limit | Expiration methods explained: @@ -80,8 +80,8 @@ Expiration methods explained: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `themesSelect` | string | `themes/2024` | Currently active theme | -| `themesChoices` | list | See below | Available themes list | +| `themes_select` | string | `themes/2024` | Currently active theme | +| `themes_choices` | list | See below | Available themes list | Default available themes: ```json @@ -113,8 +113,8 @@ Default available themes: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `admin_token` | string | Set during setup | Admin login password | -| `showAdminAddr` | int | `0` | Show admin panel entry on homepage (1=show, 0=hide) | -| `adminSessionExpire` | int | `2592000` | Admin session lifetime in seconds (30 days) | +| `show_admin_addr` | int | `0` | Show admin panel entry on homepage (1=show, 0=hide) | +| `admin_session_expire` | int | `2592000` | Admin session lifetime in seconds (30 days) | ::: danger Security Warning The setup page is shown automatically while the system is uninitialized. Complete setup before exposing a production service to the public internet. @@ -126,9 +126,9 @@ The setup page is shown automatically while the system is uninitialized. Complet | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `errorMinute` | int | `1` | Error limit time window (minutes) | -| `errorCount` | int | `10` | Maximum errors allowed within the time window | -| `trustedProxies` | list | `[]` | Trusted reverse-proxy IPs used to resolve client addresses | +| `error_minute` | int | `1` | Error limit time window (minutes) | +| `error_count` | int | `10` | Maximum errors allowed within the time window | +| `trusted_proxies` | list | `[]` | Trusted reverse-proxy IPs used to resolve client addresses | This setting prevents brute-force attacks on extraction codes. @@ -140,7 +140,7 @@ This setting prevents brute-force attacks on extraction codes. |---------|------|---------|-------------| | `file_storage` | string | `local` | Storage backend type | | `storage_path` | string | `""` | Custom storage path | -| `storageLimit` | int | `0` | Total storage quota in bytes; 0 means unlimited | +| `storage_limit` | int | `0` | Total storage quota in bytes; 0 means unlimited | Supported storage types: - `local` - Local storage @@ -160,11 +160,11 @@ Suitable for personal or small team use with relaxed limits: ```python { "name": "My File Share", - "uploadSize": 52428800, # 50MB - "uploadMinute": 5, # 5 minutes - "uploadCount": 20, # Max 20 uploads - "expireStyle": ["day", "hour", "forever"], - "showAdminAddr": 1 + "upload_size": 52428800, # 50MB + "upload_minute": 5, # 5 minutes + "upload_count": 20, # Max 20 uploads + "expire_style": ["day", "hour", "forever"], + "show_admin_addr": 1 } ``` @@ -175,14 +175,14 @@ Suitable for public services requiring stricter limits: ```python { "name": "Public File Box", - "uploadSize": 10485760, # 10MB - "uploadMinute": 1, # 1 minute - "uploadCount": 5, # Max 5 uploads - "errorMinute": 5, # 5 minutes - "errorCount": 3, # Max 3 errors - "expireStyle": ["hour", "minute", "count"], + "upload_size": 10485760, # 10MB + "upload_minute": 1, # 1 minute + "upload_count": 5, # Max 5 uploads + "error_minute": 5, # 5 minutes + "error_count": 3, # Max 3 errors + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400, # Max retention 1 day - "showAdminAddr": 0 + "show_admin_addr": 0 } ``` @@ -193,13 +193,13 @@ Suitable for enterprise internal use with large file and chunked upload support: ```python { "name": "Enterprise File Transfer", - "uploadSize": 1073741824, # 1GB - "enableChunk": 1, # Enable chunked upload - "uploadMinute": 10, # 10 minutes - "uploadCount": 100, # Max 100 uploads - "expireStyle": ["day", "forever"], + "upload_size": 1073741824, # 1GB + "enable_chunk": 1, # Enable chunked upload + "upload_minute": 10, # 10 minutes + "upload_count": 100, # Max 100 uploads + "expire_style": ["day", "forever"], "file_storage": "s3", # Use S3 storage - "showAdminAddr": 1 + "show_admin_addr": 1 } ``` diff --git a/docs/en/guide/introduction.md b/docs/en/guide/introduction.md index e2a2779d2..2d0343871 100644 --- a/docs/en/guide/introduction.md +++ b/docs/en/guide/introduction.md @@ -226,7 +226,7 @@ npm run dev ## ❓ FAQ ### Q: How to modify upload size limit? -A: Change `uploadSize` in admin panel +A: Change `upload_size` in admin panel ### Q: How to configure storage engine? A: Select storage engine and configure parameters in admin panel diff --git a/docs/en/guide/management.md b/docs/en/guide/management.md index cbb0e3500..04a85aed0 100644 --- a/docs/en/guide/management.md +++ b/docs/en/guide/management.md @@ -27,10 +27,10 @@ By default, the admin panel entry is not shown on the homepage. You can control | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `showAdminAddr` | int | `0` | Show admin entry on homepage (1=show, 0=hide) | +| `show_admin_addr` | int | `0` | Show admin entry on homepage (1=show, 0=hide) | ::: warning Security Recommendation -For public services, it's recommended to keep `showAdminAddr` at `0` and access the admin panel directly via the `/admin` path to reduce the risk of malicious scanning. +For public services, it's recommended to keep `show_admin_addr` at `0` and access the admin panel directly via the `/admin` path to reduce the risk of malicious scanning. ::: ### Authentication Mechanism @@ -240,15 +240,15 @@ Administrators can modify most configurations through the admin panel: | Category | Example Settings | |----------|------------------| | Basic Settings | `name`, `description`, `keywords`, `notify_title`, `notify_content` | -| Upload Settings | `uploadSize`, `uploadMinute`, `uploadCount`, `openUpload`, `enableChunk` | -| Expiration Settings | `expireStyle`, `max_save_seconds` | -| Theme Settings | `themesSelect`, `opacity`, `background` | -| Security Settings | `admin_token`, `showAdminAddr`, `errorMinute`, `errorCount` | +| Upload Settings | `upload_size`, `upload_minute`, `upload_count`, `open_upload`, `enable_chunk` | +| Expiration Settings | `expire_style`, `max_save_seconds` | +| Theme Settings | `themes_select`, `opacity`, `background` | +| Security Settings | `admin_token`, `show_admin_addr`, `error_minute`, `error_count` | | Storage Settings | `file_storage`, `storage_path` and storage backend-specific configurations | ::: warning Note - `admin_token` (admin password) cannot be set to empty -- `themesChoices` (theme list) cannot be modified through the admin panel +- `themes_choices` (theme list) cannot be modified through the admin panel - After modifying storage settings, existing files will not be automatically migrated ::: @@ -382,7 +382,7 @@ Content-Type: application/json { "admin_token": "new-password", - "uploadSize": 52428800 + "upload_size": 52428800 } ``` diff --git a/docs/en/guide/security.md b/docs/en/guide/security.md index 041af9c36..6bc79bfd6 100644 --- a/docs/en/guide/security.md +++ b/docs/en/guide/security.md @@ -34,14 +34,14 @@ Change the admin password through the admin panel: ### Hide Admin Entry -By default, the admin panel entry is hidden. You can control whether to show the admin entry on the homepage via the `showAdminAddr` configuration: +By default, the admin panel entry is hidden. You can control whether to show the admin entry on the homepage via the `show_admin_addr` configuration: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `showAdminAddr` | int | `0` | Show admin entry (1=show, 0=hide) | +| `show_admin_addr` | int | `0` | Show admin entry (1=show, 0=hide) | ::: tip Recommendation -For public services, it's recommended to keep `showAdminAddr` at `0` and access the admin panel directly via the `/admin` path. +For public services, it's recommended to keep `show_admin_addr` at `0` and access the admin panel directly via the `/admin` path. ::: ## IP Rate Limiting @@ -54,12 +54,12 @@ Limit the number of uploads from a single IP within a specified time: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `uploadMinute` | int | `1` | Upload limit time window (minutes) | -| `uploadCount` | int | `10` | Maximum uploads allowed within the time window | +| `upload_minute` | int | `1` | Upload limit time window (minutes) | +| `upload_count` | int | `10` | Maximum uploads allowed within the time window | **How it works:** - System records upload requests from each IP -- When an IP's upload count reaches `uploadCount` within `uploadMinute` minutes +- When an IP's upload count reaches `upload_count` within `upload_minute` minutes - Subsequent upload requests from that IP will be rejected with HTTP 423 error - Counter resets after the time window expires @@ -68,14 +68,14 @@ Limit the number of uploads from a single IP within a specified time: ```python # Relaxed configuration: Max 20 uploads in 5 minutes { - "uploadMinute": 5, - "uploadCount": 20 + "upload_minute": 5, + "upload_count": 20 } # Strict configuration: Max 3 uploads in 1 minute { - "uploadMinute": 1, - "uploadCount": 3 + "upload_minute": 1, + "upload_count": 3 } ``` @@ -86,13 +86,13 @@ Limit the number of error attempts from a single IP to prevent brute-force attac | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `errorMinute` | int | `1` | Error limit time window (minutes) | -| `errorCount` | int | `10` | Maximum errors allowed within the time window | +| `error_minute` | int | `1` | Error limit time window (minutes) | +| `error_count` | int | `10` | Maximum errors allowed within the time window | **How it works:** - When a user enters an incorrect extraction code, the system records the error count for that IP -- When error count reaches `errorCount`, that IP will be temporarily locked -- Lock duration is `errorMinute` minutes +- When error count reaches `error_count`, that IP will be temporarily locked +- Lock duration is `error_minute` minutes - During lockout, all extraction requests from that IP will be rejected **Configuration example:** @@ -100,8 +100,8 @@ Limit the number of error attempts from a single IP to prevent brute-force attac ```python # Anti-brute-force configuration: Max 3 errors in 5 minutes { - "errorMinute": 5, - "errorCount": 3 + "error_minute": 5, + "error_count": 3 } ``` @@ -115,8 +115,8 @@ The default allows up to 10 errors per IP each minute. Public services can tight | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `uploadSize` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | -| `openUpload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | +| `upload_size` | int | `10485760` | Maximum single file upload size (bytes), default 10MB | +| `open_upload` | int | `1` | Enable upload functionality (1=enabled, 0=disabled) | **Common size conversions:** - 10MB = 10 * 1024 * 1024 = `10485760` @@ -130,7 +130,7 @@ Through file expiration mechanisms, you can automatically clean up expired files | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `expireStyle` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | +| `expire_style` | list | `["day","hour","minute","forever","count"]` | Available expiration methods | | `max_save_seconds` | int | `0` | Maximum file retention time (seconds), 0 means no limit | **Expiration methods explained:** @@ -150,7 +150,7 @@ For public services, it's recommended to: ```python # Recommended configuration for public services { - "expireStyle": ["hour", "minute", "count"], + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400 # Max retention 1 day } ``` @@ -161,7 +161,7 @@ In some cases, you may need to temporarily disable upload functionality: ```python { - "openUpload": 0 # Disable upload functionality + "open_upload": 0 # Disable upload functionality } ``` @@ -197,7 +197,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - # Limit request body size (match uploadSize configuration) + # Limit request body size (match upload_size configuration) client_max_body_size 100M; # Pass real IP @@ -236,7 +236,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; **2. Request Body Size Limit** -Nginx's `client_max_body_size` should match or be slightly larger than FileCodeBox's `uploadSize` configuration: +Nginx's `client_max_body_size` should match or be slightly larger than FileCodeBox's `upload_size` configuration: ```nginx client_max_body_size 100M; # Allow max 100MB uploads @@ -269,7 +269,7 @@ your-domain.com { Before deploying FileCodeBox, confirm the following security configurations: - [ ] Completed first-run setup and set the admin password `admin_token` -- [ ] Hidden admin entry `showAdminAddr: 0` +- [ ] Hidden admin entry `show_admin_addr: 0` - [ ] Configured appropriate upload rate limiting - [ ] Configured error rate limiting to prevent brute-force attacks - [ ] Set reasonable file size limits @@ -285,15 +285,15 @@ Before deploying FileCodeBox, confirm the following security configurations: ```python { "admin_token": "your-very-secure-password", - "showAdminAddr": 0, - "uploadSize": 10485760, # 10MB - "uploadMinute": 1, - "uploadCount": 5, - "errorMinute": 5, - "errorCount": 3, - "expireStyle": ["hour", "minute", "count"], + "show_admin_addr": 0, + "upload_size": 10485760, # 10MB + "upload_minute": 1, + "upload_count": 5, + "error_minute": 5, + "error_count": 3, + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400, # Max 1 day - "openUpload": 1 + "open_upload": 1 } ``` @@ -302,15 +302,15 @@ Before deploying FileCodeBox, confirm the following security configurations: ```python { "admin_token": "internal-secure-password", - "showAdminAddr": 1, - "uploadSize": 104857600, # 100MB - "uploadMinute": 5, - "uploadCount": 50, - "errorMinute": 1, - "errorCount": 5, - "expireStyle": ["day", "hour", "forever"], + "show_admin_addr": 1, + "upload_size": 104857600, # 100MB + "upload_minute": 5, + "upload_count": 50, + "error_minute": 1, + "error_count": 5, + "expire_style": ["day", "hour", "forever"], "max_save_seconds": 0, # No limit - "openUpload": 1 + "open_upload": 1 } ``` diff --git a/docs/en/guide/share.md b/docs/en/guide/share.md index 25b92fadf..6e6963e69 100644 --- a/docs/en/guide/share.md +++ b/docs/en/guide/share.md @@ -66,10 +66,10 @@ Response example: ### File Size Limit -The default maximum single file upload size is **10MB**. Administrators can modify this limit via the `uploadSize` configuration. +The default maximum single file upload size is **10MB**. Administrators can modify this limit via the `upload_size` configuration. ::: tip Tip -If you need to upload large files, contact the administrator to enable chunked upload functionality or adjust the `uploadSize` configuration. +If you need to upload large files, contact the administrator to enable chunked upload functionality or adjust the `upload_size` configuration. ::: ### Supported Upload Methods @@ -116,7 +116,7 @@ FileCodeBox supports multiple flexible expiration methods: | By Count | `count` | File expires after specified download count | ::: info Note -- Administrators can control available expiration methods via the `expireStyle` configuration +- Administrators can control available expiration methods via the `expire_style` configuration - Administrators can limit maximum file retention time via the `max_save_seconds` configuration ::: @@ -208,7 +208,7 @@ This endpoint returns file content directly, suitable for direct browser access. ## Chunked Upload (Large Files) -For large file uploads, FileCodeBox supports chunked upload functionality. This feature requires administrator enablement (`enableChunk=1`). +For large file uploads, FileCodeBox supports chunked upload functionality. This feature requires administrator enablement (`enable_chunk=1`). ### Chunked Upload Flow diff --git a/docs/en/guide/upload.md b/docs/en/guide/upload.md index 9dc915263..5d25b1f61 100644 --- a/docs/en/guide/upload.md +++ b/docs/en/guide/upload.md @@ -51,7 +51,7 @@ Paste upload only supports image formats, not other file types. Specific support | Setting | Default | Description | |---------|---------|-------------| -| `uploadSize` | 10MB | Maximum single file upload size | +| `upload_size` | 10MB | Maximum single file upload size | ### Modify Upload Limits @@ -59,11 +59,11 @@ Administrators can modify upload size limits through the admin panel or configur ```python # Set maximum upload size to 100MB -uploadSize = 104857600 # 100 * 1024 * 1024 +upload_size = 104857600 # 100 * 1024 * 1024 ``` ::: info Note -`uploadSize` is in bytes. Common conversions: +`upload_size` is in bytes. Common conversions: - 10MB = 10485760 - 50MB = 52428800 - 100MB = 104857600 @@ -147,7 +147,7 @@ curl -L "http://localhost:12345/share/select/?code=YOUR_CODE" -o downloaded_file ``` ::: tip When Authentication Required -If guest upload is disabled in admin panel (`openUpload=0`), you need to login first: +If guest upload is disabled in admin panel (`open_upload=0`), you need to login first: ```bash # 1. Login to get token @@ -175,7 +175,7 @@ curl -X POST "http://localhost:12345/share/text/" \ For large files, FileCodeBox supports chunked upload functionality. Chunked upload splits large files into multiple small chunks for separate uploading, supporting resume capability. ::: warning Prerequisite -Chunked upload functionality requires administrator enablement: `enableChunk=1` +Chunked upload functionality requires administrator enablement: `enable_chunk=1` ::: ### Chunked Upload Flow @@ -363,7 +363,7 @@ await fetch(`/chunk/upload/complete/${upload_id}`, { | HTTP Status | Error Message | Cause | Solution | |-------------|---------------|-------|----------| -| 403 | Size exceeds limit | File exceeds `uploadSize` limit | Reduce file size or contact administrator to adjust limit | +| 403 | Size exceeds limit | File exceeds `upload_size` limit | Reduce file size or contact administrator to adjust limit | | 403 | Upload rate limit | Exceeded IP upload rate limit | Wait for limit time window before retrying | | 400 | Invalid expiration type | `expire_style` value not in allowed list | Use a valid expiration method | | 404 | Upload session not found | `upload_id` invalid or expired | Re-initialize upload | @@ -376,8 +376,8 @@ The system has rate limits on upload operations to prevent abuse: | Setting | Default | Description | |---------|---------|-------------| -| `uploadMinute` | 1 | Limit time window (minutes) | -| `uploadCount` | 10 | Maximum uploads within time window | +| `upload_minute` | 1 | Limit time window (minutes) | +| `upload_count` | 10 | Maximum uploads within time window | When rate limit is exceeded, you need to wait for the time window to pass before continuing uploads. @@ -395,26 +395,26 @@ When rate limit is exceeded, you need to wait for the time window to pass before | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `openUpload` | int | 1 | Enable upload (1=enabled, 0=disabled) | -| `uploadSize` | int | 10485760 | Maximum upload size (bytes) | -| `enableChunk` | int | 0 | Enable chunked upload (1=enabled, 0=disabled) | -| `uploadMinute` | int | 1 | Upload rate limit time window (minutes) | -| `uploadCount` | int | 10 | Maximum uploads within time window | -| `expireStyle` | list | ["day","hour","minute","forever","count"] | Allowed expiration methods | +| `open_upload` | int | 1 | Enable upload (1=enabled, 0=disabled) | +| `upload_size` | int | 10485760 | Maximum upload size (bytes) | +| `enable_chunk` | int | 0 | Enable chunked upload (1=enabled, 0=disabled) | +| `upload_minute` | int | 1 | Upload rate limit time window (minutes) | +| `upload_count` | int | 10 | Maximum uploads within time window | +| `expire_style` | list | ["day","hour","minute","forever","count"] | Allowed expiration methods | ### Configuration Example ```python # Allow 100MB file uploads, enable chunked upload -uploadSize = 104857600 -enableChunk = 1 +upload_size = 104857600 +enable_chunk = 1 # Relax upload rate limit: max 50 uploads per 5 minutes -uploadMinute = 5 -uploadCount = 50 +upload_minute = 5 +upload_count = 50 # Only allow expiration by days and count -expireStyle = ["day", "count"] +expire_style = ["day", "count"] ``` ## Next Steps diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d93fb6e2e..8c4c2846d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -22,9 +22,9 @@ FileCodeBox 支持两种配置方式: | `name` | string | `文件快递柜 - FileCodeBox` | 站点名称,显示在页面标题和导航栏 | | `description` | string | `开箱即用的文件快传系统` | 站点描述,用于 SEO | | `keywords` | string | `FileCodeBox, 文件快递柜...` | 站点关键词,用于 SEO | -| `serverHost` | string | `0.0.0.0` | 服务监听地址 | -| `serverPort` | int | `12345` | 服务监听端口 | -| `serverWorkers` | int | `1` | 工作进程数;SQLite 部署建议保持单进程 | +| `server_host` | string | `0.0.0.0` | 服务监听地址 | +| `server_port` | int | `12345` | 服务监听端口 | +| `server_workers` | int | `1` | 工作进程数;SQLite 部署建议保持单进程 | ### 通知设置 @@ -33,7 +33,7 @@ FileCodeBox 支持两种配置方式: | `notify_title` | string | `系统通知` | 通知标题 | | `notify_content` | string | 欢迎信息 | 通知内容,支持 HTML | | `page_explain` | string | 法律声明 | 页面底部说明文字 | -| `robotsText` | string | `User-agent: *\nDisallow: /` | robots.txt 内容 | +| `robots_text` | string | `User-agent: *\nDisallow: /` | robots.txt 内容 | ## 上传设置 @@ -41,21 +41,21 @@ FileCodeBox 支持两种配置方式: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `openUpload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | -| `uploadSize` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | -| `enableChunk` | int | `0` | 是否启用分片上传(1=启用,0=禁用) | +| `open_upload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | +| `upload_size` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | +| `enable_chunk` | int | `0` | 是否启用分片上传(1=启用,0=禁用) | | `allowed_file_types` | list | `["*"]` | 允许上传的扩展名;`*` 表示不限制 | ::: warning 注意 -`uploadSize` 的单位是字节。10MB = 10 * 1024 * 1024 = 10485760 字节 +`upload_size` 的单位是字节。10MB = 10 * 1024 * 1024 = 10485760 字节 ::: ### 上传频率限制 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `uploadMinute` | int | `1` | 上传限制的时间窗口(分钟) | -| `uploadCount` | int | `10` | 在时间窗口内允许的最大上传次数 | +| `upload_minute` | int | `1` | 上传限制的时间窗口(分钟) | +| `upload_count` | int | `10` | 在时间窗口内允许的最大上传次数 | 例如:默认配置表示每 1 分钟内最多允许上传 10 次。 @@ -63,7 +63,7 @@ FileCodeBox 支持两种配置方式: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `expireStyle` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | +| `expire_style` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | | `max_save_seconds` | int | `0` | 文件最大保存时间(秒),0 表示不限制 | 过期方式说明: @@ -79,8 +79,8 @@ FileCodeBox 支持两种配置方式: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `themesSelect` | string | `themes/2024` | 当前使用的主题 | -| `themesChoices` | list | 见下方 | 可用主题列表 | +| `themes_select` | string | `themes/2024` | 当前使用的主题 | +| `themes_choices` | list | 见下方 | 可用主题列表 | 默认可用主题: ```json @@ -112,8 +112,8 @@ FileCodeBox 支持两种配置方式: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `admin_token` | string | 初始化页面设置 | 管理员登录密码(哈希存储) | -| `showAdminAddr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | -| `adminSessionExpire` | int | `2592000` | 管理会话有效期(秒),默认 30 天 | +| `show_admin_addr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | +| `admin_session_expire` | int | `2592000` | 管理会话有效期(秒),默认 30 天 | ::: danger 安全警告 未初始化时会自动显示初始化页面。生产环境请在服务对外开放前完成初始化。 @@ -125,9 +125,9 @@ FileCodeBox 支持两种配置方式: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `errorMinute` | int | `1` | 错误限制的时间窗口(分钟) | -| `errorCount` | int | `10` | 在时间窗口内允许的最大错误次数 | -| `trustedProxies` | list | `[]` | 可信反向代理 IP;用于安全解析客户端地址 | +| `error_minute` | int | `1` | 错误限制的时间窗口(分钟) | +| `error_count` | int | `10` | 在时间窗口内允许的最大错误次数 | +| `trusted_proxies` | list | `[]` | 可信反向代理 IP;用于安全解析客户端地址 | 此设置用于防止暴力破解提取码。 @@ -139,7 +139,7 @@ FileCodeBox 支持两种配置方式: |--------|------|--------|------| | `file_storage` | string | `local` | 存储后端类型 | | `storage_path` | string | `""` | 自定义存储路径 | -| `storageLimit` | int | `0` | 总存储配额(字节),0 表示不限制 | +| `storage_limit` | int | `0` | 总存储配额(字节),0 表示不限制 | 支持的存储类型: - `local` - 本地存储 @@ -159,11 +159,11 @@ FileCodeBox 支持两种配置方式: ```python { "name": "我的文件分享", - "uploadSize": 52428800, # 50MB - "uploadMinute": 5, # 5分钟 - "uploadCount": 20, # 最多20次 - "expireStyle": ["day", "hour", "forever"], - "showAdminAddr": 1 + "upload_size": 52428800, # 50MB + "upload_minute": 5, # 5分钟 + "upload_count": 20, # 最多20次 + "expire_style": ["day", "hour", "forever"], + "show_admin_addr": 1 } ``` @@ -174,14 +174,14 @@ FileCodeBox 支持两种配置方式: ```python { "name": "公共文件快递柜", - "uploadSize": 10485760, # 10MB - "uploadMinute": 1, # 1分钟 - "uploadCount": 5, # 最多5次 - "errorMinute": 5, # 5分钟 - "errorCount": 3, # 最多3次错误 - "expireStyle": ["hour", "minute", "count"], + "upload_size": 10485760, # 10MB + "upload_minute": 1, # 1分钟 + "upload_count": 5, # 最多5次 + "error_minute": 5, # 5分钟 + "error_count": 3, # 最多3次错误 + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400, # 最长保存1天 - "showAdminAddr": 0 + "show_admin_addr": 0 } ``` @@ -192,13 +192,13 @@ FileCodeBox 支持两种配置方式: ```python { "name": "企业文件中转站", - "uploadSize": 1073741824, # 1GB - "enableChunk": 1, # 启用分片上传 - "uploadMinute": 10, # 10分钟 - "uploadCount": 100, # 最多100次 - "expireStyle": ["day", "forever"], + "upload_size": 1073741824, # 1GB + "enable_chunk": 1, # 启用分片上传 + "upload_minute": 10, # 10分钟 + "upload_count": 100, # 最多100次 + "expire_style": ["day", "forever"], "file_storage": "s3", # 使用S3存储 - "showAdminAddr": 1 + "show_admin_addr": 1 } ``` diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index cda9cf92f..42dce74ef 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -240,7 +240,7 @@ npm run dev ## ❓ 常见问题 ### Q: 如何修改上传大小限制? -A: 在管理面板中修改配置项 `uploadSize` +A: 在管理面板中修改配置项 `upload_size` ### Q: 如何配置存储引擎? A: 在管理面板中选择存储引擎并配置相应参数 diff --git a/docs/guide/management.md b/docs/guide/management.md index d9a912461..7cdb1e265 100644 --- a/docs/guide/management.md +++ b/docs/guide/management.md @@ -27,10 +27,10 @@ FileCodeBox 提供了功能完善的管理面板,让管理员可以方便地 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `showAdminAddr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | +| `show_admin_addr` | int | `0` | 是否在首页显示管理入口(1=显示,0=隐藏) | ::: warning 安全建议 -在公开服务中,建议保持 `showAdminAddr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板,减少被恶意扫描的风险。 +在公开服务中,建议保持 `show_admin_addr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板,减少被恶意扫描的风险。 ::: ### 认证机制 @@ -239,15 +239,15 @@ FileCodeBox 提供了功能完善的管理面板,让管理员可以方便地 | 类别 | 配置项示例 | |------|------------| | 基础设置 | `name`, `description`, `keywords`, `notify_title`, `notify_content` | -| 上传设置 | `uploadSize`, `uploadMinute`, `uploadCount`, `openUpload`, `enableChunk` | -| 过期设置 | `expireStyle`, `max_save_seconds` | -| 主题设置 | `themesSelect`, `opacity`, `background` | -| 安全设置 | `admin_token`, `showAdminAddr`, `errorMinute`, `errorCount` | +| 上传设置 | `upload_size`, `upload_minute`, `upload_count`, `open_upload`, `enable_chunk` | +| 过期设置 | `expire_style`, `max_save_seconds` | +| 主题设置 | `themes_select`, `opacity`, `background` | +| 安全设置 | `admin_token`, `show_admin_addr`, `error_minute`, `error_count` | | 存储设置 | `file_storage`, `storage_path` 及各存储后端的配置 | ::: warning 注意 - `admin_token`(管理员密码)不能设置为空 -- `themesChoices`(主题列表)不可通过管理面板修改 +- `themes_choices`(主题列表)不可通过管理面板修改 - 修改存储设置后,已有文件不会自动迁移 ::: @@ -381,7 +381,7 @@ Content-Type: application/json { "admin_token": "new-password", - "uploadSize": 52428800 + "upload_size": 52428800 } ``` diff --git a/docs/guide/security.md b/docs/guide/security.md index cb8387f92..f37a971bc 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -34,14 +34,14 @@ FileCodeBox 首次启动时不会生成默认管理员密码。请在浏览器 ### 隐藏管理入口 -默认情况下,管理面板入口是隐藏的。您可以通过 `showAdminAddr` 配置控制是否在首页显示管理入口: +默认情况下,管理面板入口是隐藏的。您可以通过 `show_admin_addr` 配置控制是否在首页显示管理入口: | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `showAdminAddr` | int | `0` | 是否显示管理入口(1=显示,0=隐藏) | +| `show_admin_addr` | int | `0` | 是否显示管理入口(1=显示,0=隐藏) | ::: tip 建议 -在公开服务中,建议保持 `showAdminAddr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板。 +在公开服务中,建议保持 `show_admin_addr` 为 `0`,通过直接访问 `/admin` 路径进入管理面板。 ::: ## IP 速率限制 @@ -54,12 +54,12 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `uploadMinute` | int | `1` | 上传限制的时间窗口(分钟) | -| `uploadCount` | int | `10` | 在时间窗口内允许的最大上传次数 | +| `upload_minute` | int | `1` | 上传限制的时间窗口(分钟) | +| `upload_count` | int | `10` | 在时间窗口内允许的最大上传次数 | **工作原理:** - 系统记录每个 IP 的上传请求 -- 当某 IP 在 `uploadMinute` 分钟内的上传次数达到 `uploadCount` 时 +- 当某 IP 在 `upload_minute` 分钟内的上传次数达到 `upload_count` 时 - 该 IP 的后续上传请求将被拒绝,返回 HTTP 423 错误 - 等待时间窗口过期后,计数器重置 @@ -68,14 +68,14 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 ```python # 宽松配置:5分钟内最多上传20次 { - "uploadMinute": 5, - "uploadCount": 20 + "upload_minute": 5, + "upload_count": 20 } # 严格配置:1分钟内最多上传3次 { - "uploadMinute": 1, - "uploadCount": 3 + "upload_minute": 1, + "upload_count": 3 } ``` @@ -85,13 +85,13 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `errorMinute` | int | `1` | 错误限制的时间窗口(分钟) | -| `errorCount` | int | `10` | 在时间窗口内允许的最大错误次数 | +| `error_minute` | int | `1` | 错误限制的时间窗口(分钟) | +| `error_count` | int | `10` | 在时间窗口内允许的最大错误次数 | **工作原理:** - 当用户输入错误的提取码时,系统记录该 IP 的错误次数 -- 当错误次数达到 `errorCount` 时,该 IP 将被暂时锁定 -- 锁定时间为 `errorMinute` 分钟 +- 当错误次数达到 `error_count` 时,该 IP 将被暂时锁定 +- 锁定时间为 `error_minute` 分钟 - 锁定期间,该 IP 的所有提取请求都将被拒绝 **配置示例:** @@ -99,8 +99,8 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 ```python # 防暴力破解配置:5分钟内最多允许3次错误 { - "errorMinute": 5, - "errorCount": 3 + "error_minute": 5, + "error_count": 3 } ``` @@ -114,8 +114,8 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `uploadSize` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | -| `openUpload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | +| `upload_size` | int | `10485760` | 单文件最大上传大小(字节),默认 10MB | +| `open_upload` | int | `1` | 是否开启上传功能(1=开启,0=关闭) | **常用大小换算:** - 10MB = 10 * 1024 * 1024 = `10485760` @@ -129,7 +129,7 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `expireStyle` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | +| `expire_style` | list | `["day","hour","minute","forever","count"]` | 可选的过期方式 | | `max_save_seconds` | int | `0` | 文件最大保存时间(秒),0 表示不限制 | **过期方式说明:** @@ -149,7 +149,7 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 ```python # 公开服务推荐配置 { - "expireStyle": ["hour", "minute", "count"], + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400 # 最长保存1天 } ``` @@ -160,7 +160,7 @@ FileCodeBox 内置了基于 IP 的速率限制机制,可以有效防止滥用 ```python { - "openUpload": 0 # 关闭上传功能 + "open_upload": 0 # 关闭上传功能 } ``` @@ -196,7 +196,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - # 限制请求体大小(与 uploadSize 配置一致) + # 限制请求体大小(与 upload_size 配置一致) client_max_body_size 100M; # 传递真实 IP @@ -235,7 +235,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; **2. 请求体大小限制** -Nginx 的 `client_max_body_size` 应该与 FileCodeBox 的 `uploadSize` 配置一致或略大: +Nginx 的 `client_max_body_size` 应该与 FileCodeBox 的 `upload_size` 配置一致或略大: ```nginx client_max_body_size 100M; # 允许上传最大 100MB @@ -268,7 +268,7 @@ your-domain.com { 部署 FileCodeBox 前,请确认以下安全配置: - [ ] 已完成首次初始化并设置管理员密码 `admin_token` -- [ ] 已隐藏管理入口 `showAdminAddr: 0` +- [ ] 已隐藏管理入口 `show_admin_addr: 0` - [ ] 已配置合适的上传频率限制 - [ ] 已配置错误次数限制防止暴力破解 - [ ] 已设置合理的文件大小限制 @@ -284,15 +284,15 @@ your-domain.com { ```python { "admin_token": "your-very-secure-password", - "showAdminAddr": 0, - "uploadSize": 10485760, # 10MB - "uploadMinute": 1, - "uploadCount": 5, - "errorMinute": 5, - "errorCount": 3, - "expireStyle": ["hour", "minute", "count"], + "show_admin_addr": 0, + "upload_size": 10485760, # 10MB + "upload_minute": 1, + "upload_count": 5, + "error_minute": 5, + "error_count": 3, + "expire_style": ["hour", "minute", "count"], "max_save_seconds": 86400, # 最长1天 - "openUpload": 1 + "open_upload": 1 } ``` @@ -301,15 +301,15 @@ your-domain.com { ```python { "admin_token": "internal-secure-password", - "showAdminAddr": 1, - "uploadSize": 104857600, # 100MB - "uploadMinute": 5, - "uploadCount": 50, - "errorMinute": 1, - "errorCount": 5, - "expireStyle": ["day", "hour", "forever"], + "show_admin_addr": 1, + "upload_size": 104857600, # 100MB + "upload_minute": 5, + "upload_count": 50, + "error_minute": 1, + "error_count": 5, + "expire_style": ["day", "hour", "forever"], "max_save_seconds": 0, # 不限制 - "openUpload": 1 + "open_upload": 1 } ``` diff --git a/docs/guide/share.md b/docs/guide/share.md index 5baa70dbe..bae28f91c 100644 --- a/docs/guide/share.md +++ b/docs/guide/share.md @@ -66,10 +66,10 @@ FileCodeBox 支持两种分享方式: ### 文件大小限制 -默认单文件最大上传大小为 **10MB**。管理员可以通过 `uploadSize` 配置项修改此限制。 +默认单文件最大上传大小为 **10MB**。管理员可以通过 `upload_size` 配置项修改此限制。 ::: tip 提示 -如果需要上传大文件,请联系管理员启用分片上传功能,或调整 `uploadSize` 配置。 +如果需要上传大文件,请联系管理员启用分片上传功能,或调整 `upload_size` 配置。 ::: ### 支持的上传方式 @@ -115,7 +115,7 @@ FileCodeBox 支持多种灵活的过期方式: | 按次数过期 | `count` | 文件在被下载指定次数后过期 | ::: info 说明 -- 管理员可以通过 `expireStyle` 配置项控制用户可选的过期方式 +- 管理员可以通过 `expire_style` 配置项控制用户可选的过期方式 - 管理员可以通过 `max_save_seconds` 配置项限制文件的最长保存时间 ::: @@ -207,7 +207,7 @@ expire_value=5, expire_style=count ## 分片上传(大文件) -对于大文件上传,FileCodeBox 支持分片上传功能。此功能需要管理员启用(`enableChunk=1`)。 +对于大文件上传,FileCodeBox 支持分片上传功能。此功能需要管理员启用(`enable_chunk=1`)。 ### 分片上传流程 diff --git a/docs/guide/storage-onedrive.md b/docs/guide/storage-onedrive.md deleted file mode 100644 index 796a3c0c9..000000000 --- a/docs/guide/storage-onedrive.md +++ /dev/null @@ -1,135 +0,0 @@ -# OneDrive作为存储的配置方法 - -**仅支持工作或学校账户,并且需要有管理员权限以授权API** - -## 1. 需要配置的参数 - -``` -file_storage=onedrive -onedrive_domain=XXXXXX -onedrive_client_id=XXXXXX-XXXXXX-XXXXXX-XXXXXX -onedrive_username=XXXXXX@XXXXXX -onedrive_password=XXXXXX -``` - -`onedrive_username`和`onedrive_password`是你的账户名(邮箱)和密码,另外两个参数需要在[微软Azure门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)中注册应用后获取。 - -## 2. 应用注册 - -1. 登录[https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade),鼠标置于右上角账号处,浮窗将显示的`域`即为`onedrive_domain`的值。 -![onedrive_domain](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGCiErO85doq9Tcu/root/content) - -2. 点击左上角的`+新注册`,输入名称, - * 受支持的帐户类型:选择任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox) - * 重定向 URI (可选):选择`Web`,并输入`http://localhost` - -3. 完成注册后进入概述页面,在概要中找到`应用程序(客户端)ID`,即为`onedrive_client_id`的值。 -![onedrive_client_id](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGHD4CNyJxm_QBb8/root/content) - -4. 此时还需要配置允许公共客户端流和API权限 - * 在左侧选择`身份验证`,找到`允许的客户端流`,选择`是`,并**点击`保存`**。 - ![允许的客户端流](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGJQMOlOCb2-L0Lh/root/content) - * 在左侧选择`API权限`,点击`+添加权限`,选择`Microsoft Graph`->`委托的权限`,并勾选下述权限:openid、Files中所有权限、User.Read,如下图所示。最后**点击下方的`添加权限`**。 - ![添加权限](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGOZzz7sIrdXkD4w/root/content) - * 最后点击`授予管理员同意`,并**点击`是`**,最终状态变为`已授予`。 - ![授予管理员同意](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGSOAnjnHUlbirbU/root/content) - -## 3. 使用下述代码测试是否配置成功 - -安装依赖:`pip install Office365-REST-Python-Client` - -```python -# common.py -import msal -domain = 'XXXXXX' -client_id = 'XXXXXX' -username = 'XXXXXX' -password = 'XXXXXX' - -def acquire_token_pwd(): - authority_url = f'https://login.microsoftonline.com/{domain}' - app = msal.PublicClientApplication( - authority=authority_url, - client_id=client_id - ) - result = app.acquire_token_by_username_password( - username=username, - password=password, - scopes=['https://graph.microsoft.com/.default'] - ) - return result -``` - -测试登录,如果成功打印出账户名,说明配置成功。 - -```python -from common import acquire_token_pwd - -from office365.graph_client import GraphClient -try: - client = GraphClient(acquire_token_pwd) - me = client.me.get().execute_query() - print(me.user_principal_name) -except Exception as e: - print(e) -``` - -测试文件上传 - -```python -import os -from office365.graph_client import GraphClient -from common import acquire_token_pwd - -remote_path = 'tmp' -local_path = '.tmp/1689843925000.png' - -def convert_link_to_download_link(link): - import re - 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}' - -client = GraphClient(acquire_token_pwd) -folder = client.me.drive.root.get_by_path(remote_path) -# 1. upload -file = folder.upload_file(local_path).execute_query() -print(f'File {file.web_url} has been uploaded') -# 2. create sharing link -remote_file = folder.get_by_path(os.path.basename(local_path)) -permission = remote_file.create_link("view", "anonymous").execute_query() -print(f"sharing link: {convert_link_to_download_link(permission.link.webUrl)}") -``` - -测试文件下载 - -```python -import os -from office365.graph_client import GraphClient -from common import acquire_token_pwd - -remote_path = 'tmp/1689843925000.png' -local_path = '.tmp' -if not os.path.exists(local_path): - os.makedirs(local_path) - -client = GraphClient(acquire_token_pwd) -remote_file = client.me.drive.root.get_by_path(remote_path).get().execute_query() -with open(os.path.join(local_path, os.path.basename(remote_path)), 'wb') as local_file: - remote_file.download(local_file).execute_query() - print(f'{remote_file.name} has been downloaded into {local_file.name}') -``` - -测试删除文件 - -```python -from office365.graph_client import GraphClient -from common import acquire_token_pwd - -remote_path = 'tmp/1689843925000.png' - -client = GraphClient(acquire_token_pwd) -file = client.me.drive.root.get_by_path(remote_path) -file.delete_object().execute_query() -``` diff --git a/docs/guide/storage-opendal.md b/docs/guide/storage-opendal.md deleted file mode 100644 index c4d383f96..000000000 --- a/docs/guide/storage-opendal.md +++ /dev/null @@ -1,30 +0,0 @@ -# 通过 OpenDAL 集成存储的配置方法 - -## 需要配置的参数 - -```dotenv -file_storage=opendal -opendal_scheme= -opendal__=... -``` - -以 Gcs 为例,需要配置的参数如下: -```dotenv -file_storage=opendal -opendal_scheme=gcs -opendal_gcs_root= -opendal_gcs_bucket= -opendal_gcs_credential= -``` - -所有支持的服务可以在[此处](https://opendal.apache.org/docs/rust/opendal/services/index.html)查看。 -具体服务的配置参数与 OpenDAL 文档一致。 - -## 补充说明 - -通过 OpenDAL 集成的服务均通过服务器中转下载。因此,每次下载既消耗存储服务的流量,也消耗服务器的流量。 - -OpenDAL 和该项目本身都支持本地存储、`s3`、`onedrive`。不同之处有以下几点: -1. 项目的支持通过预签名实现,不消耗服务器流量。而 OpenDAL 通过服务器中转下载,消耗服务器流量。(本地存储除外) -2. 项目的支持对于异常情况可能会有更多的调试信息,方便排查问题。 -3. OpenDAL 项目本身采用 Rust 编写,性能更好。 \ No newline at end of file diff --git a/docs/guide/upload.md b/docs/guide/upload.md index 9a5ebfaf7..d776d685c 100644 --- a/docs/guide/upload.md +++ b/docs/guide/upload.md @@ -51,7 +51,7 @@ FileCodeBox 支持以下几种上传方式: | 配置项 | 默认值 | 说明 | |--------|--------|------| -| `uploadSize` | 10MB | 单文件最大上传大小 | +| `upload_size` | 10MB | 单文件最大上传大小 | ### 修改上传限制 @@ -59,11 +59,11 @@ FileCodeBox 支持以下几种上传方式: ```python # 设置最大上传大小为 100MB -uploadSize = 104857600 # 100 * 1024 * 1024 +upload_size = 104857600 # 100 * 1024 * 1024 ``` ::: info 说明 -`uploadSize` 的单位是字节。常用换算: +`upload_size` 的单位是字节。常用换算: - 10MB = 10485760 - 50MB = 52428800 - 100MB = 104857600 @@ -147,7 +147,7 @@ curl -L "http://localhost:12345/share/select/?code=取件码" -o downloaded_file ``` ::: tip 需要认证时 -如果管理面板关闭了游客上传(`openUpload=0`),需要先登录获取 token: +如果管理面板关闭了游客上传(`open_upload=0`),需要先登录获取 token: ```bash # 1. 登录获取 token @@ -174,7 +174,7 @@ curl -X POST "http://localhost:12345/share/text/" \ 对于大文件,FileCodeBox 支持分片上传功能。分片上传将大文件分割成多个小块分别上传,支持断点续传。 ::: warning 前提条件 -分片上传功能需要管理员启用:`enableChunk=1` +分片上传功能需要管理员启用:`enable_chunk=1` ::: ### 分片上传流程 @@ -362,7 +362,7 @@ await fetch(`/chunk/upload/complete/${upload_id}`, { | HTTP 状态码 | 错误信息 | 原因 | 解决方案 | |-------------|----------|------|----------| -| 403 | 大小超过限制 | 文件超过 `uploadSize` 限制 | 减小文件大小或联系管理员调整限制 | +| 403 | 大小超过限制 | 文件超过 `upload_size` 限制 | 减小文件大小或联系管理员调整限制 | | 403 | 上传频率限制 | 超过 IP 上传频率限制 | 等待限制时间窗口后重试 | | 400 | 过期时间类型错误 | `expire_style` 值不在允许列表中 | 使用有效的过期方式 | | 404 | 上传会话不存在 | `upload_id` 无效或已过期 | 重新初始化上传 | @@ -375,8 +375,8 @@ await fetch(`/chunk/upload/complete/${upload_id}`, { | 配置项 | 默认值 | 说明 | |--------|--------|------| -| `uploadMinute` | 1 | 限制时间窗口(分钟) | -| `uploadCount` | 10 | 时间窗口内最大上传次数 | +| `upload_minute` | 1 | 限制时间窗口(分钟) | +| `upload_count` | 10 | 时间窗口内最大上传次数 | 当超过频率限制时,需要等待时间窗口过后才能继续上传。 @@ -394,26 +394,26 @@ await fetch(`/chunk/upload/complete/${upload_id}`, { | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| -| `openUpload` | int | 1 | 是否开放上传(1=开放,0=关闭) | -| `uploadSize` | int | 10485760 | 最大上传大小(字节) | -| `enableChunk` | int | 0 | 是否启用分片上传(1=启用,0=禁用) | -| `uploadMinute` | int | 1 | 上传频率限制时间窗口(分钟) | -| `uploadCount` | int | 10 | 时间窗口内最大上传次数 | -| `expireStyle` | list | ["day","hour","minute","forever","count"] | 允许的过期方式 | +| `open_upload` | int | 1 | 是否开放上传(1=开放,0=关闭) | +| `upload_size` | int | 10485760 | 最大上传大小(字节) | +| `enable_chunk` | int | 0 | 是否启用分片上传(1=启用,0=禁用) | +| `upload_minute` | int | 1 | 上传频率限制时间窗口(分钟) | +| `upload_count` | int | 10 | 时间窗口内最大上传次数 | +| `expire_style` | list | ["day","hour","minute","forever","count"] | 允许的过期方式 | ### 配置示例 ```python # 允许上传 100MB 文件,启用分片上传 -uploadSize = 104857600 -enableChunk = 1 +upload_size = 104857600 +enable_chunk = 1 # 放宽上传频率限制:每 5 分钟最多 50 次 -uploadMinute = 5 -uploadCount = 50 +upload_minute = 5 +upload_count = 50 # 只允许按天和按次数过期 -expireStyle = ["day", "count"] +expire_style = ["day", "count"] ``` ## 下一步 diff --git a/main.py b/main.py index f916683df..0127b270b 100644 --- a/main.py +++ b/main.py @@ -3,720 +3,38 @@ # @File : main.py # @Software: PyCharm import asyncio -import html import time from contextlib import asynccontextmanager -from urllib.parse import parse_qs -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import JSONResponse from tortoise import Tortoise from tortoise.contrib.fastapi import register_tortoise from apps.admin.views import admin_api -from apps.base.models import KeyValue -from apps.base.utils import ip_limit -from apps.base.views import share_api, chunk_api, presign_api -from core.config import ( +from apps.base.config import ( ensure_security_settings, ensure_settings_row, - initialize_system, is_runtime_initialized, refresh_settings, ) -from core.database import db_startup_lock, get_db_config, init_db -from core.logger import get_log_level_name, is_access_log_enabled, logger -from core.response import APIResponse -from core.settings import settings, BASE_DIR, DEFAULT_CONFIG -from core.tasks import ( +from apps.base.models import KeyValue +from apps.base.pages import index, 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, clean_incomplete_uploads, delete_expire_files, ) +from apps.base.views import share_api, chunk_api, presign_api +from core.database import db_startup_lock, get_db_config, init_db +from core.errors import StorageError +from core.logger import get_log_level_name, is_access_log_enabled, logger +from core.settings import settings from core.version import APP_VERSION -def normalize_public_flag(value) -> int: - if isinstance(value, str): - return int(value.strip().lower() in {"1", "true", "on", "yes"}) - return int(bool(value)) - - -def build_public_config() -> dict: - return { - "name": settings.name, - "description": settings.description, - "explain": settings.page_explain, - "uploadSize": settings.uploadSize, - "allowedFileTypes": settings.allowed_file_types, - "expireStyle": settings.expireStyle, - "enableChunk": settings.enableChunk, - "openUpload": settings.openUpload, - "notify_title": settings.notify_title, - "notify_content": settings.notify_content, - "show_admin_address": normalize_public_flag(settings.showAdminAddr), - "max_save_seconds": settings.max_save_seconds, - } - - -def build_public_meta() -> dict: - return { - "version": APP_VERSION, - "api": { - "legacyConfig": "/", - "publicConfig": "/api/v1/config", - "health": "/health", - }, - "features": { - "chunkUpload": bool(settings.enableChunk), - "guestUpload": bool(settings.openUpload), - "adminAddressVisible": bool(normalize_public_flag(settings.showAdminAddr)), - "expirationModes": settings.expireStyle, - }, - "limits": { - "uploadSize": settings.uploadSize, - "allowedFileTypes": settings.allowed_file_types, - "maxSaveSeconds": settings.max_save_seconds, - "uploadWindowMinutes": settings.uploadMinute, - "uploadWindowCount": settings.uploadCount, - }, - } - - -FILE_SIZE_UNITS = {"KB": 1024, "MB": 1024**2, "GB": 1024**3} -SAVE_TIME_UNITS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400} -EXPIRE_STYLE_OPTIONS = [ - ("day", "按天"), - ("hour", "按小时"), - ("minute", "按分钟"), - ("forever", "永久"), - ("count", "按取件次数"), -] - - -def get_form_value(data: dict, 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]: - value = data.get(key, []) - if isinstance(value, list): - return [str(item) for item in value if str(item)] - if value: - return [str(value)] - return [] - - -def normalize_bool_field(data: dict, key: str, default: bool) -> bool: - if key not in data: - return default - return get_form_value(data, key).lower() in {"1", "true", "on", "yes"} - - -def parse_int_field( - data: dict, - key: str, - default: int, - label: str, - min_value: int = 0, - max_value: int | None = None, -) -> int: - raw_value = get_form_value(data, key, str(default)).strip() - try: - value = int(raw_value) - except ValueError: - raise ValueError(f"{label} 必须是整数") - if value < min_value: - raise ValueError(f"{label} 不能小于 {min_value}") - if max_value is not None and value > max_value: - raise ValueError(f"{label} 不能大于 {max_value}") - return value - - -def parse_allowed_file_types(value: str) -> list[str]: - items = [item.strip() for item in value.split(",") if item.strip()] - return items or ["*"] - - -def parse_setup_options(data: dict) -> dict: - upload_size_unit = get_form_value(data, "upload_size_unit", "MB").upper() - if upload_size_unit not in FILE_SIZE_UNITS: - raise ValueError("文件大小单位不正确") - upload_size_value = parse_int_field( - data, "upload_size_value", 10, "文件大小限制", min_value=1 - ) - - save_time_unit = get_form_value(data, "save_time_unit", "day") - if save_time_unit not in SAVE_TIME_UNITS: - raise ValueError("最长保存时间单位不正确") - save_time_value = parse_int_field( - data, "save_time_value", 0, "最长保存时间", min_value=0 - ) - - expire_styles = get_form_list(data, "expireStyle") - valid_expire_styles = {style for style, _label in EXPIRE_STYLE_OPTIONS} - expire_styles = [style for style in expire_styles if style in valid_expire_styles] - if not expire_styles: - raise ValueError("至少需要选择一种过期方式") - - code_generate_type = get_form_value( - data, "code_generate_type", DEFAULT_CONFIG["code_generate_type"] - ) - if code_generate_type not in {"number", "secret"}: - raise ValueError("提取码类型不正确") - - return { - "allowed_file_types": parse_allowed_file_types( - get_form_value(data, "allowed_file_types", "*") - ), - "code_generate_type": code_generate_type, - "enableChunk": int(normalize_bool_field(data, "enableChunk", False)), - "errorCount": parse_int_field( - data, "errorCount", DEFAULT_CONFIG["errorCount"], "取件错误次数限制", 1 - ), - "errorMinute": parse_int_field( - data, "errorMinute", DEFAULT_CONFIG["errorMinute"], "取件错误检测窗口", 1 - ), - "loginCount": parse_int_field( - data, "loginCount", DEFAULT_CONFIG["loginCount"], "登录失败次数限制", 1 - ), - "loginMinute": parse_int_field( - data, "loginMinute", DEFAULT_CONFIG["loginMinute"], "登录失败检测窗口", 1 - ), - "expireStyle": expire_styles, - "max_save_seconds": save_time_value * SAVE_TIME_UNITS[save_time_unit], - "openUpload": int(normalize_bool_field(data, "openUpload", True)), - "uploadCount": parse_int_field( - data, "uploadCount", DEFAULT_CONFIG["uploadCount"], "上传次数限制", 1 - ), - "uploadMinute": parse_int_field( - data, "uploadMinute", DEFAULT_CONFIG["uploadMinute"], "上传检测窗口", 1 - ), - "uploadSize": upload_size_value * FILE_SIZE_UNITS[upload_size_unit], - } - - -def build_expire_style_inputs(selected_styles: list[str]) -> str: - inputs = [] - selected = set(selected_styles) - for style, label in EXPIRE_STYLE_OPTIONS: - checked = " checked" if style in selected else "" - inputs.append( - f'' - ) - return "\n ".join(inputs) - - -def build_setup_page(error: str = "", form: dict | None = None) -> str: - form = form or {} - escaped_error = html.escape(error) - escaped_site_name = html.escape( - get_form_value(form, "site_name", DEFAULT_CONFIG["name"]) - ) - escaped_allowed_types = html.escape(get_form_value(form, "allowed_file_types", "*")) - upload_size_value = html.escape(get_form_value(form, "upload_size_value", "10")) - upload_size_unit = get_form_value(form, "upload_size_unit", "MB").upper() - save_time_value = html.escape(get_form_value(form, "save_time_value", "0")) - save_time_unit = get_form_value(form, "save_time_unit", "day") - upload_minute = html.escape( - get_form_value(form, "uploadMinute", str(DEFAULT_CONFIG["uploadMinute"])) - ) - upload_count = html.escape( - get_form_value(form, "uploadCount", str(DEFAULT_CONFIG["uploadCount"])) - ) - error_minute = html.escape( - get_form_value(form, "errorMinute", str(DEFAULT_CONFIG["errorMinute"])) - ) - error_count = html.escape( - get_form_value(form, "errorCount", str(DEFAULT_CONFIG["errorCount"])) - ) - login_minute = html.escape( - get_form_value(form, "loginMinute", str(DEFAULT_CONFIG["loginMinute"])) - ) - login_count = html.escape( - get_form_value(form, "loginCount", str(DEFAULT_CONFIG["loginCount"])) - ) - open_upload_checked = ( - " checked" if normalize_bool_field(form, "openUpload", True) else "" - ) - chunk_checked = ( - " checked" if normalize_bool_field(form, "enableChunk", False) else "" - ) - code_generate_type = get_form_value( - form, "code_generate_type", DEFAULT_CONFIG["code_generate_type"] - ) - selected_expire_styles = get_form_list(form, "expireStyle") or list( - DEFAULT_CONFIG["expireStyle"] - ) - expire_style_inputs = build_expire_style_inputs(selected_expire_styles) - size_unit_options = "\n".join( - f'' - for unit in FILE_SIZE_UNITS - ) - save_time_unit_options = "\n".join( - f'' - for unit, label in [ - ("second", "秒"), - ("minute", "分钟"), - ("hour", "小时"), - ("day", "天"), - ] - ) - code_type_options = "\n".join( - f'' - for value, label in [("number", "数字"), ("secret", "随机字符")] - ) - error_block = ( - f'

' if escaped_error else "" - ) - return f""" - - - - - 初始化 FileCodeBox - - - -
-
-
-
FCB
-
-

初始化 FileCodeBox

-

首次配置管理员密码、上传限制和取件策略,后续可在后台调整。

-
-
- 首次配置向导 -
-
- {error_block} -
-
-
基础设置
- - - - - - - - -
- -
-
上传设置
- -
- - -
- - -
- - -
- -
- - - - -
-
- -
-
取件与保存
- -
- - -
- - -
- - -
- - -
- - -
- - - -
- -
-
-
可用策略
- -
- {expire_style_inputs} -
-
- -
- - -
-
-
- -
-
- -""" - - -def build_setup_success_page() -> str: - return """ - - - - - - 初始化完成 - - - -
-

初始化完成

-

管理员密码已设置,请使用刚才的密码登录后台。

- 进入后台 -
- -""" - - -def setup_response(content: str, status_code: int = 200) -> HTMLResponse: - return HTMLResponse( - content=content, - status_code=status_code, - media_type="text/html", - headers={"Cache-Control": "no-store"}, - ) - - -def is_setup_path(path: str) -> bool: - return path.rstrip("/") == "/setup" - - -def wants_html_response(request: Request) -> bool: - if request.method not in {"GET", "HEAD"}: - return False - accept = request.headers.get("accept", "") - return not accept or "text/html" in accept or "*/*" in accept - - -async def read_setup_payload(request: Request) -> dict: - content_type = request.headers.get("content-type", "") - if "application/json" in content_type: - data = await request.json() - return data if isinstance(data, dict) else {} - - body = (await request.body()).decode("utf-8") - return { - key: values if len(values) > 1 else values[-1] - for key, values in parse_qs(body).items() - } - - @asynccontextmanager async def lifespan(app: FastAPI): logger.info("正在初始化应用...") @@ -735,8 +53,6 @@ async def lifespan(app: FastAPI): try: yield finally: - # 清理操作 - logger.info("正在关闭应用...") task.cancel() chunk_cleanup_task.cancel() presign_cleanup_task.cancel() @@ -755,18 +71,33 @@ async def load_config(): await KeyValue.update_or_create( key="sys_start", defaults={"value": int(time.time() * 1000)} ) - await refresh_settings() + # refresh_settings already syncs every rate limiter (error/metadata/upload/login) + # via _sync_ip_limits; do not hand-sync a subset here — that once drifted by + # missing the metadata limiter. + await refresh_settings(force=True) await ensure_security_settings() - ip_limit["error"].minutes = settings.errorMinute - ip_limit["error"].count = settings.errorCount - ip_limit["upload"].minutes = settings.uploadMinute - ip_limit["upload"].count = settings.uploadCount - ip_limit["login"].minutes = settings.loginMinute - ip_limit["login"].count = settings.loginCount + # Rate limiters keep per-process state (apps.base.dependencies.IPRateLimit). + # With multiple workers each process counts independently, so the effective + # threshold scales with the worker count and resets on restart. + if settings.server_workers > 1: + logger.warning( + "server_workers=%s:进程内限流在多 worker 下各自独立,阈值将按 worker 数放大;" + "如需完整限流请使用单 worker(默认)或改造为共享存储限流", + settings.server_workers, + ) + app = FastAPI(lifespan=lifespan, version=APP_VERSION) + +@app.exception_handler(StorageError) +async def storage_error_handler(request, exc: StorageError): + # Render StorageError exactly like FastAPI renders HTTPException so the + # framework-free storage layer keeps identical client-visible responses. + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + @app.middleware("http") async def refresh_settings_middleware(request, call_next): await refresh_settings() @@ -807,121 +138,10 @@ async def refresh_settings_middleware(request, call_next): app.include_router(presign_api) app.include_router(presign_api, prefix="/api") app.include_router(admin_api) +app.include_router(pages_router) - -@app.get("/setup", include_in_schema=False) -@app.get("/setup/", include_in_schema=False) -async def setup_page(): - if is_runtime_initialized(): - return RedirectResponse(url="/", status_code=303) - return setup_response(build_setup_page()) - - -@app.post("/setup", include_in_schema=False) -@app.post("/setup/", include_in_schema=False) -async def setup_submit(request: Request): - if is_runtime_initialized(): - return RedirectResponse(url="/", status_code=303) - - data = await read_setup_payload(request) - admin_password = str(data.get("admin_password") or "") - confirm_password = str(data.get("confirm_password") or "") - site_name = str(data.get("site_name") or "") - - if admin_password != confirm_password: - return setup_response(build_setup_page("两次输入的管理员密码不一致", data), 400) - - try: - setup_options = parse_setup_options(data) - await initialize_system( - admin_password=admin_password, - site_name=site_name, - setup_options=setup_options, - ) - except ValueError as exc: - return setup_response(build_setup_page(str(exc), data), 400) - - if "application/json" in request.headers.get("accept", ""): - return APIResponse(detail={"ok": True, "admin": "/#/admin"}) - return setup_response(build_setup_success_page()) - - -def resolve_theme_root(): - themes_root = (BASE_DIR / "themes").resolve() - theme_root = (BASE_DIR / str(settings.themesSelect)).resolve() - try: - theme_root.relative_to(themes_root) - except ValueError: - theme_root = (BASE_DIR / DEFAULT_CONFIG["themesSelect"]).resolve() - if not theme_root.exists(): - theme_root = (BASE_DIR / DEFAULT_CONFIG["themesSelect"]).resolve() - return theme_root - - -def resolve_theme_file(*parts: str): - theme_root = resolve_theme_root() - file_path = theme_root.joinpath(*parts).resolve() - # 防止通过 /assets/../ 读取主题目录外的文件。 - try: - file_path.relative_to(theme_root) - except ValueError: - raise HTTPException(status_code=404, detail="资源不存在") - if not file_path.is_file(): - raise HTTPException(status_code=404, detail="资源不存在") - return file_path - - -@app.get("/assets/{asset_path:path}", include_in_schema=False) -async def theme_asset(asset_path: str): - return FileResponse(resolve_theme_file("assets", asset_path)) - - -@app.exception_handler(404) -@app.get("/") -async def index(request=None, exc=None): - return HTMLResponse( - content=resolve_theme_file("index.html") - .read_text(encoding="utf-8") - .replace("{{title}}", str(settings.name)) - .replace("{{description}}", str(settings.description)) - .replace("{{keywords}}", str(settings.keywords)) - .replace("{{opacity}}", str(settings.opacity)) - .replace("{{background}}", str(settings.background)), - media_type="text/html", - headers={"Cache-Control": "no-cache"}, - ) - - -@app.get("/robots.txt") -async def robots(): - return HTMLResponse(content=settings.robotsText, media_type="text/plain") - - -@app.post("/") -async def get_config(): - return APIResponse(detail=build_public_config()) - - -@app.get("/api/v1/config") -async def get_public_config(): - return APIResponse( - detail={ - "config": build_public_config(), - "meta": build_public_meta(), - } - ) - - -@app.get("/health") -async def health_check(): - return APIResponse( - detail={ - "status": "ok", - "version": APP_VERSION, - "storage": settings.file_storage, - "theme": settings.themesSelect, - } - ) +# 404 时返回主题首页(index 兼任 exception handler 与 GET / 路由) +app.add_exception_handler(404, index) if __name__ == "__main__": @@ -929,10 +149,10 @@ async def health_check(): uvicorn.run( app="main:app", - host=settings.serverHost, - port=settings.serverPort, + host=settings.server_host, + port=settings.server_port, reload=False, - workers=settings.serverWorkers, + workers=settings.server_workers, log_level=get_log_level_name(), access_log=is_access_log_enabled(), ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..e1e48de65 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +# FileCodeBox tooling config. +# Application repo (not a library): no [project] packaging metadata here, and +# requirements.txt remains the single source of runtime deps. This file only +# holds tool configs plus the dev dependency group (PEP 735, `uv sync --group dev`). + +[tool.pytest.ini_options] +testpaths = ["tests"] +# Tests import top-level packages (core/apps) directly; put the repo root on +# sys.path so bare `pytest` collects without `python -m pytest`. +pythonpath = ["."] +# Async tests/fixtures without per-test decorators. +asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "ruff", + "pre-commit", +] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +# Core correctness rules only for now (same families as ruff defaults): +# E4/E7/E9 = syntax/indent errors and unused variables; F = pyflakes +# (unused imports, undefined names). Style families (pyupgrade, broad-except, +# import sorting) are deferred to keep the initial diff minimal. +select = ["E4", "E7", "E9", "F"] diff --git a/requirements.lock.txt b/requirements.lock.txt new file mode 100644 index 000000000..6901baabd --- /dev/null +++ b/requirements.lock.txt @@ -0,0 +1,1028 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.lock.tmp --generate-hashes --universal -o requirements.lock +aioboto3==15.5.0 \ + --hash=sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6 \ + --hash=sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979 + # via -r requirements.lock.tmp +aiobotocore==2.25.1 \ + --hash=sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc \ + --hash=sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f + # via aioboto3 +aiofiles==25.1.0 \ + --hash=sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2 \ + --hash=sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695 + # via + # -r requirements.lock.tmp + # 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 + # via + # -r requirements.lock.tmp + # aiobotocore +aioitertools==0.13.0 \ + --hash=sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be \ + --hash=sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c + # via aiobotocore +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + # via aiohttp +aiosqlite==0.22.1 \ + --hash=sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650 \ + --hash=sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb + # via tortoise-orm +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + # via fastapi +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via + # starlette + # tortoise-orm +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via aiohttp +boto3==1.40.61 \ + --hash=sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c \ + --hash=sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12 + # via aiobotocore +botocore==1.40.61 \ + --hash=sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7 \ + --hash=sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd + # via + # aiobotocore + # boto3 + # s3transfer +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 +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \ + --hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \ + --hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \ + --hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \ + --hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \ + --hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \ + --hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \ + --hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \ + --hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \ + --hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \ + --hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \ + --hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \ + --hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \ + --hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \ + --hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \ + --hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \ + --hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \ + --hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \ + --hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \ + --hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd + # via + # aiohttp + # aiosignal +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via uvicorn +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via + # anyio + # yarl +iso8601==2.1.0 ; python_full_version < '4' \ + --hash=sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df \ + --hash=sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242 + # via tortoise-orm +jmespath==1.1.0 \ + --hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 + # via + # aiobotocore + # boto3 + # botocore +multidict==6.8.0 \ + --hash=sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b \ + --hash=sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d \ + --hash=sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e \ + --hash=sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd \ + --hash=sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e \ + --hash=sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40 \ + --hash=sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20 \ + --hash=sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368 \ + --hash=sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad \ + --hash=sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e \ + --hash=sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4 \ + --hash=sha256:0eca15d627e942ce186a935061f1568cc46c02e97c419c8da802df2be9f917d8 \ + --hash=sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b \ + --hash=sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7 \ + --hash=sha256:11d71490bf4bbff1141b14b93af419ad68c56b60bea9277fcb3f94dcca4796eb \ + --hash=sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384 \ + --hash=sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0 \ + --hash=sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e \ + --hash=sha256:15db8e6cab5f4cc9241bc56e69fdf3452cf49c10ee3c7977c742e68a275b3786 \ + --hash=sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c \ + --hash=sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04 \ + --hash=sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794 \ + --hash=sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff \ + --hash=sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab \ + --hash=sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af \ + --hash=sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33 \ + --hash=sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee \ + --hash=sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836 \ + --hash=sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc \ + --hash=sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6 \ + --hash=sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712 \ + --hash=sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916 \ + --hash=sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f \ + --hash=sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec \ + --hash=sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1 \ + --hash=sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15 \ + --hash=sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d \ + --hash=sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f \ + --hash=sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78 \ + --hash=sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb \ + --hash=sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3 \ + --hash=sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc \ + --hash=sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39 \ + --hash=sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b \ + --hash=sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4 \ + --hash=sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4 \ + --hash=sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c \ + --hash=sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8 \ + --hash=sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8 \ + --hash=sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5 \ + --hash=sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9 \ + --hash=sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b \ + --hash=sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a \ + --hash=sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a \ + --hash=sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2 \ + --hash=sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0 \ + --hash=sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035 \ + --hash=sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8 \ + --hash=sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4 \ + --hash=sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110 \ + --hash=sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15 \ + --hash=sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891 \ + --hash=sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5 \ + --hash=sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37 \ + --hash=sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f \ + --hash=sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8 \ + --hash=sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb \ + --hash=sha256:6300d5176647145ba1e22991c924fb29743e54b4d7b8bc85a0d3ec0e55e189cb \ + --hash=sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef \ + --hash=sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5 \ + --hash=sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c \ + --hash=sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31 \ + --hash=sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2 \ + --hash=sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a \ + --hash=sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3 \ + --hash=sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891 \ + --hash=sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943 \ + --hash=sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735 \ + --hash=sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e \ + --hash=sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62 \ + --hash=sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac \ + --hash=sha256:7a2573d0fd34f361a4a14e54d8cda3a91ac4e55fbf0d719698024f3b09c5b147 \ + --hash=sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5 \ + --hash=sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90 \ + --hash=sha256:7bc7003991ebd368a20d05228137a37b3d3066751f3ea1e4f7b8efe8e752f2f5 \ + --hash=sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630 \ + --hash=sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18 \ + --hash=sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab \ + --hash=sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b \ + --hash=sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7 \ + --hash=sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238 \ + --hash=sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364 \ + --hash=sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435 \ + --hash=sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08 \ + --hash=sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f \ + --hash=sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52 \ + --hash=sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da \ + --hash=sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4 \ + --hash=sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20 \ + --hash=sha256:8dc2d9c3a924ed14166e63650b2cf9f59e7821743bdd50b23802bd97ca09bde5 \ + --hash=sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a \ + --hash=sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84 \ + --hash=sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e \ + --hash=sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14 \ + --hash=sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c \ + --hash=sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7 \ + --hash=sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882 \ + --hash=sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d \ + --hash=sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2 \ + --hash=sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f \ + --hash=sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874 \ + --hash=sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6 \ + --hash=sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca \ + --hash=sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602 \ + --hash=sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb \ + --hash=sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463 \ + --hash=sha256:b1cd4d66ce894a45482e1ac2837c31d0bd447df35065e542b60055aa2d00404b \ + --hash=sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25 \ + --hash=sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d \ + --hash=sha256:b7e62b8fc7bd6cad007b9f2e0ad9c8d4854c06350d5f51e1a439dd18b510ecac \ + --hash=sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83 \ + --hash=sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316 \ + --hash=sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b \ + --hash=sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e \ + --hash=sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee \ + --hash=sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385 \ + --hash=sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d \ + --hash=sha256:c0fe437a6d2f36aac2b49517057776575b5bf359df314cca20d230a6e139c089 \ + --hash=sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11 \ + --hash=sha256:c2c5fd0fd39574ccd58e1a52565b341aff522c5c836f1b3eb7605c371e61f52c \ + --hash=sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e \ + --hash=sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742 \ + --hash=sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101 \ + --hash=sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0 \ + --hash=sha256:c9c98d2f0126ba84cb45601eed97ff67ff767e19ae6eb3c31b02827b54d700e5 \ + --hash=sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944 \ + --hash=sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d \ + --hash=sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f \ + --hash=sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26 \ + --hash=sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd \ + --hash=sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba \ + --hash=sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc \ + --hash=sha256:d6dc7804c50fabd28644d4d18a4b20aad3681b3e64f3acd3182b330ca73f7a32 \ + --hash=sha256:d7e5ba0a0153e35fbce9c51df530c8b4cb0c3012b46a04ff9a048441a269c2ed \ + --hash=sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556 \ + --hash=sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58 \ + --hash=sha256:dc911ae6152e455b16a2a1a626aa6cd612fa01efb9d0a4ab3f5cf328b911483d \ + --hash=sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9 \ + --hash=sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3 \ + --hash=sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38 \ + --hash=sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc \ + --hash=sha256:e418ec99574ca24365ca96546af285c2b021a1a072478a79f0e3cc3b08837154 \ + --hash=sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395 \ + --hash=sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f \ + --hash=sha256:e8e1e895e23818d343e4ae7dd95a0a556fdeaf8b471acf1c0a39b93c6f54d478 \ + --hash=sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081 \ + --hash=sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f \ + --hash=sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23 \ + --hash=sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f \ + --hash=sha256:edda19aff836ec515caafc09ea53d2ab144a041f09ee9a7cefcbd3ae4e976256 \ + --hash=sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39 \ + --hash=sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706 \ + --hash=sha256:f2fa3d3b1c933d4bcb8fd2018700d5e7235c52f2ab8c88d22286965c5c0f00f8 \ + --hash=sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b \ + --hash=sha256:f3a0a31189acf6703307397c6139ddabd734c20c5ef92649fc93e473df6615a3 \ + --hash=sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91 \ + --hash=sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc \ + --hash=sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6 \ + --hash=sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c \ + --hash=sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea \ + --hash=sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c + # via + # aiobotocore + # aiohttp + # yarl +propcache==0.5.2 \ + --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ + --hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \ + --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \ + --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \ + --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \ + --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \ + --hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \ + --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \ + --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \ + --hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \ + --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \ + --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \ + --hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \ + --hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \ + --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \ + --hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \ + --hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \ + --hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \ + --hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \ + --hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \ + --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \ + --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \ + --hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \ + --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \ + --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \ + --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \ + --hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \ + --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \ + --hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \ + --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \ + --hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \ + --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \ + --hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \ + --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \ + --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \ + --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \ + --hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \ + --hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \ + --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \ + --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \ + --hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \ + --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \ + --hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \ + --hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \ + --hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \ + --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \ + --hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \ + --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \ + --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \ + --hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \ + --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \ + --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \ + --hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \ + --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \ + --hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \ + --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \ + --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \ + --hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \ + --hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \ + --hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \ + --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \ + --hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \ + --hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \ + --hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \ + --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \ + --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \ + --hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \ + --hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \ + --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \ + --hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \ + --hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \ + --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \ + --hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \ + --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \ + --hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \ + --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \ + --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \ + --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \ + --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \ + --hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \ + --hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \ + --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \ + --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \ + --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ + --hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \ + --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \ + --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \ + --hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \ + --hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \ + --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \ + --hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \ + --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \ + --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \ + --hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \ + --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \ + --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \ + --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \ + --hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \ + --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \ + --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \ + --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \ + --hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \ + --hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \ + --hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \ + --hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \ + --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \ + --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \ + --hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \ + --hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \ + --hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \ + --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \ + --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \ + --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \ + --hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \ + --hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \ + --hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \ + --hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \ + --hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \ + --hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \ + --hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \ + --hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6 + # via + # aiohttp + # yarl +pydantic==2.12.5 \ + --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ + --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d + # via + # -r requirements.lock.tmp + # 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 + # via pydantic +pypika-tortoise==0.6.5 \ + --hash=sha256:64d96c9b88450f6360ad22a7063933b6a90961a7317f04b2b63c98fd5d705506 \ + --hash=sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1 + # via tortoise-orm +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via + # aiobotocore + # botocore +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via -r requirements.lock.tmp +pytz==2026.3.post1 \ + --hash=sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d \ + --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815 + # via tortoise-orm +s3transfer==0.14.0 \ + --hash=sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456 \ + --hash=sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125 + # via boto3 +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via python-dateutil +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b + # via + # -r requirements.lock.tmp + # fastapi +tortoise-orm==0.25.3 \ + --hash=sha256:3c52a53c41f4137aee9ffb3f3de01f30b52ad7767157f9bd9586a910fe839ca3 \ + --hash=sha256:b6dedd388393624628ec46228c93df361533ceb3925986fa2d1d22debc838a7d + # via -r requirements.lock.tmp +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # aiohttp + # aiosignal + # anyio + # fastapi + # pydantic + # pydantic-core + # starlette + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via + # fastapi + # pydantic +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 +wrapt==1.17.3 \ + --hash=sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56 \ + --hash=sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828 \ + --hash=sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f \ + --hash=sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396 \ + --hash=sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77 \ + --hash=sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d \ + --hash=sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139 \ + --hash=sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7 \ + --hash=sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb \ + --hash=sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f \ + --hash=sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f \ + --hash=sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067 \ + --hash=sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f \ + --hash=sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7 \ + --hash=sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b \ + --hash=sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc \ + --hash=sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05 \ + --hash=sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd \ + --hash=sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7 \ + --hash=sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9 \ + --hash=sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81 \ + --hash=sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977 \ + --hash=sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa \ + --hash=sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b \ + --hash=sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe \ + --hash=sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58 \ + --hash=sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8 \ + --hash=sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77 \ + --hash=sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85 \ + --hash=sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c \ + --hash=sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df \ + --hash=sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454 \ + --hash=sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a \ + --hash=sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e \ + --hash=sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c \ + --hash=sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6 \ + --hash=sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5 \ + --hash=sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9 \ + --hash=sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd \ + --hash=sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277 \ + --hash=sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225 \ + --hash=sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22 \ + --hash=sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116 \ + --hash=sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16 \ + --hash=sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc \ + --hash=sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00 \ + --hash=sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2 \ + --hash=sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a \ + --hash=sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804 \ + --hash=sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04 \ + --hash=sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1 \ + --hash=sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba \ + --hash=sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390 \ + --hash=sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0 \ + --hash=sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d \ + --hash=sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22 \ + --hash=sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0 \ + --hash=sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2 \ + --hash=sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18 \ + --hash=sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6 \ + --hash=sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311 \ + --hash=sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89 \ + --hash=sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f \ + --hash=sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39 \ + --hash=sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4 \ + --hash=sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5 \ + --hash=sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa \ + --hash=sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a \ + --hash=sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050 \ + --hash=sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6 \ + --hash=sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235 \ + --hash=sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056 \ + --hash=sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2 \ + --hash=sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418 \ + --hash=sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c \ + --hash=sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a \ + --hash=sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6 \ + --hash=sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0 \ + --hash=sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775 \ + --hash=sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10 \ + --hash=sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c + # via aiobotocore +yarl==1.24.5 \ + --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \ + --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \ + --hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \ + --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \ + --hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \ + --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \ + --hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \ + --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \ + --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \ + --hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \ + --hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \ + --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \ + --hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \ + --hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \ + --hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \ + --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \ + --hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \ + --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \ + --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \ + --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \ + --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \ + --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \ + --hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \ + --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \ + --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \ + --hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \ + --hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \ + --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \ + --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \ + --hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \ + --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \ + --hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \ + --hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \ + --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \ + --hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \ + --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \ + --hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \ + --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \ + --hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \ + --hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \ + --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \ + --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \ + --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \ + --hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \ + --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \ + --hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \ + --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \ + --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \ + --hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \ + --hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \ + --hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \ + --hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \ + --hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \ + --hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \ + --hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \ + --hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \ + --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \ + --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \ + --hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \ + --hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \ + --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \ + --hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \ + --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \ + --hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \ + --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \ + --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \ + --hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \ + --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \ + --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \ + --hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \ + --hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \ + --hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \ + --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \ + --hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \ + --hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \ + --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \ + --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \ + --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \ + --hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \ + --hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \ + --hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \ + --hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \ + --hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \ + --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \ + --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \ + --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \ + --hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \ + --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \ + --hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \ + --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \ + --hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \ + --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \ + --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \ + --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \ + --hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \ + --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \ + --hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \ + --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \ + --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \ + --hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \ + --hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \ + --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \ + --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \ + --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104 + # via aiohttp diff --git a/requirements.txt b/requirements.txt index 1e572e657..8a1227f2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ aioboto3==15.5.0 aiohttp==3.14.2 aiofiles==25.1.0 fastapi==0.139.2 -starlette==1.3.1 +starlette==1.6.0 pydantic==2.12.5 uvicorn==0.51.0 tortoise-orm==0.25.3 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1c6c0b9ef --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,69 @@ +"""Pytest fixtures for integration tests (real ASGI chain via httpx). + +The legacy unittest suite keeps its own style (helpers.SettingsOverrideMixin); +these fixtures are for new pytest-style tests. Lifespan is intentionally NOT +run: it would init the real file DB and start background tasks. Instead the +``db`` fixture inits an in-memory DB, which the per-request middleware and the +handlers then use normally. +""" +import shutil + +import httpx +import pytest_asyncio + +import main +from core.settings import data_root +from tests.helpers import close_db, init_memory_db + +TEST_ADMIN_PASSWORD = "Integration-Test-12345" + + +@pytest_asyncio.fixture +async def db(): + await init_memory_db() + try: + yield + finally: + await close_db() + + +@pytest_asyncio.fixture +async def client(db): + # Rate limiters are process-global; clear them so tests never trip 429. + from apps.base.utils import ip_limit + + for limiter in ip_limit.values(): + limiter.ips.clear() + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", follow_redirects=True + ) as c: + yield c + + +@pytest_asyncio.fixture +async def initialized_client(client): + """App with the system set up via POST /setup (admin password known).""" + response = await client.post( + "/setup", + json={ + "admin_password": TEST_ADMIN_PASSWORD, + "confirm_password": TEST_ADMIN_PASSWORD, + "site_name": "integration-tests", + "expire_style": ["day", "forever", "count"], + }, + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200, response.text + # initialize_system wrote config into the in-memory DB; restore the + # process-global settings snapshot afterwards so other tests see defaults. + from core.settings import settings + + original_user_config = dict(settings.user_config) + try: + yield client + finally: + settings.user_config = original_user_config + # Uploads in tests land under /data/share; remove them so the + # working tree stays clean (the DB itself is in-memory). + shutil.rmtree(f"{data_root}/share", ignore_errors=True) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..6722cd191 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,52 @@ +"""Shared test helpers: settings-override mixin and in-memory Tortoise setup. + +Kept unittest-friendly (the legacy suite is unittest-based); pytest fixtures +live in conftest.py and build on top of these helpers. +""" +from tortoise import Tortoise + +from core.settings import settings + +# Mirrors core.database.get_db_config() but with an in-memory SQLite database +# and no WAL/startup-lock specifics, so tests never touch the real data dir's DB. +MEMORY_DB_CONFIG = { + "connections": { + "default": { + "engine": "tortoise.backends.sqlite", + "credentials": {"file_path": ":memory:"}, + } + }, + "apps": { + "models": { + "models": ["apps.base.models"], + "default_connection": "default", + } + }, + "use_tz": False, + "timezone": "Asia/Shanghai", +} + + +class SettingsOverrideMixin: + """Snapshot settings.user_config in setUp and restore it in tearDown.""" + + def setUp(self): + self._original_user_config = dict(settings.user_config) + + def tearDown(self): + settings.user_config = self._original_user_config + + +async def init_memory_db(): + """Init Tortoise against an in-memory DB and create all schemas.""" + # 每个测试都是全新 DB,必须同步失效进程级配置 TTL 缓存, + # 否则上一个测试的缓存会让 middleware 误判初始化状态。 + import apps.base.config as config_module + + config_module._config_cached_until = 0.0 + await Tortoise.init(config=MEMORY_DB_CONFIG) + await Tortoise.generate_schemas() + + +async def close_db(): + await Tortoise.close_connections() diff --git a/tests/test_admin_security.py b/tests/test_admin_security.py index b7a0f9506..53cf54cd4 100644 --- a/tests/test_admin_security.py +++ b/tests/test_admin_security.py @@ -5,12 +5,12 @@ import apps.admin.services as admin_services import apps.admin.views as admin_views import apps.admin.dependencies as admin_dependencies -import core.config as core_config +import apps.base.config as core_config from apps.admin.dependencies import create_token, verify_token from apps.admin.schemas import LoginData from apps.admin.services import ConfigService from fastapi import HTTPException -from main import parse_setup_options +from apps.base.setup_wizard import parse_setup_options from core.security import ( LEGACY_DEFAULT_ADMIN_TOKEN, is_config_initialized, @@ -20,12 +20,7 @@ from core.utils import hash_password, verify_password -class SettingsOverrideMixin: - def setUp(self): - self._original_user_config = dict(settings.user_config) - - def tearDown(self): - settings.user_config = self._original_user_config +from tests.helpers import SettingsOverrideMixin class SecurityConfigTests(unittest.TestCase): @@ -69,47 +64,47 @@ def test_parse_setup_options_converts_common_fields(self): "upload_size_unit": "MB", "save_time_value": "7", "save_time_unit": "day", - "uploadCount": "30", - "uploadMinute": "2", - "errorCount": "5", - "errorMinute": "1", - "loginCount": "4", - "loginMinute": "20", - "openUpload": ["0", "1"], - "enableChunk": "0", + "upload_count": "30", + "upload_minute": "2", + "error_count": "5", + "error_minute": "1", + "login_count": "4", + "login_minute": "20", + "open_upload": ["0", "1"], + "enable_chunk": "0", "code_generate_type": "secret", - "expireStyle": ["day", "count"], + "expire_style": ["day", "count"], "allowed_file_types": ".zip, image/*", } ) - self.assertEqual(options["uploadSize"], 20 * 1024 * 1024) + self.assertEqual(options["upload_size"], 20 * 1024 * 1024) self.assertEqual(options["max_save_seconds"], 7 * 86400) - self.assertEqual(options["uploadCount"], 30) - self.assertEqual(options["uploadMinute"], 2) - self.assertEqual(options["errorCount"], 5) - self.assertEqual(options["errorMinute"], 1) - self.assertEqual(options["loginCount"], 4) - self.assertEqual(options["loginMinute"], 20) - self.assertEqual(options["openUpload"], 1) - self.assertEqual(options["enableChunk"], 0) + self.assertEqual(options["upload_count"], 30) + self.assertEqual(options["upload_minute"], 2) + self.assertEqual(options["error_count"], 5) + self.assertEqual(options["error_minute"], 1) + self.assertEqual(options["login_count"], 4) + self.assertEqual(options["login_minute"], 20) + self.assertEqual(options["open_upload"], 1) + self.assertEqual(options["enable_chunk"], 0) self.assertEqual(options["code_generate_type"], "secret") - self.assertEqual(options["expireStyle"], ["day", "count"]) + self.assertEqual(options["expire_style"], ["day", "count"]) self.assertEqual(options["allowed_file_types"], [".zip", "image/*"]) def test_parse_setup_options_allows_turning_guest_upload_off(self): options = parse_setup_options( { - "openUpload": "0", - "expireStyle": ["day"], + "open_upload": "0", + "expire_style": ["day"], } ) - self.assertEqual(options["openUpload"], 0) + self.assertEqual(options["open_upload"], 0) def test_parse_setup_options_requires_expire_style(self): with self.assertRaises(ValueError): - parse_setup_options({"expireStyle": []}) + parse_setup_options({"expire_style": []}) class AdminJwtTests(SettingsOverrideMixin, unittest.TestCase): @@ -130,7 +125,7 @@ def test_admin_jwt_signature_uses_independent_secret(self): def test_configured_session_lifetime_is_returned_by_login(self): settings.admin_token = hash_password("admin-password") settings.jwt_secret = "j" * 48 - settings.adminSessionExpire = 90 * 24 * 60 * 60 + settings.admin_session_expire = 90 * 24 * 60 * 60 original_time = admin_dependencies.time.time admin_dependencies.time.time = lambda: 1_800_000_000 try: @@ -157,7 +152,7 @@ async def update_or_create(cls, key, defaults): return None, True -async def fake_refresh_settings(): +async def fake_refresh_settings(force=False): return None @@ -189,7 +184,7 @@ class ConfigServiceSecurityTests(SettingsOverrideMixin, unittest.TestCase): def test_storage_limit_rejects_negative_values(self): settings.user_config = copy.deepcopy(DEFAULT_CONFIG) with self.assertRaises(HTTPException) as context: - asyncio.run(ConfigService().update_config({"storageLimit": -1})) + asyncio.run(ConfigService().update_config({"storage_limit": -1})) self.assertEqual(context.exception.status_code, 400) def test_admin_session_lifetime_rejects_out_of_range_values(self): @@ -200,7 +195,7 @@ def test_admin_session_lifetime_rejects_out_of_range_values(self): with self.assertRaises(HTTPException) as context: asyncio.run( ConfigService().update_config( - {"adminSessionExpire": invalid_value} + {"admin_session_expire": invalid_value} ) ) self.assertEqual(context.exception.status_code, 400) @@ -247,9 +242,9 @@ def test_initialize_system_sets_admin_password_and_jwt_secret(self): admin_password="new-admin-password", site_name="我的文件快递柜", setup_options={ - "uploadSize": 50 * 1024 * 1024, - "errorCount": 6, - "expireStyle": ["day", "count"], + "upload_size": 50 * 1024 * 1024, + "error_count": 6, + "expire_style": ["day", "count"], }, ) ) @@ -263,6 +258,6 @@ def test_initialize_system_sets_admin_password_and_jwt_secret(self): ) self.assertGreaterEqual(len(FakeConfigKeyValue.saved_value["jwt_secret"]), 32) self.assertEqual(FakeConfigKeyValue.saved_value["name"], "我的文件快递柜") - self.assertEqual(FakeConfigKeyValue.saved_value["uploadSize"], 50 * 1024 * 1024) - self.assertEqual(FakeConfigKeyValue.saved_value["errorCount"], 6) - self.assertEqual(FakeConfigKeyValue.saved_value["expireStyle"], ["day", "count"]) + self.assertEqual(FakeConfigKeyValue.saved_value["upload_size"], 50 * 1024 * 1024) + self.assertEqual(FakeConfigKeyValue.saved_value["error_count"], 6) + self.assertEqual(FakeConfigKeyValue.saved_value["expire_style"], ["day", "count"]) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py new file mode 100644 index 000000000..a70a9f954 --- /dev/null +++ b/tests/test_api_contract.py @@ -0,0 +1,77 @@ +"""API contract guard: responses must carry snake_case keys only. + +Regression net for the D7 lesson: a camel+snake dual-field contract crept +back through build_public_config/build_public_meta (file-based scoping hid +that they serve a live endpoint). Any key matching [a-z]+[A-Z] camel shape +anywhere in these documented response payloads now fails the suite. +""" +import re + +import httpx +import pytest + +CAMEL_KEY = re.compile(r"^[a-z0-9]+(?:[A-Z][a-zA-Z0-9]*)+$") + + +def _assert_no_camel_keys(node, path, violations): + if isinstance(node, dict): + for key, value in node.items(): + key_path = f"{path}.{key}" + if isinstance(key, str) and CAMEL_KEY.match(key): + violations.append(key_path) + _assert_no_camel_keys(value, key_path, violations) + elif isinstance(node, list): + for index, item in enumerate(node): + _assert_no_camel_keys(item, f"{path}[{index}]", violations) + + +async def _login(client: httpx.AsyncClient) -> 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"] + + +def _check_contract(payload: dict, url: str) -> None: + violations = [] + _assert_no_camel_keys(payload, url, violations) + assert not violations, f"camelCase keys leaked into {url}: {violations}" + + +@pytest.mark.asyncio +class TestApiContractSnakeCase: + async def test_public_config(self, initialized_client): + response = await initialized_client.get("/api/v1/config") + assert response.status_code == 200 + _check_contract(response.json(), "/api/v1/config") + + async def test_dashboard(self, initialized_client): + token = await _login(initialized_client) + response = await initialized_client.get( + "/admin/dashboard", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + _check_contract(response.json(), "/admin/dashboard") + + async def test_admin_file_list(self, initialized_client): + token = await _login(initialized_client) + response = await initialized_client.get( + "/admin/file/list", headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + _check_contract(response.json(), "/admin/file/list") + + async def test_share_metadata(self, initialized_client): + share = await initialized_client.post( + "/share/text/", data={"text": "contract guard", "expire_value": 1, "expire_style": "day"} + ) + assert share.status_code == 200, share.text + code = share.json()["detail"]["code"] + response = await initialized_client.get( + "/share/metadata/", params={"code": code} + ) + assert response.status_code == 200 + _check_contract(response.json(), "/share/metadata/") diff --git a/tests/test_background_url_validation.py b/tests/test_background_url_validation.py new file mode 100644 index 000000000..fb08114e5 --- /dev/null +++ b/tests/test_background_url_validation.py @@ -0,0 +1,77 @@ +"""Tests for the background-URL config validation. + +Themes inject the site ``background`` config into inline CSS ``url('...')``; +a single-quote breakout there is not neutralized by html escaping (the CSS +engine decodes entities back). The fix is upstream validation at config-write +time: only well-formed http(s) URLs (or empty) may be stored. +""" +import asyncio +import unittest + +from tests.helpers import SettingsOverrideMixin, close_db, init_memory_db + +from apps.admin.services import ConfigService +from core.utils import validate_background_url + +VALID = [ + "", + "https://example.com/bg.webp", + "http://cdn.example.org/a/b.jpg?id=1&token=ab", +] +INVALID = [ + "x') ;background:url(https://evil.com/)", + "https://example.com/bg') ;background:url(evil)", + "javascript:alert(1)", + "data:image/svg+xml;base64,AAAA", + "/relative/path.jpg", + "//protocol-relative.example/bg", + "https://example.com/a b.png", + "https://example.com/a(b).png", +] + + +class ValidateBackgroundUrlTests(unittest.TestCase): + def test_valid_values_pass_through(self): + for value in VALID: + self.assertEqual(validate_background_url(value), value.strip() if value else "") + + def test_injection_and_non_http_values_rejected(self): + for value in INVALID: + with self.assertRaises(ValueError, msg=value): + validate_background_url(value) + + def test_none_and_whitespace_become_empty(self): + self.assertEqual(validate_background_url(None), "") + self.assertEqual(validate_background_url(" "), "") + + +class ConfigServiceBackgroundGuardTests(SettingsOverrideMixin, unittest.TestCase): + def test_update_config_rejects_malicious_background(self): + asyncio.run(self._scenario()) + + async def _scenario(self): + await init_memory_db() + try: + service = ConfigService() + # 合法值可通过 + await service.update_config({"background": "https://ok.example/bg.png"}) + # 恶意值被拒且不落库 + from fastapi import HTTPException + + with self.assertRaises(HTTPException) as ctx: + await service.update_config({"background": "x') ;background:url(https://evil.com/)"}) + self.assertEqual(ctx.exception.status_code, 400) + + from apps.base.models import KeyValue + + record = await KeyValue.filter(key="settings").first() + self.assertEqual( + record.value.get("background"), "https://ok.example/bg.png", + "被拒绝的值不得写入配置", + ) + finally: + await close_db() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cleanup_tasks.py b/tests/test_cleanup_tasks.py new file mode 100644 index 000000000..0db45051c --- /dev/null +++ b/tests/test_cleanup_tasks.py @@ -0,0 +1,205 @@ +"""Tests for the data-deleting background tasks in core/tasks.py. + +These are the highest-risk paths in the app (they unlink files and delete DB +rows), previously untested. The infinite ``while True`` loops are broken by +patching asyncio.sleep to raise a sentinel after the first full pass; storage +is isolated into a temp dir by patching data_root in both consumers. +""" +import asyncio +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from tests.helpers import SettingsOverrideMixin, close_db, init_memory_db + +from apps.base.models import ( + FileCodes, + PresignUploadSession, + StorageReservation, + UploadChunk, +) +from apps.base.tasks import ( + clean_expired_presign_sessions, + clean_incomplete_uploads, + delete_expire_files, +) +from core.utils import get_now + + +class SleepSentinel(Exception): + pass + + +class _OneRoundMixin(SettingsOverrideMixin): + """Run one pass of a cleanup task, then escape the loop via the sleep patch.""" + + async def run_one_round(self, task_coro_factory, tmpdir): + def _sleep(seconds): + raise SleepSentinel + + with patch("apps.base.tasks.data_root", Path(tmpdir)), patch( + "core.storage.data_root", Path(tmpdir) + ), patch("apps.base.tasks.asyncio.sleep", side_effect=_sleep): + try: + await task_coro_factory() + except SleepSentinel: + pass + + def make_physical_file(self, tmpdir, file_code, content=b"payload"): + """Materialise the file a FileCodes row points at, under the tmp root.""" + path = Path(tmpdir) / file_code.file_path / file_code.uuid_file_name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + +class DeleteExpireFilesTests(_OneRoundMixin, unittest.TestCase): + def test_expired_files_and_rows_deleted_alive_kept(self): + asyncio.run(self._scenario()) + + async def _scenario(self): + with TemporaryDirectory() as tmpdir: + await init_memory_db() + try: + now = await get_now() + expired = await FileCodes.create( + code="gone1", + file_path="share/data/2026/01/01", + uuid_file_name="uuid-expired", + prefix="old", + suffix=".bin", + size=10, + expired_at=now - __import__("datetime").timedelta(days=1), + ) + kept = await FileCodes.create( + code="keep1", + file_path="share/data/2026/01/01", + uuid_file_name="uuid-alive", + prefix="new", + suffix=".bin", + size=10, + expired_at=now + __import__("datetime").timedelta(days=30), + expired_count=1, + ) + expired_path = self.make_physical_file(tmpdir, expired) + kept_path = self.make_physical_file(tmpdir, kept) + + await self.run_one_round(delete_expire_files, tmpdir) + + self.assertFalse(expired_path.exists(), "过期文件应从磁盘删除") + self.assertIsNone(await FileCodes.filter(code="gone1").first()) + self.assertTrue(kept_path.exists(), "未过期文件必须保留") + self.assertIsNotNone(await FileCodes.filter(code="keep1").first()) + finally: + await close_db() + + +class CleanIncompleteUploadsTests(_OneRoundMixin, unittest.TestCase): + def test_stale_sessions_chunks_and_reservations_cleaned(self): + asyncio.run(self._scenario()) + + async def _scenario(self): + with TemporaryDirectory() as tmpdir: + await init_memory_db() + try: + import datetime + + stale = await UploadChunk.create( + upload_id="stale-uid", + chunk_index=-1, + chunk_hash="a" * 64, + file_name="stale.bin", + file_size=64, + chunk_size=64, + total_chunks=2, + save_path="share/data/2026/01/01/uuid-stale/stale.bin", + created_at=await get_now() - datetime.timedelta(hours=48), + ) + chunks_dir = ( + Path(tmpdir) / "share/data/2026/01/01/uuid-stale/chunks/stale-uid" + ) + chunks_dir.mkdir(parents=True) + (chunks_dir / "0.part").write_bytes(b"x" * 64) + await StorageReservation.create( + token="chunk:stale-uid", size=64, expires_at=await get_now() + ) + + await self.run_one_round(clean_incomplete_uploads, tmpdir) + + self.assertFalse(chunks_dir.exists(), "过期会话的分片目录应被清理") + self.assertIsNone( + await UploadChunk.filter(upload_id="stale-uid").first() + ) + self.assertIsNone( + await StorageReservation.filter(token="chunk:stale-uid").first() + ) + # 静默保留 stale 引用避免未使用告警(save_path 行为已由目录断言覆盖) + del stale + finally: + await close_db() + + +class CleanExpiredPresignSessionsTests(_OneRoundMixin, unittest.TestCase): + def test_expired_direct_session_file_and_rows_cleaned(self): + asyncio.run(self._scenario()) + + async def _scenario(self): + with TemporaryDirectory() as tmpdir: + await init_memory_db() + try: + import datetime + + now = await get_now() + expired_direct = await PresignUploadSession.create( + upload_id="presign-direct", + file_name="direct.bin", + file_size=64, + save_path="share/data/2026/01/01/uuid-pd/direct.bin", + mode="direct", + expire_value=1, + expire_style="day", + expires_at=now - datetime.timedelta(seconds=1), + ) + proxy_session = await PresignUploadSession.create( + upload_id="presign-proxy", + file_name="proxy.bin", + file_size=64, + save_path="share/data/2026/01/01/uuid-pp/proxy.bin", + mode="proxy", + expire_value=1, + expire_style="day", + expires_at=now - datetime.timedelta(seconds=1), + ) + direct_path = Path(tmpdir) / expired_direct.save_path + direct_path.parent.mkdir(parents=True, exist_ok=True) + direct_path.write_bytes(b"payload") + proxy_path = Path(tmpdir) / proxy_session.save_path + + await self.run_one_round(clean_expired_presign_sessions, tmpdir) + + self.assertFalse(direct_path.exists(), "直传模式的临时文件应被删除") + self.assertIsNone( + await PresignUploadSession.filter( + upload_id="presign-direct" + ).first() + ) + self.assertIsNone( + await StorageReservation.filter( + token="presign:presign-direct" + ).first() + ) + # proxy 模式的文件可能尚未上传,任务只清记录 + self.assertIsNone( + await PresignUploadSession.filter( + upload_id="presign-proxy" + ).first() + ) + if proxy_path.exists(): + self.fail("proxy 会话文件不应被任务删除(从未上传成功)") + finally: + await close_db() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config_schema_guard.py b/tests/test_config_schema_guard.py new file mode 100644 index 000000000..efa8f2c27 --- /dev/null +++ b/tests/test_config_schema_guard.py @@ -0,0 +1,68 @@ +"""Schema guard: every settings attribute referenced by application code must +be declared in DEFAULT_CONFIG. + +This is the regression net for the ``opendal_scheme`` class of bug — a config +key read via ``settings.X`` that DEFAULT_CONFIG never declared, which crashes +with AttributeError only when an admin actually selects that code path. +""" +import re +import unittest +from pathlib import Path + +from core.settings import DEFAULT_CONFIG + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCAN_DIRS = ["main.py", "core", "apps"] + +# Attribute references that are Settings' own machinery, not config keys. +NON_CONFIG_ATTRS = {"items", "user_config", "default_config", "unknown_keys"} + +# Filename-extension tokens: matches like "core/settings.py" in docstrings. +FILE_EXT_LIKE = {"py", "pyc", "toml", "ini", "cfg", "yaml", "yml", "md"} + +# (?{{title}}" + '' + "
{{opacity}}
" + ) + tmp.close() + self._template_path = Path(tmp.name) + + def tearDown(self): + settings.user_config = self._original_user_config + pages.resolve_theme_file = self._original_resolve_theme_file + self._template_path.unlink() + + def _patch_template(self): + pages.resolve_theme_file = lambda *args, **kwargs: self._template_path + + def _render_index_html(self) -> str: + return asyncio.run(pages.index()).body.decode("utf-8") + + def test_malicious_site_config_is_escaped(self): + self._patch_template() + settings.name = "" + settings.description = '">' + + html = self._render_index_html() + + self.assertNotIn("