diff --git a/.env.example b/.env.example index 7f71da8..16c6036 100644 --- a/.env.example +++ b/.env.example @@ -38,11 +38,8 @@ WEBAPP_ENABLED=True # WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title -WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) -WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used -WEBAPP_LOGO_EMOJI="πŸ«₯" # Emoji logo fallback shown in the header and login screen WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index 4f1e93a..de1ed9f 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -56,4 +56,5 @@ def setup_admin_routes(app: web.Application) -> None: router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_get("/api/admin/themes", admin_themes_get_route) router.add_put("/api/admin/themes", admin_themes_save_route) + router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/bot/app/web/admin_api_impl/settings.py index 787778f..3746394 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/bot/app/web/admin_api_impl/settings.py @@ -70,5 +70,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} + if ( + "WEBAPP_LOGO_URL" in updates + or "WEBAPP_LOGO_URL" in deletes + or "WEBAPP_LOGO_USE_EMOJI" in updates + or "WEBAPP_LOGO_USE_EMOJI" in deletes + ): + request.app["webapp_logo_cache"] = None return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py index 983618d..f1b4d79 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/bot/app/web/admin_api_impl/themes.py @@ -1,6 +1,14 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +import asyncio +import hashlib +import ipaddress +import re +import socket + +from aiohttp import ClientSession, ClientTimeout + from config.webapp_themes_config import ( WebappThemesConfig, ensure_webapp_core_themes, @@ -9,6 +17,167 @@ from config.webapp_themes_config import ( ) +WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def _detect_logo_extension( + body: bytes, content_type: str = "", filename: str = "" +) -> Optional[str]: + content_type = (content_type or "").split(";", 1)[0].strip().lower() + suffix = Path(filename or "").suffix.lower() + if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"): + return ".jpg" + if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if content_type == "image/webp" or ( + len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP" + ): + return ".webp" + if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg": + head = body[:512].lstrip().lower() + if head.startswith(b" str: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be a non-empty image up to 2 MiB") + ext = _detect_logo_extension(body, content_type, filename) + if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES: + raise ValueError("unsupported image type") + digest = hashlib.sha256(body).hexdigest()[:16] + safe_name = f"logo-{digest}{ext}" + WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True) + (WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body) + return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" + + +async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: + reader = await request.multipart() + async for part in reader: + if part.name != "file": + continue + body = bytearray() + while True: + chunk = await part.read_chunk(size=64 * 1024) + if not chunk: + break + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), part.headers.get("Content-Type", ""), part.filename or "" + raise ValueError("file field is required") + + +async def _hostname_resolves_to_public_address(hostname: str) -> bool: + if not hostname: + return False + try: + ip_obj = ipaddress.ip_address(hostname) + return not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ) + except ValueError: + pass + + loop = asyncio.get_running_loop() + try: + resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except Exception: + return False + + found_public_ip = False + for entry in resolved: + sockaddr = entry[4] + candidate = sockaddr[0] if sockaddr else "" + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ): + return False + found_public_ip = True + return found_public_ip + + +async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("only https image URLs are supported") + if not await _hostname_resolves_to_public_address(parsed.hostname): + raise ValueError("logo URL must resolve to a public address") + + timeout = ClientTimeout(total=5) + async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session: + async with session.get( + url, + allow_redirects=False, + headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, + ) as response: + if response.status != 200: + raise ValueError(f"logo URL returned HTTP {response.status}") + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + if content_type and not content_type.startswith("image/"): + raise ValueError("logo URL returned non-image content") + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), content_type, Path(parsed.path).name + + +async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + logo_url = _write_uploaded_logo(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_logo", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp logo") + return _error(500, "write_failed", str(exc)) + + return _ok({"logo_url": logo_url}) + + async def admin_themes_get_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 1b4b578..031fbc5 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной Ρ†Π²Π΅Ρ‚", placeholder="#00fe7a" ), + SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ эмодТи-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏΠ°"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Π­ΠΌΠΎΠ΄ΠΆΠΈ-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ", placeholder="πŸ«₯"), SettingField( diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index ec51a5c..2467319 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -87,6 +87,7 @@ ...(MOCK ? MOCK.config : {}), ...(injectedConfig || {}), }; + const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim(); const I18N = injectedI18n || {}; let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; @@ -246,7 +247,7 @@ $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUrl, + logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, emoji: brandEmoji, emojiFont: brandEmojiFont, }); @@ -308,7 +309,11 @@ $: user = data?.user || {}; $: themesCatalog = data?.themes_catalog || CFG.themesCatalog || { default_theme: "dark", themes: [] }; - $: resolvedThemeKey = resolveEffectiveThemeKey(themesCatalog); + $: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin)); + $: previewThemeEntry = previewThemeAllowed + ? findThemeEntry(themesCatalog, themePreviewKey) + : null; + $: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog); $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); $: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); $: effectiveThemeEntry = @@ -870,13 +875,32 @@ ); } - async function handleAdminPersistedSaved() { + function adminPayloadHasLogoChange(options = {}) { + const keys = new Set([ + ...Object.keys(options.updates || {}), + ...(Array.isArray(options.deletes) ? options.deletes : []), + ]); + return [ + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + ].some((key) => keys.has(key)); + } + + async function handleAdminPersistedSaved(options = {}) { invalidateWebappTariffOptionCaches(billingStore); try { await loadData(); } catch { // Admin save already succeeded; a later full refresh will pick up new settings or catalog. } + const shouldReloadFrontend = + options?.reloadFrontend === true || + (!options?.deferFrontendReload && adminPayloadHasLogoChange(options)); + if (shouldReloadFrontend && typeof window !== "undefined") { + window.location.reload(); + } } function selectTariff(tariff) { diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 91b7b46..6bc0291 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -35,7 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; - import ThemesSection from "./sections/ThemesSection.svelte"; + import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; @@ -115,7 +115,7 @@ label: at("nav_system", {}, "БистСма"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Π’Π°Ρ€ΠΈΡ„Ρ‹"), icon: Coins }, - { id: "themes", label: at("nav_themes", {}, "Π’Π΅ΠΌΡ‹"), icon: Paintbrush }, + { id: "appearance", label: at("nav_appearance", {}, "Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -158,9 +158,9 @@ title: at("section_tariffs_title", {}, "Π’Π°Ρ€ΠΈΡ„Ρ‹"), subtitle: at("section_tariffs_subtitle", {}, "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ ΠΏΡ€ΠΎΠ΄Π°ΠΆ, ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Ρ‹, ΠΏΠ°ΠΊΠ΅Ρ‚Ρ‹ ΠΈ Π»ΠΈΠΌΠΈΡ‚Ρ‹"), }, - themes: { - title: at("section_themes_title", {}, "Π’Π΅ΠΌΡ‹ Web App"), - subtitle: at("section_themes_subtitle", {}, "Π¦Π²Π΅Ρ‚Π°, ΡˆΡ€ΠΈΡ„Ρ‚Ρ‹ ΠΈ Ρ‚Π΅ΠΌΡ‹ оформлСния Mini App"), + appearance: { + title: at("section_appearance_title", {}, "Π’Π½Π΅ΡˆΠ½ΠΈΠΉ Π²ΠΈΠ΄"), + subtitle: at("section_appearance_subtitle", {}, "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ, Ρ‚Π΅ΠΌΡ‹ ΠΈ Π°ΠΊΡ†Π΅Π½Ρ‚Π½Ρ‹Π΅ Ρ†Π²Π΅Ρ‚Π° Mini App"), }, settings: { title: at("section_settings_title", {}, "Настройки прилоТСния"), @@ -646,8 +646,8 @@ {/if} - {#if active === "themes"} - + {#if active === "appearance"} + {/if} {#if active === "settings"} diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte new file mode 100644 index 0000000..758592e --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -0,0 +1,743 @@ + + +{#if themesLoading || settingsLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+
+

