Files

391 lines
15 KiB
Python

"""Application chrome name, logo, and optional red-theme artwork.
Stored separately from ``generated_page_branding`` and the
``generated-page-branding/`` directory. Missing custom images leave the
built-in theme artwork in place.
"""
from __future__ import annotations
import asyncio
from typing import Annotated
from uuid import uuid4
from xml.etree import ElementTree
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.gateway.routers.system_settings import _require_admin
from deerflow.config.runtime_paths import runtime_home
from deerflow.config.system_settings import (
AppIdentitySettings,
app_identity_light_red_header_background_path,
app_identity_light_red_nav_active_path,
app_identity_logo_path,
load_system_settings,
save_system_settings,
)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
public_router = APIRouter(prefix="/api/public/app-identity", tags=["app-identity"])
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
_PUBLIC_LOGO_PATH = "/api/public/app-identity/logo"
_PUBLIC_LIGHT_RED_HEADER_PATH = "/api/public/app-identity/light-red-header-background"
_PUBLIC_LIGHT_RED_ACTIVE_PATH = "/api/public/app-identity/light-red-nav-active"
class AppIdentityResponse(BaseModel):
system_name: str = ""
header_title_font_mode: str = "adaptive"
header_title_font_size: int = 30
logo_url: str | None = None
logo_version: str | None = None
has_logo: bool = False
light_red_header_background_url: str | None = None
light_red_header_background_version: str | None = None
has_light_red_header_background: bool = False
light_red_nav_active_url: str | None = None
light_red_nav_active_version: str | None = None
has_light_red_nav_active: bool = False
def _detect_image(
data: bytes,
label: str,
*,
allow_svg: bool = False,
) -> tuple[str, str]:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png", "image/png"
if data.startswith(b"\xff\xd8\xff"):
return ".jpg", "image/jpeg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp", "image/webp"
if allow_svg:
try:
root = ElementTree.fromstring(data)
except ElementTree.ParseError:
root = None
if root is not None and root.tag.rsplit("}", 1)[-1].lower() == "svg":
return ".svg", "image/svg+xml"
supported = "PNG、JPG、WebP 或 SVG" if allow_svg else "PNG、JPG 或 WebP"
raise HTTPException(status_code=422, detail=f"{label}仅支持 {supported} 图片")
def _normalize_system_name(value: str | None) -> str:
normalized = " ".join(str(value or "").split())
if len(normalized) > 80:
raise HTTPException(status_code=422, detail="系统名称不能超过 80 个字符")
return normalized
def _normalize_header_title_font_mode(value: str) -> str:
normalized = str(value).strip().lower()
if normalized not in {"adaptive", "fixed"}:
raise HTTPException(status_code=422, detail="系统名称字号模式仅支持自适应或固定大小")
return normalized
def _normalize_header_title_font_size(value: int) -> int:
normalized = int(value)
if normalized < 16 or normalized > 40:
raise HTTPException(status_code=422, detail="系统名称固定字号必须在 16px 到 40px 之间")
return normalized
def _response(settings: AppIdentitySettings) -> AppIdentityResponse:
path = app_identity_logo_path(settings)
version = settings.logo_version if path is not None else None
header_background_path = app_identity_light_red_header_background_path(settings)
header_background_version = (
settings.light_red_header_background_version
if header_background_path is not None
else None
)
nav_active_path = app_identity_light_red_nav_active_path(settings)
nav_active_version = (
settings.light_red_nav_active_version if nav_active_path is not None else None
)
return AppIdentityResponse(
system_name=settings.system_name,
header_title_font_mode=settings.header_title_font_mode,
header_title_font_size=settings.header_title_font_size,
logo_url=f"{_PUBLIC_LOGO_PATH}?v={version}" if version else None,
logo_version=version,
has_logo=path is not None,
light_red_header_background_url=(
f"{_PUBLIC_LIGHT_RED_HEADER_PATH}?v={header_background_version}"
if header_background_version
else None
),
light_red_header_background_version=header_background_version,
has_light_red_header_background=header_background_path is not None,
light_red_nav_active_url=(
f"{_PUBLIC_LIGHT_RED_ACTIVE_PATH}?v={nav_active_version}"
if nav_active_version
else None
),
light_red_nav_active_version=nav_active_version,
has_light_red_nav_active=nav_active_path is not None,
)
def _write_asset(data: bytes, suffix: str, prefix: str) -> str:
directory = runtime_home() / "app-identity"
directory.mkdir(parents=True, exist_ok=True)
filename = f"{prefix}-{uuid4().hex}{suffix}"
destination = directory / filename
temporary = directory / f".{filename}.tmp"
try:
temporary.write_bytes(data)
temporary.replace(destination)
finally:
temporary.unlink(missing_ok=True)
return filename
async def _read_uploaded_image(
upload: UploadFile,
label: str,
*,
allow_svg: bool = False,
max_bytes: int | None = _MAX_IMAGE_BYTES,
) -> tuple[bytes, str, str]:
data = await upload.read(max_bytes + 1) if max_bytes is not None else await upload.read()
await upload.close()
if not data:
raise HTTPException(status_code=422, detail=f"{label}文件不能为空")
if max_bytes is not None and len(data) > max_bytes:
raise HTTPException(status_code=413, detail=f"{label}不能超过 10MB")
suffix, media_type = _detect_image(data, label, allow_svg=allow_svg)
return data, suffix, media_type
def _public_asset_headers(media_type: str | None) -> dict[str, str]:
headers = {"Cache-Control": "public, max-age=31536000, immutable"}
if media_type == "image/svg+xml":
headers.update(
{
"Content-Security-Policy": (
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"
),
"X-Content-Type-Options": "nosniff",
}
)
return headers
@router.get("/app-identity", response_model=AppIdentityResponse)
async def get_app_identity_settings() -> AppIdentityResponse:
"""Return the application identity settings readable by all logged-in users."""
return _response(load_system_settings().app_identity)
@router.put("/app-identity", response_model=AppIdentityResponse)
async def update_app_identity_settings(
request: Request,
system_name: Annotated[str, Form()] = "",
remove_logo: Annotated[bool, Form()] = False,
logo: Annotated[UploadFile | None, File()] = None,
header_title_font_mode: Annotated[str | None, Form()] = None,
header_title_font_size: Annotated[int | None, Form()] = None,
light_red_header_background: Annotated[UploadFile | None, File()] = None,
remove_light_red_header_background: Annotated[bool, Form()] = False,
light_red_nav_active: Annotated[UploadFile | None, File()] = None,
remove_light_red_nav_active: Annotated[bool, Form()] = False,
) -> AppIdentityResponse:
"""Save the application chrome identity and optional red-theme artwork."""
await _require_admin(request)
if logo is not None and remove_logo:
raise HTTPException(status_code=422, detail="上传 Logo 时不能同时恢复静态 Logo")
if light_red_header_background is not None and remove_light_red_header_background:
raise HTTPException(status_code=422, detail="上传导航栏背景图时不能同时恢复默认图片")
if light_red_nav_active is not None and remove_light_red_nav_active:
raise HTTPException(status_code=422, detail="上传菜单选中样式图时不能同时恢复默认图片")
current = load_system_settings()
previous_logo = app_identity_logo_path(current.app_identity)
previous_header_background = app_identity_light_red_header_background_path(
current.app_identity
)
previous_nav_active = app_identity_light_red_nav_active_path(current.app_identity)
update: dict[str, object] = {"system_name": _normalize_system_name(system_name)}
if header_title_font_mode is not None:
update["header_title_font_mode"] = _normalize_header_title_font_mode(header_title_font_mode)
if header_title_font_size is not None:
update["header_title_font_size"] = _normalize_header_title_font_size(header_title_font_size)
logo_asset = (
await _read_uploaded_image(logo, "Logo") if logo is not None else None
)
header_background_asset = (
await _read_uploaded_image(
light_red_header_background,
"导航栏背景图",
allow_svg=True,
max_bytes=None,
)
if light_red_header_background is not None
else None
)
nav_active_asset = (
await _read_uploaded_image(
light_red_nav_active,
"菜单选中样式图",
allow_svg=True,
max_bytes=None,
)
if light_red_nav_active is not None
else None
)
staged_paths = []
try:
if logo_asset is not None:
data, suffix, media_type = logo_asset
filename = await asyncio.to_thread(_write_asset, data, suffix, "logo")
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"logo_filename": filename,
"logo_media_type": media_type,
"logo_version": uuid4().hex,
}
)
elif remove_logo:
update.update(
{
"logo_filename": None,
"logo_media_type": None,
"logo_version": None,
}
)
if header_background_asset is not None:
data, suffix, media_type = header_background_asset
filename = await asyncio.to_thread(
_write_asset, data, suffix, "light-red-header"
)
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"light_red_header_background_filename": filename,
"light_red_header_background_media_type": media_type,
"light_red_header_background_version": uuid4().hex,
}
)
elif remove_light_red_header_background:
update.update(
{
"light_red_header_background_filename": None,
"light_red_header_background_media_type": None,
"light_red_header_background_version": None,
}
)
if nav_active_asset is not None:
data, suffix, media_type = nav_active_asset
filename = await asyncio.to_thread(
_write_asset, data, suffix, "light-red-active"
)
staged_paths.append(runtime_home() / "app-identity" / filename)
update.update(
{
"light_red_nav_active_filename": filename,
"light_red_nav_active_media_type": media_type,
"light_red_nav_active_version": uuid4().hex,
}
)
elif remove_light_red_nav_active:
update.update(
{
"light_red_nav_active_filename": None,
"light_red_nav_active_media_type": None,
"light_red_nav_active_version": None,
}
)
updated = current.app_identity.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"app_identity": updated}))
except Exception:
for staged_path in staged_paths:
await asyncio.to_thread(staged_path.unlink, missing_ok=True)
raise
previous_paths = (previous_logo, previous_header_background, previous_nav_active)
saved_paths = {
app_identity_logo_path(saved.app_identity),
app_identity_light_red_header_background_path(saved.app_identity),
app_identity_light_red_nav_active_path(saved.app_identity),
}
for previous_path in previous_paths:
if previous_path is not None and previous_path not in saved_paths:
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
return _response(saved.app_identity)
@router.delete("/app-identity", response_model=AppIdentityResponse)
async def delete_app_identity_settings(request: Request) -> AppIdentityResponse:
"""Clear the application identity and uploaded theme artwork. Admin only."""
await _require_admin(request)
current = load_system_settings()
previous_paths = (
app_identity_logo_path(current.app_identity),
app_identity_light_red_header_background_path(current.app_identity),
app_identity_light_red_nav_active_path(current.app_identity),
)
saved = save_system_settings(current.model_copy(update={"app_identity": AppIdentitySettings()}))
for previous_path in previous_paths:
if previous_path is not None:
await asyncio.to_thread(previous_path.unlink, missing_ok=True)
return _response(saved.app_identity)
@public_router.get("/logo")
async def get_app_identity_logo() -> FileResponse:
"""Serve the uploaded application logo. Missing logos stay on the static assets."""
settings = load_system_settings().app_identity
path = app_identity_logo_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传系统 Logo")
return FileResponse(
path,
media_type=settings.logo_media_type or "image/png",
content_disposition_type="inline",
headers=_public_asset_headers(settings.logo_media_type),
)
@public_router.get("/light-red-header-background")
async def get_light_red_header_background() -> FileResponse:
settings = load_system_settings().app_identity
path = app_identity_light_red_header_background_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传红色导航栏背景图")
return FileResponse(
path,
media_type=settings.light_red_header_background_media_type or "image/png",
content_disposition_type="inline",
headers=_public_asset_headers(
settings.light_red_header_background_media_type
),
)
@public_router.get("/light-red-nav-active")
async def get_light_red_nav_active() -> FileResponse:
settings = load_system_settings().app_identity
path = app_identity_light_red_nav_active_path(settings)
if path is None:
raise HTTPException(status_code=404, detail="未上传红色菜单选中样式图")
return FileResponse(
path,
media_type=settings.light_red_nav_active_media_type or "image/png",
content_disposition_type="inline",
headers=_public_asset_headers(settings.light_red_nav_active_media_type),
)