391 lines
15 KiB
Python
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),
|
|
)
|