354 lines
11 KiB
Python
354 lines
11 KiB
Python
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)
|