{at("appearance_brand_title", {}, "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ")}

+ {at( + "appearance_brand_sub", + {}, + "Π€Π°ΠΉΠ», ссылка ΠΈΠ»ΠΈ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½Π½Ρ‹ΠΉ emoji-Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ" + )} +
+
+ {#if dirtyCount} + + {at("settings_dirty_count", { count: dirtyCount }, `ИзмСнСний: ${dirtyCount}`)} + + {/if} + + + {settingsSaving || themesSaving + ? at("btn_saving", {}, "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅...") + : at("btn_save", {}, "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")} + +
+
+
+
+ {#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed} + { + logoPreviewFailed = true; + }} + /> + {:else if useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + logoFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_logo_upload_file", {}, "Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»")} + +
+ + + {at("appearance_logo_upload_url", {}, "По ссылкС")} + +
+
+ +
+ +
+ + setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)} + /> + setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)} + /> +
+
+
+
+
+ +
+
+
+

{at("appearance_themes_title", {}, "Π’Π΅ΠΌΡ‹")}

+ {at( + "appearance_themes_sub", + {}, + "Π“Π»ΠΎΠ±Π°Π»ΡŒΠ½Π°Ρ Ρ‚Π΅ΠΌΠ°, accent color ΠΈ прСдпросмотр" + )} +
+
+ + + {at("btn_refresh", {}, "ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ")} + + + + {at("btn_save", {}, "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ пуст. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ΠΏΠ°ΠΏΠΊΡƒ Ρ‚Π΅ΠΌΡ‹ Π² data/themes ΠΈ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚Π΅ список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleThemeKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "ВСкущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + +
+ previewTheme(event, theme)} + > + + {at("appearance_preview_theme", {}, "ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€")} + +
+ +
+ {/each} +
+ {/if} +
+
+
+{/if} + + diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte index 893157b..a26d1a5 100644 --- a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte @@ -17,28 +17,32 @@ const settingsStore = getContext("settingsStore"); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); + $: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance"); let settingsOpenSections = []; let settingsOpenSubsections = {}; let revealedSecrets = new Set(); $: settingsAllOpen = - settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; + visibleSettingsSections.length > 0 && + settingsOpenSections.length === visibleSettingsSections.length; onMount(() => { settingsStore.loadSettings().then(() => { if ($settingsStore.settingsSections.length) { - const ids = $settingsStore.settingsSections.map((s) => s.id); + const ids = $settingsStore.settingsSections + .filter((s) => s.id !== "appearance") + .map((s) => s.id); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice(); } }); }); function toggleAllSections() { - if (settingsOpenSections.length === settingsSections.length) { + if (settingsOpenSections.length === visibleSettingsSections.length) { settingsOpenSections = []; } else { - settingsOpenSections = settingsSections.map((s) => s.id); + settingsOpenSections = visibleSettingsSections.map((s) => s.id); } } @@ -229,7 +233,7 @@ {/snippet} -{#if settingsLoading || !settingsSections.length} +{#if settingsLoading || !visibleSettingsSections.length} {settingsLoading ? at("loading", {}, "Загрузка…") @@ -265,7 +269,7 @@ - {#each settingsSections as section} + {#each visibleSettingsSections as section} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} diff --git a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte deleted file mode 100644 index e10a2a8..0000000 --- a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - -{#if themesLoading} - {at("loading", {}, "Загрузка…")} -{:else} -
-
-
-

