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}
+
+
+
+
@@ -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):