import json from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.deps import require_staff from app.db import get_db from app.models.content import ( SETTING_KEYS, AdminFile, SensitiveWord, read_upload_limit, save_settings, settings_view, ) from app.services.html_filter import is_javascript_url, preview_filter from app.services.storage import ( make_object_key, private_object_url, public_object_url, remove_stored_object, safe_filename, upload_object, ) router = APIRouter(dependencies=[Depends(require_staff)]) _TEXT_KEYS = {"site_name", "icp_text", "police_text", "link_cirea", "link_housing"} _LINK_KEYS = {"link_cirea", "link_housing"} _POSITIVE = { "upload_max_bytes": "上传大小上限必须是正整数", "login_max_failures": "登录锁定参数必须是正整数", "login_lock_minutes": "登录锁定参数必须是正整数", } _WORKDAYS = {"complaint_workdays", "expert_reply_workdays"} _INT_MAX = 2147483647 class WordIn(BaseModel): word: str = "" class PreviewIn(BaseModel): html: str = "" def _bad(detail: str) -> None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) def _actor_id(principal: dict) -> int | None: try: return int(principal["sub"]) except (KeyError, TypeError, ValueError): return None def _content_type(value: str | None) -> str: raw = (value or "").split(";", 1)[0].strip().lower() return raw.replace("\r", "").replace("\n", "")[:128] def _read_limited(upload: UploadFile, limit: int) -> bytes: chunks = bytearray() while True: piece = upload.file.read(65536) if not piece: break if len(chunks) + len(piece) > limit: _bad("文件超过大小上限") chunks.extend(piece) return bytes(chunks) def _text_setting(key: str, value) -> str: if not isinstance(value, str): _bad("配置项格式不正确") text = value.strip() if key in _LINK_KEYS and is_javascript_url(text): _bad("链接地址不正确") return text def _logo_id(value) -> str: if value is None: return "" if isinstance(value, str) and not value.strip(): return "" if isinstance(value, bool) or not isinstance(value, (int, str)): _bad("logo_file_id 必须是附件编号") text = str(value).strip() if not text.isdigit() or int(text) <= 0: _bad("logo_file_id 必须是附件编号") return str(int(text)) def _banner_ids(value) -> str: if not isinstance(value, list): _bad("banner_file_ids 必须是数组") numbers: list[int] = [] for item in value: if isinstance(item, bool) or not isinstance(item, (int, str)): _bad("banner_file_ids 必须是附件编号") text = str(item).strip() if not text.isdigit() or int(text) <= 0: _bad("banner_file_ids 必须是附件编号") numbers.append(int(text)) return json.dumps(numbers, separators=(",", ":")) def _friend_links(value) -> str: if not isinstance(value, list): _bad("friend_links 必须是数组") cleaned: list[dict[str, str]] = [] for item in value: if not isinstance(item, dict): _bad("相关链接必须包含名称和地址") name = item.get("name") url = item.get("url") if not isinstance(name, str) or not isinstance(url, str): _bad("相关链接必须包含名称和地址") name = name.strip() url = url.strip() if not name or not url: _bad("相关链接必须包含名称和地址") if is_javascript_url(url): _bad("链接地址不正确") cleaned.append({"name": name, "url": url}) return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":")) def _positive(value, message: str) -> str: if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _INT_MAX: _bad(message) return str(value) def _workdays(value) -> str: if value is None or value == "": return "" if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > _INT_MAX: _bad("时限必须是非负整数") return str(value) def _normalize_settings(payload: dict) -> dict[str, str]: if any(key not in SETTING_KEYS for key in payload): _bad("未知的配置项") stored: dict[str, str] = {} for key, value in payload.items(): if key in _TEXT_KEYS: stored[key] = _text_setting(key, value) elif key == "logo_file_id": stored[key] = _logo_id(value) elif key == "banner_file_ids": stored[key] = _banner_ids(value) elif key == "friend_links": stored[key] = _friend_links(value) elif key in _POSITIVE: stored[key] = _positive(value, _POSITIVE[key]) elif key in _WORKDAYS: stored[key] = _workdays(value) return stored def _file_link(row: AdminFile) -> tuple[str, str | None]: if row.bucket == "public": return public_object_url(row.object_key), None if row.bucket == "private": url, expires_at = private_object_url(row.object_key) return url, expires_at.isoformat() _bad("附件存储位置不正确") return "", None def _file_body(row: AdminFile, *, with_meta: bool) -> dict: url, expires_at = _file_link(row) body = { "id": row.id, "bucket": row.bucket, "filename": row.filename, "contentType": row.content_type, "size": row.size, "url": url, "expiresAt": expires_at, } if with_meta: body["createdBy"] = row.created_by body["createdAt"] = row.created_at.isoformat() if row.created_at else None return body def _word_body(row: SensitiveWord) -> dict: return { "id": row.id, "word": row.word, "createdAt": row.created_at.isoformat() if row.created_at else None, } @router.post("/files") def upload_file( file: UploadFile = File(...), bucket: str | None = Form(default=None), db: Session = Depends(get_db), principal: dict = Depends(require_staff), ): if bucket not in {"public", "private"}: _bad("bucket 只能是 public 或 private") limit = read_upload_limit(db) if limit is None: _bad("未配置上传大小上限") data = _read_limited(file, limit) filename = safe_filename(file.filename) content_type = _content_type(file.content_type) object_key = make_object_key(filename) upload_object(bucket, object_key, data, content_type) row = AdminFile( bucket=bucket, object_key=object_key, filename=filename, content_type=content_type, size=len(data), created_by=_actor_id(principal), ) db.add(row) try: db.commit() db.refresh(row) except Exception: db.rollback() try: remove_stored_object(bucket, object_key) except HTTPException: pass raise return _file_body(row, with_meta=False) def _like_contains(value: str) -> str: escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return f"%{escaped}%" @router.get("/files") def list_files( current: int = Query(default=1, ge=1), size: int = Query(default=10, ge=1, le=100), bucket: str | None = Query(default=None), q: str | None = Query(default=None), db: Session = Depends(get_db), ): if bucket is not None and bucket not in {"public", "private"}: _bad("bucket 只能是 public 或 private") count_stmt = select(func.count()).select_from(AdminFile) stmt = select(AdminFile).order_by(AdminFile.id.desc()) if bucket is not None: count_stmt = count_stmt.where(AdminFile.bucket == bucket) stmt = stmt.where(AdminFile.bucket == bucket) keyword = (q or "").strip()[:100] if keyword: needle = _like_contains(keyword) count_stmt = count_stmt.where(AdminFile.filename.like(needle, escape="\\")) stmt = stmt.where(AdminFile.filename.like(needle, escape="\\")) total = int(db.scalar(count_stmt) or 0) rows = db.scalars(stmt.offset((current - 1) * size).limit(size)).all() return { "records": [_file_body(row, with_meta=True) for row in rows], "current": current, "size": size, "total": total, } @router.get("/files/{file_id}") def read_file(file_id: int, db: Session = Depends(get_db)): row = db.get(AdminFile, file_id) if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") return _file_body(row, with_meta=True) @router.delete("/files/{file_id}") def delete_file(file_id: int, db: Session = Depends(get_db)): row = db.get(AdminFile, file_id) if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") remove_stored_object(row.bucket, row.object_key) db.delete(row) db.commit() return {"id": file_id} @router.get("/site-settings") def read_site_settings(db: Session = Depends(get_db)): return settings_view(db) @router.put("/site-settings") async def update_site_settings(request: Request, db: Session = Depends(get_db)): try: payload = await request.json() except Exception: _bad("配置项格式不正确") if not isinstance(payload, dict): _bad("配置项格式不正确") save_settings(db, _normalize_settings(payload)) return settings_view(db) @router.get("/sensitive-words") def list_words(db: Session = Depends(get_db)): rows = db.scalars(select(SensitiveWord).order_by(SensitiveWord.id.asc())).all() return [_word_body(row) for row in rows] @router.post("/sensitive-words") def create_word(body: WordIn, db: Session = Depends(get_db)): word = body.word.strip() if not word: _bad("敏感词不能为空") if len(word) > 64: _bad("敏感词不能超过 64 个字符") exists = db.scalar(select(SensitiveWord.id).where(SensitiveWord.word == word)) if exists is not None: _bad("敏感词已存在") row = SensitiveWord(word=word) db.add(row) try: db.commit() db.refresh(row) except IntegrityError: db.rollback() _bad("敏感词已存在") return _word_body(row) @router.delete("/sensitive-words/{word_id}") def delete_word(word_id: int, db: Session = Depends(get_db)): row = db.get(SensitiveWord, word_id) if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="敏感词不存在") db.delete(row) db.commit() return {"id": word_id} @router.post("/content/preview-filter") def preview_content(body: PreviewIn, db: Session = Depends(get_db)): return preview_filter(body.html, db)