chore: initialize GSREA website project
This commit is contained in:
commit
d3c5461de5
1842 files changed
+246140
No files matched your search
@@ -0,0 +1,353 @@
|
||||
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)
|
||||
Reference in new issue
Block a user