{at("themes_catalog_title", {}, "Π’Π΅ΠΌΡ‹ Web App")}

- {at( - "themes_catalog_sub", - {}, - "ВСкущая Ρ‚Π΅ΠΌΠ° выбираСтся ΠΊΠ°Ρ€Ρ‚ΠΎΡ‡ΠΊΠΎΠΉ; внСшний Π²ΠΈΠ΄ рСдактируСтся Ρ„Π°ΠΉΠ»Π°ΠΌΠΈ Π² ΠΏΠ°ΠΏΠΊΠ΅ Ρ‚Π΅ΠΌΡ‹" - )} -
-
- - - {at("btn_refresh", {}, "ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ")} - - - - {at("btn_save", {}, "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ")} - -
-
-
- {#if !themesCatalog.themes.length} - - {at( - "themes_catalog_empty", - {}, - "ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ пуст. Π”ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ ΠΏΠ°ΠΏΠΊΡƒ Ρ‚Π΅ΠΌΡ‹ Π² data/themes ΠΈ ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚Π΅ список." - )} - - {:else} -
- {#each themesCatalog.themes as theme (theme.key)} - {@const isCurrent = theme.key === activeKey} -
selectTheme(theme, event)} - onkeydown={(event) => handleCardKeydown(event, theme)} - > - - - {themeTitle(theme)} - {#if isCurrent} - {at("status_current", {}, "ВСкущая")} - {/if} - - {theme.key} - - - - {themeDescription(theme)} - - - - -
- {/each} -
- {/if} -
-
-{/if} - - diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js index 448d558..e74c3e1 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js @@ -38,13 +38,30 @@ export function createSettingsStore({ api, onToast, at }) { }); } + function setFieldValue(key, value) { + state.update((s) => { + const nextDirty = { ...s.settingsDirty }; + delete nextDirty[key]; + return { + ...s, + settingsDirty: nextDirty, + settingsSections: (s.settingsSections || []).map((section) => ({ + ...section, + fields: (section.fields || []).map((field) => + field.key === key ? { ...field, value, overridden: true } : field + ), + })), + }; + }); + } + async function saveSettings(onSettingsSaved) { let dirty = {}; state.update((s) => { dirty = s.settingsDirty; return s; }); - if (!Object.keys(dirty).length) return; + if (!Object.keys(dirty).length) return true; state.update((s) => ({ ...s, settingsSaving: true })); try { @@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) { state.update((s) => ({ ...s, settingsDirty: {} })); if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); await loadSettings(); + return true; } else if (res?.errors) { const summary = Object.entries(res.errors) .map(([k, v]) => `${k}: ${v}`) @@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) { } else { onToast(res?.error || "Ошибка"); } + return false; } finally { state.update((s) => ({ ...s, settingsSaving: false })); } @@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) { loadSettings, markDirty, clearDirty, + setFieldValue, resetField, saveSettings, }; diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js index f0f63d8..f1db26e 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -58,6 +58,67 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { } } + async function uploadLogoFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/logo", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π³ΠΎ." + ) + ); + return data.logo_url || ""; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/logo", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Π›ΠΎΠ³ΠΎΡ‚ΠΈΠΏ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚Π΅ измСнСния, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π΅Π³ΠΎ." + ) + ); + return data.logo_url || ""; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + async function setCurrentTheme(key) { let changed = false; state.update((s) => ({ @@ -102,12 +163,35 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { })); } + function setThemeAccent(key, accent) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + accent: String(accent || "").trim() || null, + }, + } + : theme + ), + }, + })); + } + return { subscribe: state.subscribe, loadThemes, saveThemes, setCurrentTheme, + setThemeAccent, togglePrimaryAccent, toggleAdminUse, + uploadLogoFile, + uploadLogoUrl, }; } diff --git a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte index 1cdec74..cabedee 100644 --- a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte +++ b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte @@ -33,6 +33,7 @@ export let emojiFont = ""; export let size = "sm"; export let animate = false; + export let fallbackEmoji = true; let className = ""; export { className as class }; @@ -206,7 +207,7 @@ clearLogoLoadTimeout(); }} /> - {:else if useAnimatedEmoji} + {:else if fallbackEmoji && useAnimatedEmoji} - {:else} + {:else if fallbackEmoji} ({}), } = {}) { + const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData; + async function api(path, options = {}) { if (mockApi) return mockApi(path, options, getMockContext()); @@ -18,7 +20,9 @@ export function createApiClient({ if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { headers["X-CSRF-Token"] = csrf; } - if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) { + headers["Content-Type"] = "application/json"; + } const response = await fetch(`${apiBase}${path}`, { ...options, diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index 5e10534..8c94f0b 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -2,6 +2,7 @@ import asyncio import base64 import hashlib +import html import hmac import io import ipaddress @@ -17,7 +18,7 @@ from collections import deque from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice @@ -65,6 +66,8 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio ASSET_DIR = TEMPLATE_PATH.parent WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index b27caa9..198ba08 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -5,6 +5,7 @@ from config.webapp_themes_config import ( default_webapp_theme_asset_file, default_webapp_theme_css_files, ensure_default_webapp_theme_descriptor_files, + public_theme_payload, public_themes_catalog_payload, ) @@ -123,6 +124,9 @@ async def theme_asset_route(request: web.Request) -> web.Response: def _resolve_webapp_logo_url(settings: Settings) -> str: + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return "" + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: return "" @@ -173,6 +177,8 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") @@ -206,6 +212,41 @@ async def webapp_logo_route(request: web.Request) -> web.Response: return response +async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + raise web.HTTPNotFound(text="webapp_logo_not_found") + + root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() + path = (root / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_logo_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + async def webapp_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() @@ -235,6 +276,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): return @@ -258,6 +301,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": return @@ -547,7 +592,7 @@ async def _security_headers_middleware(request: web.Request, handler): "frame-ancestors https://web.telegram.org https://t.me; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " + "img-src 'self' data: blob: https:; " "connect-src 'self' https://oauth.telegram.org; " "object-src 'none'; " "base-uri 'self'; " @@ -741,16 +786,24 @@ async def index_route(request: web.Request) -> web.Response: html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + initial_theme = _initial_theme_for_request(request, themes_catalog) + preview_key = str(request.query.get("theme_preview") or "").strip() + preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None + if preview_theme is None or not preview_theme.enabled: + preview_key = "" config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "themesCatalog": public_themes_catalog_payload( themes_catalog, - settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + primary_color, enabled_only=True, ), "themesDir": settings.WEBAPP_THEMES_DIR, + "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], + "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "apiBase": "/api", @@ -769,6 +822,9 @@ async def index_route(request: web.Request) -> web.Response: "appRepositoryUrl": APP_REPOSITORY_URL, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) + initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) + if initial_theme_markup: + html = html.replace("", f"{initial_theme_markup}\n", 1) i18n_instance: Optional[object] = request.app.get("i18n") i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} nonce = request.get("csp_nonce", "") @@ -793,7 +849,11 @@ async def index_route(request: web.Request) -> web.Response: f'', ) brand_asset_url = cached["logo_url"] - if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": + if ( + not brand_asset_url + and settings.WEBAPP_LOGO_USE_EMOJI + and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated" + ): brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( @@ -843,6 +903,95 @@ def _resolve_webapp_js_asset_name() -> str: return "subscription_webapp.js" +_INITIAL_THEME_TOKEN_CSS_MAP = { + "accent": "--accent", + "bg": "--bg", + "panel": "--panel", + "panel_2": "--panel-2", + "panel_3": "--panel-3", + "border": "--border", + "border_strong": "--border-strong", + "text": "--text", + "muted": "--muted", + "dim": "--dim", + "danger": "--danger", + "blue": "--blue", + "radius": "--radius", + "font_sans": "--font-sans", + "font_logo": "--font-logo", + "font_mono": "--font-mono", + "admin_bg": "--admin-bg", + "admin_surface": "--admin-surface", + "admin_surface_2": "--admin-surface-2", + "admin_elev": "--admin-elev", + "admin_border": "--admin-border", + "admin_border_strong": "--admin-border-strong", + "admin_text": "--admin-text", + "admin_muted": "--admin-muted", + "admin_dim": "--admin-dim", +} + + +def _theme_css_href_for_html(theme: Any) -> str: + css_file = str(getattr(theme, "css_file", "") or "").strip() + key = str(getattr(theme, "key", "") or "").strip() + if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return "" + parts = [part for part in css_file.replace("\\", "/").split("/") if part] + if any(part in {".", ".."} for part in parts): + return "" + themed_path = "/".join([key, *parts]) + encoded = "/".join(quote(part, safe="") for part in themed_path.split("/")) + return f"/webapp-theme-css/{encoded}" if encoded else "" + + +def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: + preview_key = str(request.query.get("theme_preview") or "").strip() + if preview_key: + preview_theme = catalog.theme_by_key(preview_key) + if preview_theme is not None and preview_theme.enabled: + return preview_theme + + theme = catalog.theme_by_key(catalog.default_theme) + if theme is not None: + return theme + return catalog.enabled_themes()[0] if catalog.enabled_themes() else None + + +def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str: + if theme is None: + return "" + + payload = public_theme_payload(theme, primary_color) + tokens = payload.get("tokens") if isinstance(payload, dict) else {} + tokens = tokens if isinstance(tokens, dict) else {} + declarations = [] + for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): + value = str(tokens.get(token_key) or "").strip() + if value: + declarations.append(f"{css_name}:{value}") + + scheme = "light" if tokens.get("color_scheme") == "light" else "dark" + bg = str(tokens.get("bg") or "").strip() + css_rules = [f"html{{color-scheme:{scheme};}}"] + if bg: + css_rules.append(f"body{{background-color:{bg};}}") + if declarations: + css_rules.append(f".app-shell{{{';'.join(declarations)}}}") + + nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) + style_tag = ( + f'" + ) + href = _theme_css_href_for_html(theme) + if not href: + return style_tag + return ( + f'\n' + style_tag + ) + + def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index d6a5862..478a3b5 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -9,12 +9,22 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) app.router.add_get("/admin", index_route) - app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get( + ( + "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" + "appearance|settings}" + ), + index_route, + ) app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) + app.router.add_get( + rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_uploaded_logo_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index bca9a36..a17ac85 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -1,7 +1,7 @@ """Branded HTML email templates that mirror the subscription Mini App look. The web app uses a dark theme with a configurable accent colour -(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same +admin-configured accent colour and logo. The same accent + logo are reused here so emails feel like part of the product. All copy goes through the shared `JsonI18n` instance so translations live in ``locales/.json`` next to the rest of the bot strings. @@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: - """Email recipients can't reach the in-app /webapp-logo proxy, so the - raw https URL from the env is used directly. Anything else is dropped.""" + """Email recipients can't reach the in-app /webapp-logo proxy, so only a + stored public https URL can be used directly. Anything else is dropped.""" + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None diff --git a/config/settings.py b/config/settings.py index be1078a..13f6b24 100644 --- a/config/settings.py +++ b/config/settings.py @@ -92,6 +92,7 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] + logo_use_emoji: bool logo_emoji: str logo_emoji_font: str session_ttl_seconds: int @@ -369,6 +370,7 @@ class Settings(BaseSettings): ), ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) WEBAPP_LOGO_EMOJI: str = Field(default="πŸ«₯") WEBAPP_LOGO_EMOJI_FONT: str = Field( default="system", @@ -545,6 +547,7 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, + logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, @@ -836,6 +839,31 @@ class Settings(BaseSettings): theme_dir=self.WEBAPP_THEMES_DIR, ) + @field_validator("WEBAPP_PRIMARY_COLOR", mode="before") + @classmethod + def ignore_deprecated_webapp_primary_color_env(cls, _value): + return "#00fe7a" + + @field_validator("WEBAPP_LOGO_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): + return False + + @field_validator("WEBAPP_LOGO_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_env(cls, _value): + return "πŸ«₯" + + @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): + return "system" + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py index e301498..4f713bc 100644 --- a/config/webapp_themes_config.py +++ b/config/webapp_themes_config.py @@ -8,7 +8,7 @@ import re from pathlib import Path from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator logger = logging.getLogger(__name__) @@ -48,6 +48,22 @@ class ThemeTokens(BaseModel): admin_muted: Optional[str] = None admin_dim: Optional[str] = None + @field_validator("accent") + @classmethod + def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw) + if not match: + raise ValueError("accent must be a hex color (#RGB or #RRGGBB)") + hex_value = match.group(1).lower() + if len(hex_value) == 3: + hex_value = "".join(char * 2 for char in hex_value) + return f"#{hex_value}" + class WebappTheme(BaseModel): """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" @@ -256,10 +272,13 @@ def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: - return [theme for _, theme in sorted( - ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), - key=lambda pair: pair[0], - )] + return [ + theme + for _, theme in sorted( + ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), + key=lambda pair: pair[0], + ) + ] def _themes_config_from_list( diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp index f864834..0c10799 100644 Binary files a/docs/remnawave-minishop.webp and b/docs/remnawave-minishop.webp differ diff --git a/tests/test_settings.py b/tests/test_settings.py index 9dbf44b..795e7ae 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -28,6 +28,25 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) + def test_deprecated_webapp_appearance_env_values_are_ignored(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + WEBAPP_PRIMARY_COLOR="#ff0000", + WEBAPP_LOGO_URL="https://cdn.example.com/logo.png", + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_EMOJI="πŸ”₯", + WEBAPP_LOGO_EMOJI_FONT="twemoji", + ) + + self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a") + self.assertIsNone(settings.WEBAPP_LOGO_URL) + self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI) + self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "πŸ«₯") + self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system") + def test_tariffs_config_missing_uses_legacy_fallback(self): settings = Settings( _env_file=None, diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 5e32b62..2eb0ed7 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -11,6 +11,7 @@ from unittest.mock import patch from bot.app.web import subscription_webapp from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings +from config.webapp_themes_config import builtin_webapp_themes_config class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): @@ -91,6 +92,36 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): r"^/webapp-logo\?v=[0-9a-f]{12}$", ) + def test_uploaded_webapp_logo_url_is_served_directly(self): + settings = SimpleNamespace( + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png" + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_logo_url(settings), + "/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self): + settings = SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "") + + def test_initial_theme_head_markup_includes_css_and_tokens(self): + cfg = builtin_webapp_themes_config("#123456") + theme = cfg.theme_by_key("light") + request = SimpleNamespace(get=lambda key, default="": "nonce-value") + + markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456") + + self.assertIn("/webapp-theme-css/light/style.css", markup) + self.assertIn('nonce="nonce-value"', markup) + self.assertIn("--accent:#123456", markup) + self.assertIn("color-scheme:light", markup) + def test_animated_emoji_asset_path_uses_same_origin_route(self): self.assertEqual( subscription_webapp._webapp_animated_emoji_asset_path("🀩"), @@ -364,7 +395,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): logo_url = "https://cdn.example.com/logo.png" logo = (b"cached-logo", "image/png") app = { - "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url), + "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False), "webapp_logo_cache": None, "webapp_logo_cache_lock": asyncio.Lock(), } @@ -403,6 +434,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as tmpdir: app = { "settings": SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, WEBAPP_LOGO_EMOJI="🀩", WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", ), diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 0e3d597..8d3f85d 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -1,8 +1,10 @@ +import asyncio import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from aiohttp import web +from aiohttp.test_utils import make_mocked_request from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import auth as admin_auth_routes @@ -57,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route", ("GET", "/health"): "health_route", ("GET", "/webapp-logo"): "webapp_logo_route", + ("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/subscription_webapp.css"): "css_asset_route", ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", @@ -139,12 +142,31 @@ class WebAppRouteContractTests(unittest.TestCase): ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", ("GET", "/api/admin/themes"): "admin_themes_get_route", ("PUT", "/api/admin/themes"): "admin_themes_save_route", + ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } for key, handler_name in expected.items(): self.assertEqual(routes.get(key), handler_name, key) + def test_admin_themes_page_route_is_not_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/themes", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.http_exception.status, 404) + + def test_admin_appearance_page_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/appearance", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "index_route") + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self): diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index 65b83e1..f8a9d43 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -279,6 +279,35 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertTrue(win95["use_in_admin"]) self.assertNotIn("accent", win95["tokens"]) + def test_theme_accent_is_normalized_to_hex(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "0F8"}, + } + ], + ) + + self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88") + + def test_theme_accent_rejects_non_hex_values(self): + with self.assertRaises(ValueError): + WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "lime"}, + } + ], + ) + def test_public_payload_keeps_admin_usage_flag(self): cfg = WebappThemesConfig( default_theme="custom",