diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index de1ed9f..3a46e7b 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -57,4 +57,5 @@ def setup_admin_routes(app: web.Application) -> None: 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_post("/api/admin/appearance/favicon", admin_appearance_favicon_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 3746394..e201123 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/bot/app/web/admin_api_impl/settings.py @@ -75,6 +75,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: or "WEBAPP_LOGO_URL" in deletes or "WEBAPP_LOGO_USE_EMOJI" in updates or "WEBAPP_LOGO_USE_EMOJI" in deletes + or "WEBAPP_FAVICON_URL" in updates + or "WEBAPP_FAVICON_URL" in deletes + or "WEBAPP_FAVICON_USE_CUSTOM" in updates + or "WEBAPP_FAVICON_USE_CUSTOM" in deletes + or "WEBAPP_LOGO_FAVICON_URL" in updates + or "WEBAPP_LOGO_FAVICON_URL" in deletes ): request.app["webapp_logo_cache"] = None diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py index f1b4d79..22b89f3 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/bot/app/web/admin_api_impl/themes.py @@ -8,6 +8,7 @@ import re import socket from aiohttp import ClientSession, ClientTimeout +from PIL import Image, ImageOps, UnidentifiedImageError from config.webapp_themes_config import ( WebappThemesConfig, @@ -20,6 +21,9 @@ 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_FAVICON_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { ".gif": "image/gif", ".ico": "image/x-icon", @@ -69,6 +73,66 @@ def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "" return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" +def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image: + fitted = source.copy() + fitted.thumbnail((size, size), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + left = (size - fitted.width) // 2 + top = (size - fitted.height) // 2 + canvas.alpha_composite(fitted, (left, top)) + return canvas + + +def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("favicon source must be a non-empty image up to 2 MiB") + + ext = _detect_logo_extension(body, content_type, filename) + digest = hashlib.sha256(body).hexdigest()[:16] + target_dir = WEBAPP_FAVICON_DIR / digest + target_dir.mkdir(parents=True, exist_ok=True) + + if ext == ".svg": + safe_name = "favicon.svg" + (target_dir / safe_name).write_bytes(body) + return { + "favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}", + "variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"}, + } + + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise ValueError("favicon source must be a raster image") from exc + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + raise ValueError("favicon source dimensions are not supported") + + variants: Dict[str, str] = {} + png_icons: Dict[int, Image.Image] = {} + for size in WEBAPP_FAVICON_SIZES: + icon = _image_to_square_icon(source, size) + png_icons[size] = icon + filename = f"icon-{size}.png" + icon.save(target_dir / filename, format="PNG", optimize=True) + variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}" + + png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True) + variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png" + png_icons[32].save( + target_dir / "favicon.ico", + format="ICO", + sizes=[(16, 16), (32, 32), (48, 48)], + ) + variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico" + return { + "favicon_url": variants["180"], + "variants": variants, + } + + async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: reader = await request.multipart() async for part in reader: @@ -169,13 +233,39 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon 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) + try: + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError: + favicon_payload = {} 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}) + return _ok({"logo_url": logo_url, **favicon_payload}) + + +async def admin_appearance_favicon_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) + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_favicon", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp favicon") + return _error(500, "write_failed", str(exc)) + + return _ok(favicon_payload) async def admin_themes_get_route(request: web.Request) -> web.Response: diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 031fbc5..457bc95 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -100,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), + SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Использовать отдельную favicon"), + SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), + SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), # ─── Subscription periods & pricing ──────────────────────────── SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 2467319..022223c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -251,6 +251,10 @@ emoji: brandEmoji, emojiFont: brandEmojiFont, }); + $: faviconBrand = normalizeBrand({ + ...brand, + logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl, + }); $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; @@ -377,7 +381,7 @@ : telegramLoginUnavailable ? t("wa_auth_telegram_not_configured") : ""; - $: applyFavicon(brand); + $: applyFavicon(faviconBrand); $: syncBodyScrollLock( paymentModalOpen || changeModalOpen || @@ -885,6 +889,9 @@ "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_EMOJI", "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", ].some((key) => keys.has(key)); } @@ -987,6 +994,8 @@ onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} + appFaviconUrl={CFG.faviconUrl} + appFaviconUseCustom={CFG.faviconUseCustom} appVersion={CFG.appVersion} appRepositoryUrl={CFG.appRepositoryUrl} {currentLang} diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 6bc0291..dac7d27 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -76,6 +76,8 @@ export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; export let appVersion = "dev+local"; export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; export let currentLang = "ru"; @@ -647,7 +649,14 @@ {/if} {#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 index b5b7dab..a6766c1 100644 --- a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -16,9 +16,23 @@ export let currentLang = "ru"; export let onSettingsSaved = () => {}; export let brand = {}; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; const settingsStore = getContext("settingsStore"); const themesStore = getContext("themesStore"); + const APPEARANCE_SETTING_KEYS = new Set([ + "WEBAPP_TITLE", + "WEBAPP_PRIMARY_COLOR", + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", + "WEBAPP_ENABLED", + ]); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); $: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore); @@ -31,6 +45,25 @@ $: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : ""; $: previewLogoUrl = logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl; + $: persistedUseCustomFavicon = boolValue( + valueForKey("WEBAPP_FAVICON_USE_CUSTOM", appFaviconUseCustom) + ); + $: if ( + !Object.prototype.hasOwnProperty.call(settingsDirty, "WEBAPP_FAVICON_USE_CUSTOM") && + lastPersistedUseCustomFavicon !== persistedUseCustomFavicon + ) { + faviconUseCustomDraft = persistedUseCustomFavicon; + lastPersistedUseCustomFavicon = persistedUseCustomFavicon; + } + $: useCustomFavicon = faviconUseCustomDraft; + $: faviconUrl = valueForKey("WEBAPP_FAVICON_URL", appFaviconUrl); + $: logoFaviconUrl = valueForKey("WEBAPP_LOGO_FAVICON_URL"); + $: generatedFaviconUrl = !useEmojiLogo ? logoFaviconUrl || previewLogoUrl || "" : ""; + $: currentFaviconUrl = useCustomFavicon + ? pendingFaviconPreviewUrl || faviconUrl || "" + : generatedFaviconUrl; + $: previewFaviconUrl = + faviconPreviewNonce && currentFaviconUrl ? withCacheBust(currentFaviconUrl) : currentFaviconUrl; $: logoEmoji = valueForKey("WEBAPP_LOGO_EMOJI"); $: logoEmojiInput = useEmojiLogo ? logoEmoji : ""; $: logoEmojiPreview = logoEmoji || "🫥"; @@ -46,36 +79,51 @@ label: item.label, })); $: dirtyCount = Object.keys(settingsDirty || {}).filter((key) => - appearanceFields.some((field) => field.key === key) + isAppearanceSettingKey(key) ).length; $: appearanceDirtyKeys = Object.keys(settingsDirty || {}).filter((key) => - appearanceFields.some((field) => field.key === key) + isAppearanceSettingKey(key) ); let logoFileInput; + let faviconFileInput; let logoSourceUrl = ""; + let faviconSourceUrl = ""; let logoPreviewNonce = 0; + let faviconPreviewNonce = 0; let logoPreviewFailed = false; + let faviconPreviewFailed = false; let lastPreviewLogoUrl = ""; + let lastPreviewFaviconUrl = ""; + let lastPersistedUseCustomFavicon; + let faviconUseCustomDraft = false; let pendingLogoPreviewUrl = ""; + let pendingFaviconPreviewUrl = ""; let pendingObjectUrl = ""; + let pendingFaviconObjectUrl = ""; $: if (previewLogoUrl !== lastPreviewLogoUrl) { lastPreviewLogoUrl = previewLogoUrl; logoPreviewFailed = false; } - function valueFor(field) { - if (!field) return ""; - if (settingsDirty[field.key]?.deleted) return ""; - if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) { - return settingsDirty[field.key].value; - } - return field.value ?? ""; + $: if (previewFaviconUrl !== lastPreviewFaviconUrl) { + lastPreviewFaviconUrl = previewFaviconUrl; + faviconPreviewFailed = false; } - function valueForKey(key) { - return valueFor(fieldMap.get(key)); + function valueForKey(key, fallback = "") { + if (settingsDirty[key]?.deleted) return ""; + if (Object.prototype.hasOwnProperty.call(settingsDirty, key)) { + return settingsDirty[key].value; + } + const field = fieldMap.get(key); + if (!field) return fallback; + return field.value ?? fallback; + } + + function isAppearanceSettingKey(key) { + return APPEARANCE_SETTING_KEYS.has(key) || appearanceFields.some((field) => field.key === key); } function boolValue(value) { @@ -88,9 +136,13 @@ } function withLogoCacheBust(url) { + return withCacheBust(url, logoPreviewNonce); + } + + function withCacheBust(url, nonce) { if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url; const separator = url.includes("?") ? "&" : "?"; - return `${url}${separator}v=${logoPreviewNonce}`; + return `${url}${separator}v=${nonce}`; } function clearPendingObjectUrl() { @@ -100,6 +152,13 @@ pendingObjectUrl = ""; } + function clearPendingFaviconObjectUrl() { + if (pendingFaviconObjectUrl && typeof URL !== "undefined") { + URL.revokeObjectURL(pendingFaviconObjectUrl); + } + pendingFaviconObjectUrl = ""; + } + function setPendingLogoPreview(url, objectUrl = "") { clearPendingObjectUrl(); pendingObjectUrl = objectUrl; @@ -108,6 +167,14 @@ logoPreviewNonce = Date.now(); } + function setPendingFaviconPreview(url, objectUrl = "") { + clearPendingFaviconObjectUrl(); + pendingFaviconObjectUrl = objectUrl; + pendingFaviconPreviewUrl = url; + faviconPreviewFailed = false; + faviconPreviewNonce = Date.now(); + } + function themeTitle(theme) { return localizedThemeName(theme, currentLang) || "—"; } @@ -151,28 +218,77 @@ const objectUrl = URL.createObjectURL(file); setPendingLogoPreview(objectUrl, objectUrl); } - themesStore.uploadLogoFile(file).then((uploadedUrl) => { + themesStore.uploadLogoFile(file).then((uploaded) => { + const uploadedUrl = uploaded?.logoUrl || ""; if (!uploadedUrl) { pendingLogoPreviewUrl = ""; clearPendingObjectUrl(); return; } settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + if (uploaded?.faviconUrl) { + settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + } settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); if (logoFileInput) logoFileInput.value = ""; }); } function uploadLogoFromUrl() { - themesStore.uploadLogoUrl(logoSourceUrl).then((uploadedUrl) => { + themesStore.uploadLogoUrl(logoSourceUrl).then((uploaded) => { + const uploadedUrl = uploaded?.logoUrl || ""; if (!uploadedUrl) return; setPendingLogoPreview(uploadedUrl); logoSourceUrl = ""; settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + if (uploaded?.faviconUrl) { + settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + } settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); }); } + function handleFaviconFileChange(event) { + const file = event.currentTarget.files?.[0]; + if (!file) return; + if (typeof URL !== "undefined") { + const objectUrl = URL.createObjectURL(file); + setPendingFaviconPreview(objectUrl, objectUrl); + } + themesStore.uploadFaviconFile(file).then((uploaded) => { + const uploadedUrl = uploaded?.faviconUrl || ""; + if (!uploadedUrl) { + pendingFaviconPreviewUrl = ""; + clearPendingFaviconObjectUrl(); + return; + } + settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); + setCustomFavicon(true); + if (faviconFileInput) faviconFileInput.value = ""; + }); + } + + function uploadFaviconFromUrl() { + themesStore.uploadFaviconUrl(faviconSourceUrl).then((uploaded) => { + const uploadedUrl = uploaded?.faviconUrl || ""; + if (!uploadedUrl) return; + setPendingFaviconPreview(uploadedUrl); + faviconSourceUrl = ""; + settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); + setCustomFavicon(true); + }); + } + + function setCustomFavicon(enabled) { + const nextEnabled = Boolean(enabled); + faviconUseCustomDraft = nextEnabled; + settingsStore.markDirty("WEBAPP_FAVICON_USE_CUSTOM", nextEnabled); + if (!nextEnabled) { + pendingFaviconPreviewUrl = ""; + clearPendingFaviconObjectUrl(); + } + } + function setEmojiLogo(enabled) { settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled)); if (!enabled) { @@ -199,6 +315,9 @@ "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_EMOJI", "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", ].includes(key) ); let settingsSaved = true; @@ -251,6 +370,7 @@ onDestroy(() => { clearPendingObjectUrl(); + clearPendingFaviconObjectUrl(); }); @@ -379,6 +499,74 @@ + +
+
+ {#if previewFaviconUrl && !faviconPreviewFailed} + { + faviconPreviewFailed = true; + }} + /> + {:else if !useCustomFavicon && useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + + faviconFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_favicon_upload_file", {}, "Загрузить favicon")} + +
+ + + {at("appearance_favicon_upload_url", {}, "По ссылке")} + +
+
+
+
@@ -548,6 +736,11 @@ align-items: stretch; } + .appearance-favicon-grid { + grid-template-columns: minmax(132px, 140px) minmax(0, 520px); + border-top: 1px solid var(--admin-border); + } + .appearance-logo-preview { display: inline-flex; align-items: center; @@ -564,6 +757,12 @@ background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-surface)); } + .appearance-favicon-preview { + width: 140px; + height: 140px; + max-width: 100%; + } + .appearance-logo-image { display: block; width: 100%; @@ -801,6 +1000,11 @@ width: min(164px, 100%); } + .appearance-favicon-preview { + width: min(140px, 100%); + height: auto; + } + .appearance-url-row, .appearance-emoji-grid { grid-template-columns: 1fr; 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 41c9f6a..9d07793 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -76,7 +76,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { "Логотип загружен. Сохраните изменения, чтобы применить его." ) ); - return data.logo_url || ""; + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; } flash( data?.message || @@ -106,7 +106,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { "Логотип загружен. Сохраните изменения, чтобы применить его." ) ); - return data.logo_url || ""; + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; } flash( data?.message || @@ -119,6 +119,67 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { } } + async function uploadFaviconFile(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/favicon", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена. Сохраните изменения, чтобы применить ее." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/favicon", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена. Сохраните изменения, чтобы применить ее." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + function setCurrentTheme(key) { state.update((s) => ({ ...s, @@ -213,5 +274,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { toggleAdminUse, uploadLogoFile, uploadLogoUrl, + uploadFaviconFile, + uploadFaviconUrl, }; } diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/bot/app/web/frontend/src/lib/webapp/mockApi.js index 6a6f7d0..e81955d 100644 --- a/bot/app/web/frontend/src/lib/webapp/mockApi.js +++ b/bot/app/web/frontend/src/lib/webapp/mockApi.js @@ -220,7 +220,21 @@ export async function mockApi(path, options = {}, context = {}) { }; } if (path === "/admin/appearance/logo") { - return { ok: true, logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png" }; + return { + ok: true, + logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png", + favicon_url: "/webapp-favicon/0000000000000000/icon-180.png", + }; + } + if (path === "/admin/appearance/favicon") { + return { + ok: true, + favicon_url: "/webapp-favicon/1111111111111111/icon-180.png", + variants: { + "32": "/webapp-favicon/1111111111111111/icon-32.png", + apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png", + }, + }; } if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") { try { @@ -236,6 +250,15 @@ export async function mockApi(path, options = {}, context = {}) { if (updates.WEBAPP_LOGO_EMOJI_FONT) { DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT; } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) { + DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || ""; + } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) { + DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || ""; + } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) { + DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM); + } } catch (_e) { void _e; } @@ -282,6 +305,27 @@ export async function mockApi(path, options = {}, context = {}) { { value: "noto-color-animated", label: "Noto Color Emoji Animated" }, ], }, + { + key: "WEBAPP_FAVICON_USE_CUSTOM", + type: "bool", + section: "appearance", + label: "Custom favicon", + value: Boolean(DEV_MOCK.config.faviconUseCustom), + }, + { + key: "WEBAPP_FAVICON_URL", + type: "url", + section: "appearance", + label: "Favicon URL", + value: DEV_MOCK.config.faviconUrl || "", + }, + { + key: "WEBAPP_LOGO_FAVICON_URL", + type: "url", + section: "appearance", + label: "Logo favicon URL", + value: DEV_MOCK.config.faviconUrl || "", + }, ], }, ], diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/bot/app/web/frontend/src/lib/webapp/previewMock.js index 8eba8fb..c9609b9 100644 --- a/bot/app/web/frontend/src/lib/webapp/previewMock.js +++ b/bot/app/web/frontend/src/lib/webapp/previewMock.js @@ -30,6 +30,8 @@ export const DEV_MOCK = { logoUseEmoji: false, logoEmoji: "🫥", logoEmojiFont: "system", + faviconUrl: "", + faviconUseCustom: false, apiBase: "/api", supportUrl: "https://t.me/support", privacyPolicyUrl: "https://example.com/privacy", diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index 8c94f0b..a148340 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -68,6 +68,8 @@ 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_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" 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 c0916fd..cf8c770 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -142,6 +142,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str: return "" +def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: + raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip() + raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip() + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url: + return _resolve_webapp_asset_url(raw_custom_url) + if logo_url and raw_logo_favicon_url: + resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) + if resolved: + return resolved + return logo_url or "" + + +def _resolve_webapp_asset_url(raw_url: str) -> str: + parsed_url = urlsplit(raw_url) + if parsed_url.scheme in {"https", "http", "data"}: + return raw_url + if raw_url.startswith("/"): + return raw_url + return "" + + def _webapp_logo_cache_key(logo_url: str) -> str: return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() @@ -247,6 +268,47 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: return response +async def webapp_favicon_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + digest = str(request.match_info.get("digest") or "").strip().lower() + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"[0-9a-f]{16}", digest): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + if not re.fullmatch( + r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", + filename, + ): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + root = WEBAPP_FAVICON_DIR.expanduser().resolve() + path = (root / digest / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_favicon_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_favicon_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() @@ -639,8 +701,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: cache = request.app["webapp_settings_cache"] now = time.monotonic() if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): + logo_url = _resolve_webapp_logo_url(settings) cache["data"] = { - "logo_url": _resolve_webapp_logo_url(settings), + "logo_url": logo_url, + "favicon_url": _resolve_webapp_favicon_url(settings, logo_url), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, "traffic_packages": settings.traffic_packages, @@ -806,6 +870,8 @@ async def index_route(request: web.Request) -> web.Response: "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "faviconUrl": cached["favicon_url"], + "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, @@ -848,6 +914,12 @@ async def index_route(request: web.Request) -> web.Response: WEBAPP_JS_PLACEHOLDER, f'', ) + favicon_markup = _favicon_head_markup(cached["favicon_url"]) + if favicon_markup: + html = html.replace( + '', + favicon_markup, + ) brand_asset_url = cached["logo_url"] if ( not brand_asset_url @@ -992,6 +1064,39 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: ) +def _favicon_head_markup(favicon_url: str) -> str: + href = str(favicon_url or "").strip() + if not href: + return "" + + escaped_href = html.escape(href, quote=True) + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/icon-(?:16|32|48|180|192|512)\.png", + href, + ) + if not match: + rel = "apple-touch-icon" if href.endswith(".png") else "icon" + return ( + f'\n' + f'' + ) + + digest = match.group(1) + base = f"{WEBAPP_FAVICON_PATH}/{digest}" + return "\n".join( + [ + ( + f'' + ), + f'', + f'', + f'', + f'', + ] + ) + + 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 478a3b5..d8a0b59 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -25,6 +25,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", webapp_uploaded_logo_route, ) + app.router.add_get( + rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_favicon_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, diff --git a/config/settings.py b/config/settings.py index 13f6b24..99ea47a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -95,6 +95,9 @@ class WebAppSettings(BaseModel): logo_use_emoji: bool logo_emoji: str logo_emoji_font: str + favicon_use_custom: bool + favicon_url: Optional[str] + logo_favicon_url: Optional[str] session_ttl_seconds: int session_secret: str webhook_secret_token: str @@ -379,6 +382,9 @@ class Settings(BaseSettings): "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" ), ) + WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) + WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) @@ -550,6 +556,9 @@ class Settings(BaseSettings): logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, + favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, + favicon_url=self.WEBAPP_FAVICON_URL, + logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_secret=self.WEBAPP_SESSION_SECRET, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, @@ -864,6 +873,21 @@ class Settings(BaseSettings): def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): return "system" + @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): + return False + + @field_validator("WEBAPP_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_favicon_url_env(cls, _value): + return None + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/requirements.txt b/requirements.txt index 9ce503e..55c818f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49 asyncpg==0.31.0 aiocryptopay==0.4.8 PyJWT[crypto]==2.12.1 +Pillow==12.2.0 diff --git a/tests/test_settings.py b/tests/test_settings.py index 795e7ae..d3ec935 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -39,6 +39,9 @@ class SettingsTests(unittest.TestCase): WEBAPP_LOGO_USE_EMOJI=True, WEBAPP_LOGO_EMOJI="🔥", WEBAPP_LOGO_EMOJI_FONT="twemoji", + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="https://cdn.example.com/favicon.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", ) self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a") @@ -46,6 +49,9 @@ class SettingsTests(unittest.TestCase): self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI) self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥") self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system") + self.assertFalse(settings.WEBAPP_FAVICON_USE_CUSTOM) + self.assertIsNone(settings.WEBAPP_FAVICON_URL) + self.assertIsNone(settings.WEBAPP_LOGO_FAVICON_URL) def test_tariffs_config_missing_uses_legacy_fallback(self): settings = Settings( diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 2eb0ed7..bd53f4d 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -1,4 +1,5 @@ import asyncio +import io import json import os import tempfile @@ -8,7 +9,10 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +from PIL import Image + from bot.app.web import subscription_webapp +from bot.app.web.admin_api_impl import themes as admin_themes 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 @@ -110,6 +114,61 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "") + def test_custom_webapp_favicon_takes_precedence(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/abcdef1234567890/icon-180.png", + ) + + def test_logo_generated_favicon_is_used_when_custom_disabled(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/1111111111111111/icon-180.png", + ) + + def test_logo_generated_favicon_is_not_used_without_logo(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_favicon_url(settings, ""), "") + + def test_favicon_head_markup_includes_touch_icon(self): + markup = subscription_webapp._favicon_head_markup( + "/webapp-favicon/abcdef1234567890/icon-180.png" + ) + + self.assertIn('rel="apple-touch-icon"', markup) + self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup) + + def test_favicon_set_generation_writes_common_icon_sizes(self): + buffer = io.BytesIO() + Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG") + png_body = buffer.getvalue() + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(admin_themes, "WEBAPP_FAVICON_DIR", Path(tmpdir)): + payload = admin_themes._write_favicon_set(png_body, "image/png", "icon.png") + + self.assertRegex(payload["favicon_url"], r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$") + digest = payload["favicon_url"].split("/")[2] + self.assertTrue((Path(tmpdir) / digest / "icon-32.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists()) + def test_initial_theme_head_markup_includes_css_and_tokens(self): cfg = builtin_webapp_themes_config("#123456") theme = cfg.theme_by_key("light") diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 8d3f85d..a511d9b 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -143,6 +143,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("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", + ("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } @@ -167,6 +168,19 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(match_info.handler.__name__, "index_route") + def test_webapp_favicon_asset_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request( + "GET", + "/webapp-favicon/abcdef1234567890/icon-180.png", + app=app, + ) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "webapp_favicon_route") + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self):