From 771be8babb87d14e3ea5f236738408febfc328fd Mon Sep 17 00:00:00 2001 From: chenjie Date: Thu, 2 Jul 2026 17:40:06 +0800 Subject: [PATCH 1/2] feat: support custom UI branding --- flocks/config/config.py | 50 +++ flocks/server/auth.py | 2 + flocks/server/routes/config.py | 396 ++++++++++++++++++- tests/config/test_config.py | 27 ++ tests/server/routes/test_remaining_routes.py | 90 +++++ webui/index.html | 2 +- webui/src/App.tsx | 15 +- webui/src/api/index.ts | 1 + webui/src/api/uiConfig.ts | 37 ++ webui/src/components/layout/Layout.test.tsx | 12 +- webui/src/components/layout/Layout.tsx | 9 +- webui/src/contexts/ProductNameContext.tsx | 143 +++++++ webui/src/locales/en-US/auth.json | 6 +- webui/src/locales/en-US/common.json | 2 +- webui/src/locales/en-US/device.json | 4 +- webui/src/locales/en-US/flockspro.json | 20 +- webui/src/locales/en-US/home.json | 2 +- webui/src/locales/en-US/nav.json | 22 +- webui/src/locales/zh-CN/auth.json | 6 +- webui/src/locales/zh-CN/common.json | 2 +- webui/src/locales/zh-CN/device.json | 4 +- webui/src/locales/zh-CN/flockspro.json | 20 +- webui/src/locales/zh-CN/home.json | 2 +- webui/src/locales/zh-CN/nav.json | 20 +- webui/src/pages/FlocksproUpgrade/index.tsx | 24 +- webui/src/pages/Home/index.tsx | 6 +- webui/src/pages/Hub/index.tsx | 14 +- webui/src/pages/Login/index.tsx | 8 +- webui/src/pages/Settings/index.test.tsx | 43 +- webui/src/pages/Settings/index.tsx | 173 +++++++- 30 files changed, 1060 insertions(+), 102 deletions(-) create mode 100644 webui/src/api/uiConfig.ts create mode 100644 webui/src/contexts/ProductNameContext.tsx diff --git a/flocks/config/config.py b/flocks/config/config.py index 387f9ce4b..9e5fc41fe 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -358,6 +358,55 @@ class FlocksProConfig(BaseModel): url: Optional[str] = None +class UIConfig(BaseModel): + """WebUI display preferences.""" + + model_config = {"populate_by_name": True} + + display_name: Optional[str] = Field( + None, + alias="displayName", + max_length=48, + description="Custom product display name used by visible WebUI branding.", + ) + favicon_path: Optional[str] = Field( + None, + alias="faviconPath", + max_length=256, + description="Relative path to a custom WebUI favicon stored in the user config directory.", + ) + + @field_validator("display_name", mode="before") + @classmethod + def normalize_display_name(cls, value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("displayName must be a string") + normalized = value.strip() + if not normalized: + return None + if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized): + raise ValueError("displayName must not contain control characters") + return normalized + + @field_validator("favicon_path", mode="before") + @classmethod + def normalize_favicon_path(cls, value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError("faviconPath must be a string") + normalized = value.strip().replace("\\", "/") + if not normalized: + return None + if normalized.startswith("/") or ".." in normalized.split("/"): + raise ValueError("faviconPath must be a safe relative path") + if any(ord(ch) < 32 or ord(ch) == 127 for ch in normalized): + raise ValueError("faviconPath must not contain control characters") + return normalized + + class ExperimentalConfig(BaseModel): """Experimental features configuration""" model_config = {"extra": "allow", "populate_by_name": True} @@ -619,6 +668,7 @@ class ConfigInfo(BaseModel): ) agent_logic: Optional[Literal["base", "rex"]] = Field(None, alias="agentLogic") flockspro: Optional[FlocksProConfig] = None + ui: Optional[UIConfig] = None compaction: Optional[CompactionConfig] = None tool_output: Optional[ToolOutputConfig] = Field( None, diff --git a/flocks/server/auth.py b/flocks/server/auth.py index 654f7d381..86da6a7ba 100644 --- a/flocks/server/auth.py +++ b/flocks/server/auth.py @@ -28,6 +28,8 @@ "/openapi.json", "/favicon.ico", "/api/health", + "/api/config/ui-display", + "/api/config/ui-favicon", "/api/auth/login", "/api/auth/bootstrap-status", "/api/auth/bootstrap-admin", diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 4c59c9ce2..8eede8fe2 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -16,11 +16,18 @@ } """ +import re +import xml.etree.ElementTree as ET +from pathlib import Path from typing import Dict, Any, Optional -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from flocks.config.config import Config, GlobalConfig, ConfigInfo as ConfigInfoModel +from fastapi import APIRouter, File, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from defusedxml import ElementTree as DefusedET +from defusedxml.common import DefusedXmlException + +from flocks.config.config import Config, GlobalConfig, ConfigInfo as ConfigInfoModel, UIConfig +from flocks.config.config_writer import ConfigWriter from flocks.provider.provider import Provider from flocks.utils.log import Log @@ -118,6 +125,387 @@ class ProviderDefaultsResponse(BaseModel): default: Dict[str, str] +class UIDisplayResponse(BaseModel): + """Public WebUI display-name response.""" + + display_name: str = Field(alias="displayName") + configured_display_name: Optional[str] = Field(None, alias="configuredDisplayName") + favicon_url: Optional[str] = Field(None, alias="faviconUrl") + + +class UIConfigUpdateRequest(BaseModel): + """Update request for visible WebUI display preferences.""" + + model_config = {"populate_by_name": True} + + display_name: Optional[str] = Field(None, alias="displayName") + + +DEFAULT_UI_DISPLAY_NAME = "Flocks" +FAVICON_MAX_BYTES = 512 * 1024 +FAVICON_RELATIVE_DIR = "assets" +FAVICON_BASENAME = "favicon" +FAVICON_MEDIA_TYPES = { + ".ico": "image/x-icon", + ".png": "image/png", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +} +SVG_NAMESPACE = "http://www.w3.org/2000/svg" +XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" +SVG_ALLOWED_TAGS = { + "circle", + "clippath", + "defs", + "desc", + "ellipse", + "g", + "line", + "lineargradient", + "mask", + "path", + "pattern", + "polygon", + "polyline", + "radialgradient", + "rect", + "stop", + "svg", + "symbol", + "text", + "textpath", + "title", + "tspan", + "use", +} +SVG_ALLOWED_ATTRIBUTES = { + "aria-label", + "baseprofile", + "class", + "clip-path", + "clip-rule", + "color", + "cx", + "cy", + "d", + "direction", + "display", + "dominant-baseline", + "dx", + "dy", + "fill", + "fill-opacity", + "fill-rule", + "font-family", + "font-size", + "font-style", + "font-weight", + "fr", + "fx", + "fy", + "gradienttransform", + "gradientunits", + "height", + "href", + "id", + "letter-spacing", + "mask", + "offset", + "opacity", + "points", + "preserveaspectratio", + "r", + "role", + "rotate", + "rx", + "ry", + "spreadmethod", + "stop-color", + "stop-opacity", + "stroke", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-opacity", + "stroke-width", + "text-anchor", + "transform", + "version", + "viewbox", + "visibility", + "width", + "x", + "x1", + "x2", + "y", + "y1", + "y2", +} +SVG_UNSAFE_VALUE_PATTERN = re.compile( + r"(?:javascript|vbscript|data|file|https?):|//|@import|expression\s*\(", + re.IGNORECASE, +) +SVG_URL_PATTERN = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE) + + +def _effective_display_name(config: ConfigInfoModel) -> tuple[str, Optional[str]]: + configured = config.ui.display_name if config.ui else None + return configured or DEFAULT_UI_DISPLAY_NAME, configured + + +def _ui_assets_dir() -> Path: + return Config.get_config_path() / FAVICON_RELATIVE_DIR + + +def _favicon_relative_path(ext: str) -> str: + return f"{FAVICON_RELATIVE_DIR}/{FAVICON_BASENAME}{ext}" + + +def _safe_config_relative_path(relative_path: str) -> Optional[Path]: + try: + config_dir = Config.get_config_path().resolve() + target = (config_dir / relative_path).resolve() + if target == config_dir or config_dir not in target.parents: + return None + return target + except Exception: + return None + + +def _configured_favicon_path(config: ConfigInfoModel) -> Optional[Path]: + relative_path = config.ui.favicon_path if config.ui else None + if not relative_path: + return None + target = _safe_config_relative_path(relative_path) + if not target or not target.is_file(): + return None + return target + + +def _favicon_media_type(path: Path) -> str: + return FAVICON_MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream") + + +def _favicon_url(config: ConfigInfoModel) -> Optional[str]: + path = _configured_favicon_path(config) + if not path: + return None + try: + version = int(path.stat().st_mtime) + except OSError: + version = 0 + return f"/api/config/ui-favicon?v={version}" + + +def _xml_local_name(name: str) -> str: + if name.startswith("{") and "}" in name: + return name.rsplit("}", 1)[1] + if ":" in name: + return name.rsplit(":", 1)[1] + return name + + +def _xml_namespace(name: str) -> Optional[str]: + if name.startswith("{") and "}" in name: + return name[1:].split("}", 1)[0] + return None + + +def _validate_svg_value(attribute: str, value: str) -> None: + stripped = value.strip() + if SVG_UNSAFE_VALUE_PATTERN.search(stripped): + raise HTTPException(status_code=400, detail=f"Unsafe SVG attribute value: {attribute}") + + if attribute == "href" and stripped and not stripped.startswith("#"): + raise HTTPException(status_code=400, detail="SVG href values must reference local fragments only") + + for match in SVG_URL_PATTERN.finditer(stripped): + target = match.group(2).strip() + if not target.startswith("#"): + raise HTTPException(status_code=400, detail="SVG url() values must reference local fragments only") + + +def _validate_svg_favicon(content: bytes) -> bytes: + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as e: + raise HTTPException(status_code=400, detail="SVG favicon must be UTF-8 encoded") from e + + lowered = text.lower() + if "") + + for element in root.iter(): + tag_name = _xml_local_name(element.tag).lower() + if _xml_namespace(element.tag) not in (None, SVG_NAMESPACE): + raise HTTPException(status_code=400, detail=f"Unsupported SVG namespace for <{tag_name}>") + if tag_name not in SVG_ALLOWED_TAGS: + raise HTTPException(status_code=400, detail=f"Unsupported SVG element: <{tag_name}>") + + for raw_attribute, value in element.attrib.items(): + attribute_namespace = _xml_namespace(raw_attribute) + attribute_name = _xml_local_name(raw_attribute).lower() + if attribute_namespace not in (None, XLINK_NAMESPACE): + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_namespace == XLINK_NAMESPACE and attribute_name != "href": + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_name.startswith("on") or attribute_name in {"style", "src"}: + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + if attribute_name not in SVG_ALLOWED_ATTRIBUTES: + raise HTTPException(status_code=400, detail=f"Unsupported SVG attribute: {attribute_name}") + _validate_svg_value(attribute_name, value) + + return content + + +def _get_or_create_ui_section(data: Dict[str, Any]) -> Dict[str, Any]: + ui_section = data.get("ui") + if not isinstance(ui_section, dict): + ui_section = {} + return ui_section + + +def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> None: + if ui_section: + data["ui"] = ui_section + else: + data.pop("ui", None) + ConfigWriter._write_raw(data) + + +@router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name") +async def get_ui_display() -> UIDisplayResponse: + """Return only the effective WebUI display name for public screens.""" + try: + complete_config = await Config.get() + display_name, configured_display_name = _effective_display_name(complete_config) + return UIDisplayResponse( + displayName=display_name, + configuredDisplayName=configured_display_name, + faviconUrl=_favicon_url(complete_config), + ) + except Exception as e: + log.error("config.ui_display.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/ui", response_model=UIDisplayResponse, summary="Update UI display preferences") +async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: + """Update visible WebUI display preferences.""" + try: + ui_config = UIConfig.model_validate({"displayName": request.display_name}) + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + + if ui_config.display_name: + ui_section["displayName"] = ui_config.display_name + else: + ui_section.pop("displayName", None) + + _persist_ui_section(data, ui_section) + return await get_ui_display() + except Exception as e: + log.error("config.ui.update.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/ui-favicon", summary="Get custom UI favicon") +async def get_ui_favicon() -> FileResponse: + """Return the uploaded favicon, if one is configured.""" + try: + complete_config = await Config.get() + path = _configured_favicon_path(complete_config) + if not path: + raise HTTPException(status_code=404, detail="custom favicon is not configured") + return FileResponse(path, media_type=_favicon_media_type(path)) + except HTTPException: + raise + except Exception as e: + log.error("config.ui_favicon.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/ui/favicon", response_model=UIDisplayResponse, summary="Upload UI favicon") +async def upload_ui_favicon(file: UploadFile = File(...)) -> UIDisplayResponse: + """Upload a custom favicon for visible WebUI branding.""" + filename = Path(file.filename or "").name + ext = Path(filename).suffix.lower() + if ext not in FAVICON_MEDIA_TYPES: + raise HTTPException(status_code=400, detail="Unsupported favicon type. Use .ico, .png, .svg, .jpg, or .webp") + + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65536) + if not chunk: + break + total += len(chunk) + if total > FAVICON_MAX_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="Favicon file is too large. Maximum size is 512 KB.", + ) + chunks.append(chunk) + + if total == 0: + raise HTTPException(status_code=400, detail="Favicon file is empty") + + content = b"".join(chunks) + if ext == ".svg": + content = _validate_svg_favicon(content) + + assets_dir = _ui_assets_dir() + assets_dir.mkdir(parents=True, exist_ok=True) + for old in assets_dir.glob(f"{FAVICON_BASENAME}.*"): + if old.is_file(): + try: + old.unlink() + except OSError: + pass + + target = assets_dir / f"{FAVICON_BASENAME}{ext}" + target.write_bytes(content) + + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + ui_section["faviconPath"] = _favicon_relative_path(ext) + _persist_ui_section(data, ui_section) + + log.info("config.ui_favicon.uploaded", {"path": ui_section["faviconPath"], "size": total}) + return await get_ui_display() + + +@router.delete("/ui/favicon", response_model=UIDisplayResponse, summary="Reset UI favicon") +async def reset_ui_favicon() -> UIDisplayResponse: + """Remove the uploaded favicon and fall back to the default bundled favicon.""" + assets_dir = _ui_assets_dir() + if assets_dir.exists(): + for old in assets_dir.glob(f"{FAVICON_BASENAME}.*"): + if old.is_file(): + try: + old.unlink() + except OSError: + pass + + data = ConfigWriter._read_raw() + ui_section = _get_or_create_ui_section(data) + ui_section.pop("faviconPath", None) + _persist_ui_section(data, ui_section) + return await get_ui_display() + + @router.get("", summary="Get configuration") async def get_config() -> Dict[str, Any]: """ diff --git a/tests/config/test_config.py b/tests/config/test_config.py index ed21568f6..eb99c29e1 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -111,6 +111,33 @@ def test_legacy_todo_tool_flags_migrate_to_todo_permission(): assert worker.permission["bash"] == PermissionAction.ALLOW +def test_ui_config_normalizes_and_dumps_aliases(): + config = ConfigInfo.model_validate({ + "ui": { + "displayName": " Acme SOC ", + "faviconPath": "assets/favicon.png", + } + }) + + assert config.ui is not None + assert config.ui.display_name == "Acme SOC" + assert config.ui.favicon_path == "assets/favicon.png" + assert config.model_dump(by_alias=True, exclude_none=True)["ui"] == { + "displayName": "Acme SOC", + "faviconPath": "assets/favicon.png", + } + + +def test_ui_display_name_rejects_control_characters(): + with pytest.raises(ValueError): + ConfigInfo.model_validate({"ui": {"displayName": "Acme\nSOC"}}) + + +def test_ui_favicon_path_rejects_unsafe_paths(): + with pytest.raises(ValueError): + ConfigInfo.model_validate({"ui": {"faviconPath": "../favicon.svg"}}) + + @pytest.mark.asyncio async def test_config_file_loading(tmp_path): """Test loading configuration from file""" diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index f36aa98b3..cc637a21a 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -508,6 +508,96 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) + @pytest.mark.asyncio + async def test_ui_display_defaults_and_updates( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + ): + """UI display-name endpoints expose only the visible product name.""" + from flocks.config.config import Config + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + Config._global_config = None + Config._cached_config = None + + resp = await client.get("/api/config/ui-display") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "displayName": "Flocks", + "configuredDisplayName": None, + "faviconUrl": None, + } + + resp = await client.patch("/api/config/ui", json={"displayName": " Acme SOC "}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "displayName": "Acme SOC", + "configuredDisplayName": "Acme SOC", + "faviconUrl": None, + } + + resp = await client.get("/api/config/ui-display") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["displayName"] == "Acme SOC" + + svg = b'' + resp = await client.post( + "/api/config/ui/favicon", + files={"file": ("favicon.svg", svg, "image/svg+xml")}, + ) + assert resp.status_code == status.HTTP_200_OK, resp.text + data = resp.json() + assert data["displayName"] == "Acme SOC" + assert data["faviconUrl"].startswith("/api/config/ui-favicon?v=") + + favicon_resp = await client.get(data["faviconUrl"]) + assert favicon_resp.status_code == status.HTTP_200_OK + assert favicon_resp.content == svg + assert (tmp_path / "config" / "assets" / "favicon.svg").is_file() + + resp = await client.delete("/api/config/ui/favicon") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["faviconUrl"] is None + + @pytest.mark.parametrize( + "svg", + [ + b'', + b'', + b'', + b'', + ''.encode("utf-16"), + ], + ) + @pytest.mark.asyncio + async def test_ui_favicon_rejects_unsafe_svg( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + svg: bytes, + ): + """SVG favicons are accepted only when they fit the safe favicon subset.""" + from flocks.config.config import Config + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + Config._global_config = None + Config._cached_config = None + + resp = await client.post( + "/api/config/ui/favicon", + files={"file": ("favicon.svg", svg, "image/svg+xml")}, + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert not (tmp_path / "config" / "assets" / "favicon.svg").exists() + + display_resp = await client.get("/api/config/ui-display") + assert display_resp.status_code == status.HTTP_200_OK + assert display_resp.json()["faviconUrl"] is None + # =========================================================================== # Permission routes diff --git a/webui/index.html b/webui/index.html index c5a921e2d..a62686330 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ - Flocks - AI Native SecOps Platform + Console