206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""Database-backed global upload-policy API.
|
|
|
|
``GET /api/upload-policy`` is available to authenticated users so every upload
|
|
surface can render the same effective limits. ``PUT`` replaces the document
|
|
atomically and is restricted to administrators. Before the first save the
|
|
built-in defaults are returned without requiring a seed row.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
from app.gateway.auth.disabled_mode import is_auth_disabled
|
|
from app.gateway.deps import get_optional_user_from_request
|
|
from deerflow.persistence.upload_policy import UploadPolicyStore
|
|
from deerflow.uploads.policy import MIB, default_upload_policy, normalize_extension, set_runtime_upload_policy
|
|
|
|
router = APIRouter(prefix="/api/upload-policy", tags=["upload-policy"])
|
|
|
|
MAX_CONFIGURED_FILES = 100
|
|
MAX_CONFIGURED_TOTAL_BYTES = 200 * MIB
|
|
MAX_CONFIGURED_FILE_BYTES = 100 * MIB
|
|
MAX_MODEL_RESULT_CHARS = 32_000
|
|
|
|
_SUPPORTED_BY_GROUP = {
|
|
"document": {".txt", ".md", ".doc", ".docx", ".ppt", ".pptx", ".pdf"},
|
|
"image": {".jpg", ".jpeg", ".png"},
|
|
"spreadsheet": {".xls", ".xlsx", ".csv"},
|
|
}
|
|
|
|
|
|
class UploadGroupPolicy(BaseModel):
|
|
max_bytes: int = Field(..., ge=1, le=MAX_CONFIGURED_FILE_BYTES)
|
|
extensions: list[str] = Field(default_factory=list)
|
|
|
|
@field_validator("extensions")
|
|
@classmethod
|
|
def _normalize_extensions(cls, values: list[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for value in values:
|
|
extension = normalize_extension(value)
|
|
if not extension or len(extension) > 16 or not extension[1:].isalnum():
|
|
raise ValueError(f"Invalid file extension: {value!r}")
|
|
if extension not in seen:
|
|
normalized.append(extension)
|
|
seen.add(extension)
|
|
return normalized
|
|
|
|
|
|
class UploadGroups(BaseModel):
|
|
document: UploadGroupPolicy
|
|
image: UploadGroupPolicy
|
|
spreadsheet: UploadGroupPolicy
|
|
|
|
|
|
class LargeFilePolicy(BaseModel):
|
|
threshold_bytes: int = Field(default=1 * MIB, ge=1, le=MAX_CONFIGURED_TOTAL_BYTES)
|
|
max_result_chars: int = Field(default=12_000, ge=1024, le=MAX_MODEL_RESULT_CHARS)
|
|
hard_max_result_chars: int = Field(default=MAX_MODEL_RESULT_CHARS, ge=1024, le=MAX_MODEL_RESULT_CHARS)
|
|
|
|
@model_validator(mode="after")
|
|
def _soft_limit_not_above_hard_limit(self) -> LargeFilePolicy:
|
|
if self.max_result_chars > self.hard_max_result_chars:
|
|
raise ValueError("large_file.max_result_chars must not exceed hard_max_result_chars")
|
|
return self
|
|
|
|
|
|
class UploadPolicyDocument(BaseModel):
|
|
max_files_per_message: int = Field(default=20, ge=1, le=MAX_CONFIGURED_FILES)
|
|
max_total_bytes: int = Field(default=100 * MIB, ge=1, le=MAX_CONFIGURED_TOTAL_BYTES)
|
|
groups: UploadGroups
|
|
large_file: LargeFilePolicy = Field(default_factory=LargeFilePolicy)
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_groups(self) -> UploadPolicyDocument:
|
|
seen: set[str] = set()
|
|
for group_name in ("document", "image", "spreadsheet"):
|
|
group = getattr(self.groups, group_name)
|
|
invalid = set(group.extensions) - _SUPPORTED_BY_GROUP[group_name]
|
|
if invalid:
|
|
raise ValueError(
|
|
f"Unsupported {group_name} extension(s): {', '.join(sorted(invalid))}"
|
|
)
|
|
overlap = seen.intersection(group.extensions)
|
|
if overlap:
|
|
raise ValueError(f"Extensions cannot appear in multiple groups: {', '.join(sorted(overlap))}")
|
|
seen.update(group.extensions)
|
|
if group.max_bytes > self.max_total_bytes:
|
|
raise ValueError(f"groups.{group_name}.max_bytes must not exceed max_total_bytes")
|
|
if self.large_file.threshold_bytes > self.max_total_bytes:
|
|
raise ValueError("large_file.threshold_bytes must not exceed max_total_bytes")
|
|
return self
|
|
|
|
|
|
class UploadPolicyResponse(UploadPolicyDocument):
|
|
source: Literal["default", "database"] = "default"
|
|
revision: int = 0
|
|
updated_by: str | None = None
|
|
updated_at: datetime | str | None = None
|
|
|
|
|
|
def _get_store(request: Request) -> UploadPolicyStore:
|
|
store = getattr(request.app.state, "upload_policy_store", None)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="Upload policy store not available")
|
|
return store
|
|
|
|
|
|
async def _actor(request: Request):
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None and not is_auth_disabled():
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
return user
|
|
|
|
|
|
async def get_effective_upload_policy(request: Request) -> tuple[dict, dict]:
|
|
"""Load the latest policy on every request, falling back to defaults."""
|
|
|
|
row = await _get_store(request).get_policy()
|
|
if not row:
|
|
policy = default_upload_policy()
|
|
set_runtime_upload_policy(policy)
|
|
return policy, {
|
|
"source": "default",
|
|
"revision": 0,
|
|
"updated_by": None,
|
|
"updated_at": None,
|
|
}
|
|
try:
|
|
policy = UploadPolicyDocument.model_validate(row.get("policy") or {}).model_dump()
|
|
except Exception:
|
|
# A future/older malformed row must never disable upload enforcement.
|
|
policy = default_upload_policy()
|
|
set_runtime_upload_policy(policy)
|
|
return policy, {
|
|
"source": "default",
|
|
"revision": int(row.get("revision") or 0),
|
|
"updated_by": row.get("updated_by"),
|
|
"updated_at": row.get("updated_at"),
|
|
}
|
|
set_runtime_upload_policy(policy)
|
|
return policy, {
|
|
"source": "database",
|
|
"revision": int(row.get("revision") or 1),
|
|
"updated_by": row.get("updated_by"),
|
|
"updated_at": row.get("updated_at"),
|
|
}
|
|
|
|
|
|
def _response(policy: dict, metadata: dict) -> UploadPolicyResponse:
|
|
return UploadPolicyResponse(**deepcopy(policy), **metadata)
|
|
|
|
|
|
@router.get("", response_model=UploadPolicyResponse)
|
|
async def read_upload_policy(request: Request) -> UploadPolicyResponse:
|
|
await _actor(request)
|
|
policy, metadata = await get_effective_upload_policy(request)
|
|
return _response(policy, metadata)
|
|
|
|
|
|
@router.put("", response_model=UploadPolicyResponse)
|
|
async def replace_upload_policy(request: Request, body: UploadPolicyDocument) -> UploadPolicyResponse:
|
|
user = await _actor(request)
|
|
if user is not None and getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="上传设置仅限管理员")
|
|
user_id = str(user.id) if user is not None else None
|
|
row = await _get_store(request).set_policy(body.model_dump(), updated_by=user_id)
|
|
policy = UploadPolicyDocument.model_validate(row["policy"]).model_dump()
|
|
set_runtime_upload_policy(policy)
|
|
return _response(
|
|
policy,
|
|
{
|
|
"source": "database",
|
|
"revision": int(row.get("revision") or 1),
|
|
"updated_by": row.get("updated_by"),
|
|
"updated_at": row.get("updated_at"),
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/reset", response_model=UploadPolicyResponse)
|
|
async def reset_upload_policy(request: Request) -> UploadPolicyResponse:
|
|
"""Restore the built-in defaults as a new persisted revision."""
|
|
|
|
user = await _actor(request)
|
|
if user is not None and getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="上传设置仅限管理员")
|
|
user_id = str(user.id) if user is not None else None
|
|
row = await _get_store(request).set_policy(default_upload_policy(), updated_by=user_id)
|
|
set_runtime_upload_policy(row["policy"])
|
|
return _response(
|
|
row["policy"],
|
|
{
|
|
"source": "database",
|
|
"revision": int(row.get("revision") or 1),
|
|
"updated_by": row.get("updated_by"),
|
|
"updated_at": row.get("updated_at"),
|
|
},
|
|
)
|