|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import hashlib |
| 4 | +import mimetypes |
| 5 | +from pathlib import Path |
| 6 | +from typing import Any, Dict, Literal |
| 7 | + |
| 8 | +from ...paths import ensure_home |
| 9 | +from ...util.fs import atomic_write_bytes |
| 10 | + |
| 11 | +BrandingAssetKind = Literal["logo_icon", "favicon"] |
| 12 | + |
| 13 | +_BRANDING_MAX_BYTES = 2 * 1024 * 1024 |
| 14 | +_DEFAULT_PRODUCT_NAME = "CCCC" |
| 15 | +_DEFAULT_LOGO_ICON_URL = "/ui/logo.svg" |
| 16 | +_DEFAULT_FAVICON_URL = "/ui/logo.svg" |
| 17 | + |
| 18 | +_ALLOWED_MIME_TYPES: dict[str, set[str]] = { |
| 19 | + "logo_icon": { |
| 20 | + "image/svg+xml", |
| 21 | + "image/png", |
| 22 | + "image/jpeg", |
| 23 | + "image/webp", |
| 24 | + "image/gif", |
| 25 | + "image/avif", |
| 26 | + "image/x-icon", |
| 27 | + "image/vnd.microsoft.icon", |
| 28 | + }, |
| 29 | + "favicon": { |
| 30 | + "image/svg+xml", |
| 31 | + "image/png", |
| 32 | + "image/x-icon", |
| 33 | + "image/vnd.microsoft.icon", |
| 34 | + }, |
| 35 | +} |
| 36 | + |
| 37 | +_EXTENSION_BY_MIME: dict[str, str] = { |
| 38 | + "image/svg+xml": ".svg", |
| 39 | + "image/png": ".png", |
| 40 | + "image/jpeg": ".jpg", |
| 41 | + "image/webp": ".webp", |
| 42 | + "image/gif": ".gif", |
| 43 | + "image/avif": ".avif", |
| 44 | + "image/x-icon": ".ico", |
| 45 | + "image/vnd.microsoft.icon": ".ico", |
| 46 | +} |
| 47 | + |
| 48 | + |
| 49 | +def normalize_branding_asset_kind(value: str) -> BrandingAssetKind: |
| 50 | + normalized = str(value or "").strip().lower() |
| 51 | + if normalized in {"logo_icon", "favicon"}: |
| 52 | + return normalized # type: ignore[return-value] |
| 53 | + raise ValueError("asset kind must be one of: logo_icon, favicon") |
| 54 | + |
| 55 | + |
| 56 | +def branding_asset_dir() -> Path: |
| 57 | + return ensure_home() / "state" / "web_branding" |
| 58 | + |
| 59 | + |
| 60 | +def _branding_asset_rel_path(filename: str) -> str: |
| 61 | + return str(Path("state") / "web_branding" / filename).replace("\\", "/") |
| 62 | + |
| 63 | + |
| 64 | +def resolve_branding_asset_path(rel_path: str) -> Path: |
| 65 | + normalized = str(rel_path or "").strip().replace("\\", "/") |
| 66 | + if not normalized: |
| 67 | + raise FileNotFoundError("branding asset path is empty") |
| 68 | + base = ensure_home().resolve() |
| 69 | + target = (base / Path(*Path(normalized).parts)).resolve() |
| 70 | + try: |
| 71 | + target.relative_to(base) |
| 72 | + except ValueError as exc: |
| 73 | + raise FileNotFoundError("branding asset path is outside CCCC_HOME") from exc |
| 74 | + return target |
| 75 | + |
| 76 | + |
| 77 | +def store_branding_asset(*, asset_kind: BrandingAssetKind, data: bytes, content_type: str, filename: str = "") -> Dict[str, Any]: |
| 78 | + mime_type = str(content_type or "").strip().lower() |
| 79 | + if not mime_type: |
| 80 | + guessed, _ = mimetypes.guess_type(str(filename or "").strip()) |
| 81 | + mime_type = str(guessed or "").strip().lower() |
| 82 | + if mime_type not in _ALLOWED_MIME_TYPES[asset_kind]: |
| 83 | + raise ValueError(f"unsupported {asset_kind} type: {mime_type or 'unknown'}") |
| 84 | + if len(data) > _BRANDING_MAX_BYTES: |
| 85 | + raise ValueError(f"{asset_kind} file too large") |
| 86 | + |
| 87 | + digest = hashlib.sha256(data).hexdigest() |
| 88 | + ext = _EXTENSION_BY_MIME.get(mime_type) or Path(str(filename or "").strip()).suffix.lower() or ".bin" |
| 89 | + stored_name = f"{asset_kind}_{digest[:16]}{ext}" |
| 90 | + abs_path = branding_asset_dir() / stored_name |
| 91 | + atomic_write_bytes(abs_path, data) |
| 92 | + return { |
| 93 | + "asset_kind": asset_kind, |
| 94 | + "mime_type": mime_type, |
| 95 | + "bytes": len(data), |
| 96 | + "sha256": digest, |
| 97 | + "rel_path": _branding_asset_rel_path(stored_name), |
| 98 | + "public_url": f"/api/v1/branding/assets/{asset_kind}?v={digest[:16]}", |
| 99 | + } |
| 100 | + |
| 101 | + |
| 102 | +def delete_branding_asset(rel_path: str) -> None: |
| 103 | + try: |
| 104 | + target = resolve_branding_asset_path(rel_path) |
| 105 | + if target.exists(): |
| 106 | + target.unlink() |
| 107 | + except Exception: |
| 108 | + pass |
| 109 | + |
| 110 | + |
| 111 | +def build_branding_payload(raw: Dict[str, Any]) -> Dict[str, Any]: |
| 112 | + product_name = str(raw.get("product_name") or "").strip() or _DEFAULT_PRODUCT_NAME |
| 113 | + logo_rel_path = str(raw.get("logo_icon_asset_path") or "").strip() |
| 114 | + favicon_rel_path = str(raw.get("favicon_asset_path") or "").strip() |
| 115 | + updated_at = str(raw.get("updated_at") or "").strip() or None |
| 116 | + |
| 117 | + logo_url = ( |
| 118 | + f"/api/v1/branding/assets/logo_icon?v={updated_at or 'default'}" |
| 119 | + if logo_rel_path |
| 120 | + else _DEFAULT_LOGO_ICON_URL |
| 121 | + ) |
| 122 | + favicon_url = ( |
| 123 | + f"/api/v1/branding/assets/favicon?v={updated_at or 'default'}" |
| 124 | + if favicon_rel_path |
| 125 | + else (logo_url if logo_rel_path else _DEFAULT_FAVICON_URL) |
| 126 | + ) |
| 127 | + return { |
| 128 | + "product_name": product_name, |
| 129 | + "logo_icon_url": logo_url, |
| 130 | + "favicon_url": favicon_url, |
| 131 | + "has_custom_logo_icon": bool(logo_rel_path), |
| 132 | + "has_custom_favicon": bool(favicon_rel_path), |
| 133 | + "updated_at": updated_at, |
| 134 | + } |
0 commit comments