feat: separate webapp favicon configuration in appearance admin panel section
This commit is contained in:
@@ -57,4 +57,5 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router.add_get("/api/admin/themes", admin_themes_get_route)
|
router.add_get("/api/admin/themes", admin_themes_get_route)
|
||||||
router.add_put("/api/admin/themes", admin_themes_save_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/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)
|
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
|||||||
or "WEBAPP_LOGO_URL" in deletes
|
or "WEBAPP_LOGO_URL" in deletes
|
||||||
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
or "WEBAPP_LOGO_USE_EMOJI" in updates
|
||||||
or "WEBAPP_LOGO_USE_EMOJI" in deletes
|
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
|
request.app["webapp_logo_cache"] = None
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
|
|
||||||
from aiohttp import ClientSession, ClientTimeout
|
from aiohttp import ClientSession, ClientTimeout
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
from config.webapp_themes_config import (
|
from config.webapp_themes_config import (
|
||||||
WebappThemesConfig,
|
WebappThemesConfig,
|
||||||
@@ -20,6 +21,9 @@ from config.webapp_themes_config import (
|
|||||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads"
|
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads"
|
||||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
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 = {
|
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".ico": "image/x-icon",
|
".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}"
|
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]:
|
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
async for part in reader:
|
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")
|
return _error(400, "invalid_payload", "url or file is required")
|
||||||
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
|
||||||
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
|
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:
|
except ValueError as exc:
|
||||||
return _error(400, "invalid_logo", str(exc))
|
return _error(400, "invalid_logo", str(exc))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.exception("Failed to save uploaded webapp logo")
|
logger.exception("Failed to save uploaded webapp logo")
|
||||||
return _error(500, "write_failed", str(exc))
|
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:
|
async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
("noto-local", "Noto Emoji (local)"),
|
("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 включён"),
|
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||||
# ─── Subscription periods & pricing ────────────────────────────
|
# ─── Subscription periods & pricing ────────────────────────────
|
||||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
emoji: brandEmoji,
|
emoji: brandEmoji,
|
||||||
emojiFont: brandEmojiFont,
|
emojiFont: brandEmojiFont,
|
||||||
});
|
});
|
||||||
|
$: faviconBrand = normalizeBrand({
|
||||||
|
...brand,
|
||||||
|
logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl,
|
||||||
|
});
|
||||||
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
|
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
|
||||||
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
|
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
|
||||||
$: appSettings = data?.settings || DEV_MOCK.data.settings;
|
$: appSettings = data?.settings || DEV_MOCK.data.settings;
|
||||||
@@ -377,7 +381,7 @@
|
|||||||
: telegramLoginUnavailable
|
: telegramLoginUnavailable
|
||||||
? t("wa_auth_telegram_not_configured")
|
? t("wa_auth_telegram_not_configured")
|
||||||
: "";
|
: "";
|
||||||
$: applyFavicon(brand);
|
$: applyFavicon(faviconBrand);
|
||||||
$: syncBodyScrollLock(
|
$: syncBodyScrollLock(
|
||||||
paymentModalOpen ||
|
paymentModalOpen ||
|
||||||
changeModalOpen ||
|
changeModalOpen ||
|
||||||
@@ -885,6 +889,9 @@
|
|||||||
"WEBAPP_LOGO_USE_EMOJI",
|
"WEBAPP_LOGO_USE_EMOJI",
|
||||||
"WEBAPP_LOGO_EMOJI",
|
"WEBAPP_LOGO_EMOJI",
|
||||||
"WEBAPP_LOGO_EMOJI_FONT",
|
"WEBAPP_LOGO_EMOJI_FONT",
|
||||||
|
"WEBAPP_FAVICON_URL",
|
||||||
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
|
"WEBAPP_LOGO_FAVICON_URL",
|
||||||
].some((key) => keys.has(key));
|
].some((key) => keys.has(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,6 +994,8 @@
|
|||||||
onThemesSaved={handleAdminPersistedSaved}
|
onThemesSaved={handleAdminPersistedSaved}
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
{brand}
|
{brand}
|
||||||
|
appFaviconUrl={CFG.faviconUrl}
|
||||||
|
appFaviconUseCustom={CFG.faviconUseCustom}
|
||||||
appVersion={CFG.appVersion}
|
appVersion={CFG.appVersion}
|
||||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||||
{currentLang}
|
{currentLang}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
export let onThemesSaved = () => {};
|
export let onThemesSaved = () => {};
|
||||||
export let brand = {};
|
export let brand = {};
|
||||||
export let brandTitle = "/minishop";
|
export let brandTitle = "/minishop";
|
||||||
|
export let appFaviconUrl = "";
|
||||||
|
export let appFaviconUseCustom = false;
|
||||||
export let appVersion = "dev+local";
|
export let appVersion = "dev+local";
|
||||||
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
||||||
export let currentLang = "ru";
|
export let currentLang = "ru";
|
||||||
@@ -647,7 +649,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "appearance"}
|
{#if active === "appearance"}
|
||||||
<AppearanceSection {at} {currentLang} {onSettingsSaved} {brand} />
|
<AppearanceSection
|
||||||
|
{at}
|
||||||
|
{currentLang}
|
||||||
|
{onSettingsSaved}
|
||||||
|
{brand}
|
||||||
|
{appFaviconUrl}
|
||||||
|
{appFaviconUseCustom}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "settings"}
|
{#if active === "settings"}
|
||||||
|
|||||||
@@ -16,9 +16,23 @@
|
|||||||
export let currentLang = "ru";
|
export let currentLang = "ru";
|
||||||
export let onSettingsSaved = () => {};
|
export let onSettingsSaved = () => {};
|
||||||
export let brand = {};
|
export let brand = {};
|
||||||
|
export let appFaviconUrl = "";
|
||||||
|
export let appFaviconUseCustom = false;
|
||||||
|
|
||||||
const settingsStore = getContext("settingsStore");
|
const settingsStore = getContext("settingsStore");
|
||||||
const themesStore = getContext("themesStore");
|
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);
|
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
|
||||||
$: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore);
|
$: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore);
|
||||||
@@ -31,6 +45,25 @@
|
|||||||
$: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : "";
|
$: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : "";
|
||||||
$: previewLogoUrl =
|
$: previewLogoUrl =
|
||||||
logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl;
|
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");
|
$: logoEmoji = valueForKey("WEBAPP_LOGO_EMOJI");
|
||||||
$: logoEmojiInput = useEmojiLogo ? logoEmoji : "";
|
$: logoEmojiInput = useEmojiLogo ? logoEmoji : "";
|
||||||
$: logoEmojiPreview = logoEmoji || "🫥";
|
$: logoEmojiPreview = logoEmoji || "🫥";
|
||||||
@@ -46,36 +79,51 @@
|
|||||||
label: item.label,
|
label: item.label,
|
||||||
}));
|
}));
|
||||||
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
|
$: dirtyCount = Object.keys(settingsDirty || {}).filter((key) =>
|
||||||
appearanceFields.some((field) => field.key === key)
|
isAppearanceSettingKey(key)
|
||||||
).length;
|
).length;
|
||||||
$: appearanceDirtyKeys = Object.keys(settingsDirty || {}).filter((key) =>
|
$: appearanceDirtyKeys = Object.keys(settingsDirty || {}).filter((key) =>
|
||||||
appearanceFields.some((field) => field.key === key)
|
isAppearanceSettingKey(key)
|
||||||
);
|
);
|
||||||
|
|
||||||
let logoFileInput;
|
let logoFileInput;
|
||||||
|
let faviconFileInput;
|
||||||
let logoSourceUrl = "";
|
let logoSourceUrl = "";
|
||||||
|
let faviconSourceUrl = "";
|
||||||
let logoPreviewNonce = 0;
|
let logoPreviewNonce = 0;
|
||||||
|
let faviconPreviewNonce = 0;
|
||||||
let logoPreviewFailed = false;
|
let logoPreviewFailed = false;
|
||||||
|
let faviconPreviewFailed = false;
|
||||||
let lastPreviewLogoUrl = "";
|
let lastPreviewLogoUrl = "";
|
||||||
|
let lastPreviewFaviconUrl = "";
|
||||||
|
let lastPersistedUseCustomFavicon;
|
||||||
|
let faviconUseCustomDraft = false;
|
||||||
let pendingLogoPreviewUrl = "";
|
let pendingLogoPreviewUrl = "";
|
||||||
|
let pendingFaviconPreviewUrl = "";
|
||||||
let pendingObjectUrl = "";
|
let pendingObjectUrl = "";
|
||||||
|
let pendingFaviconObjectUrl = "";
|
||||||
|
|
||||||
$: if (previewLogoUrl !== lastPreviewLogoUrl) {
|
$: if (previewLogoUrl !== lastPreviewLogoUrl) {
|
||||||
lastPreviewLogoUrl = previewLogoUrl;
|
lastPreviewLogoUrl = previewLogoUrl;
|
||||||
logoPreviewFailed = false;
|
logoPreviewFailed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueFor(field) {
|
$: if (previewFaviconUrl !== lastPreviewFaviconUrl) {
|
||||||
if (!field) return "";
|
lastPreviewFaviconUrl = previewFaviconUrl;
|
||||||
if (settingsDirty[field.key]?.deleted) return "";
|
faviconPreviewFailed = false;
|
||||||
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
|
|
||||||
return settingsDirty[field.key].value;
|
|
||||||
}
|
|
||||||
return field.value ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueForKey(key) {
|
function valueForKey(key, fallback = "") {
|
||||||
return valueFor(fieldMap.get(key));
|
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) {
|
function boolValue(value) {
|
||||||
@@ -88,9 +136,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withLogoCacheBust(url) {
|
function withLogoCacheBust(url) {
|
||||||
|
return withCacheBust(url, logoPreviewNonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCacheBust(url, nonce) {
|
||||||
if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url;
|
if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url;
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
return `${url}${separator}v=${logoPreviewNonce}`;
|
return `${url}${separator}v=${nonce}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingObjectUrl() {
|
function clearPendingObjectUrl() {
|
||||||
@@ -100,6 +152,13 @@
|
|||||||
pendingObjectUrl = "";
|
pendingObjectUrl = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPendingFaviconObjectUrl() {
|
||||||
|
if (pendingFaviconObjectUrl && typeof URL !== "undefined") {
|
||||||
|
URL.revokeObjectURL(pendingFaviconObjectUrl);
|
||||||
|
}
|
||||||
|
pendingFaviconObjectUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
function setPendingLogoPreview(url, objectUrl = "") {
|
function setPendingLogoPreview(url, objectUrl = "") {
|
||||||
clearPendingObjectUrl();
|
clearPendingObjectUrl();
|
||||||
pendingObjectUrl = objectUrl;
|
pendingObjectUrl = objectUrl;
|
||||||
@@ -108,6 +167,14 @@
|
|||||||
logoPreviewNonce = Date.now();
|
logoPreviewNonce = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPendingFaviconPreview(url, objectUrl = "") {
|
||||||
|
clearPendingFaviconObjectUrl();
|
||||||
|
pendingFaviconObjectUrl = objectUrl;
|
||||||
|
pendingFaviconPreviewUrl = url;
|
||||||
|
faviconPreviewFailed = false;
|
||||||
|
faviconPreviewNonce = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
function themeTitle(theme) {
|
function themeTitle(theme) {
|
||||||
return localizedThemeName(theme, currentLang) || "—";
|
return localizedThemeName(theme, currentLang) || "—";
|
||||||
}
|
}
|
||||||
@@ -151,28 +218,77 @@
|
|||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
setPendingLogoPreview(objectUrl, objectUrl);
|
setPendingLogoPreview(objectUrl, objectUrl);
|
||||||
}
|
}
|
||||||
themesStore.uploadLogoFile(file).then((uploadedUrl) => {
|
themesStore.uploadLogoFile(file).then((uploaded) => {
|
||||||
|
const uploadedUrl = uploaded?.logoUrl || "";
|
||||||
if (!uploadedUrl) {
|
if (!uploadedUrl) {
|
||||||
pendingLogoPreviewUrl = "";
|
pendingLogoPreviewUrl = "";
|
||||||
clearPendingObjectUrl();
|
clearPendingObjectUrl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
|
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
|
||||||
|
if (uploaded?.faviconUrl) {
|
||||||
|
settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl);
|
||||||
|
}
|
||||||
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false);
|
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false);
|
||||||
if (logoFileInput) logoFileInput.value = "";
|
if (logoFileInput) logoFileInput.value = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadLogoFromUrl() {
|
function uploadLogoFromUrl() {
|
||||||
themesStore.uploadLogoUrl(logoSourceUrl).then((uploadedUrl) => {
|
themesStore.uploadLogoUrl(logoSourceUrl).then((uploaded) => {
|
||||||
|
const uploadedUrl = uploaded?.logoUrl || "";
|
||||||
if (!uploadedUrl) return;
|
if (!uploadedUrl) return;
|
||||||
setPendingLogoPreview(uploadedUrl);
|
setPendingLogoPreview(uploadedUrl);
|
||||||
logoSourceUrl = "";
|
logoSourceUrl = "";
|
||||||
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
|
settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl);
|
||||||
|
if (uploaded?.faviconUrl) {
|
||||||
|
settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl);
|
||||||
|
}
|
||||||
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false);
|
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) {
|
function setEmojiLogo(enabled) {
|
||||||
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled));
|
settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled));
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -199,6 +315,9 @@
|
|||||||
"WEBAPP_LOGO_USE_EMOJI",
|
"WEBAPP_LOGO_USE_EMOJI",
|
||||||
"WEBAPP_LOGO_EMOJI",
|
"WEBAPP_LOGO_EMOJI",
|
||||||
"WEBAPP_LOGO_EMOJI_FONT",
|
"WEBAPP_LOGO_EMOJI_FONT",
|
||||||
|
"WEBAPP_FAVICON_URL",
|
||||||
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
|
"WEBAPP_LOGO_FAVICON_URL",
|
||||||
].includes(key)
|
].includes(key)
|
||||||
);
|
);
|
||||||
let settingsSaved = true;
|
let settingsSaved = true;
|
||||||
@@ -251,6 +370,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearPendingObjectUrl();
|
clearPendingObjectUrl();
|
||||||
|
clearPendingFaviconObjectUrl();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -379,6 +499,74 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card-body appearance-logo-grid appearance-favicon-grid">
|
||||||
|
<div class="appearance-logo-preview appearance-favicon-preview">
|
||||||
|
{#if previewFaviconUrl && !faviconPreviewFailed}
|
||||||
|
<img
|
||||||
|
class="appearance-logo-image"
|
||||||
|
src={previewFaviconUrl}
|
||||||
|
alt=""
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
onerror={() => {
|
||||||
|
faviconPreviewFailed = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if !useCustomFavicon && useEmojiLogo}
|
||||||
|
<BrandMark brand={logoBrand} size="lg" />
|
||||||
|
{:else}
|
||||||
|
<span class="appearance-logo-empty" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="appearance-controls">
|
||||||
|
<section class="appearance-control-card">
|
||||||
|
<label class="appearance-switch">
|
||||||
|
<Switch.Root
|
||||||
|
bind:checked={faviconUseCustomDraft}
|
||||||
|
onCheckedChange={setCustomFavicon}
|
||||||
|
class="admin-switch-root"
|
||||||
|
>
|
||||||
|
<Switch.Thumb class="admin-switch-thumb" />
|
||||||
|
</Switch.Root>
|
||||||
|
<span>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
bind:this={faviconFileInput}
|
||||||
|
class="appearance-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon,.ico"
|
||||||
|
onchange={handleFaviconFileChange}
|
||||||
|
/>
|
||||||
|
<AdminButton
|
||||||
|
class="appearance-control"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => faviconFileInput?.click()}
|
||||||
|
disabled={themesSaving}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
{at("appearance_favicon_upload_file", {}, "Загрузить favicon")}
|
||||||
|
</AdminButton>
|
||||||
|
<div class="appearance-url-row">
|
||||||
|
<input
|
||||||
|
class="input appearance-control"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/icon.png"
|
||||||
|
bind:value={faviconSourceUrl}
|
||||||
|
/>
|
||||||
|
<AdminButton
|
||||||
|
class="appearance-control"
|
||||||
|
size="sm"
|
||||||
|
onclick={uploadFaviconFromUrl}
|
||||||
|
disabled={themesSaving || !faviconSourceUrl.trim()}
|
||||||
|
>
|
||||||
|
{at("appearance_favicon_upload_url", {}, "По ссылке")}
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="admin-card">
|
<article class="admin-card">
|
||||||
@@ -548,6 +736,11 @@
|
|||||||
align-items: stretch;
|
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 {
|
.appearance-logo-preview {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -564,6 +757,12 @@
|
|||||||
background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-surface));
|
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 {
|
.appearance-logo-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -801,6 +1000,11 @@
|
|||||||
width: min(164px, 100%);
|
width: min(164px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appearance-favicon-preview {
|
||||||
|
width: min(140px, 100%);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-url-row,
|
.appearance-url-row,
|
||||||
.appearance-emoji-grid {
|
.appearance-emoji-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -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(
|
flash(
|
||||||
data?.message ||
|
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(
|
flash(
|
||||||
data?.message ||
|
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) {
|
function setCurrentTheme(key) {
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -213,5 +274,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
|||||||
toggleAdminUse,
|
toggleAdminUse,
|
||||||
uploadLogoFile,
|
uploadLogoFile,
|
||||||
uploadLogoUrl,
|
uploadLogoUrl,
|
||||||
|
uploadFaviconFile,
|
||||||
|
uploadFaviconUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,21 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (path === "/admin/appearance/logo") {
|
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") {
|
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +250,15 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
|
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
|
||||||
DEV_MOCK.config.logoEmojiFont = 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) {
|
} catch (_e) {
|
||||||
void _e;
|
void _e;
|
||||||
}
|
}
|
||||||
@@ -282,6 +305,27 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
|
{ 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 || "",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const DEV_MOCK = {
|
|||||||
logoUseEmoji: false,
|
logoUseEmoji: false,
|
||||||
logoEmoji: "🫥",
|
logoEmoji: "🫥",
|
||||||
logoEmojiFont: "system",
|
logoEmojiFont: "system",
|
||||||
|
faviconUrl: "",
|
||||||
|
faviconUseCustom: false,
|
||||||
apiBase: "/api",
|
apiBase: "/api",
|
||||||
supportUrl: "https://t.me/support",
|
supportUrl: "https://t.me/support",
|
||||||
privacyPolicyUrl: "https://example.com/privacy",
|
privacyPolicyUrl: "https://example.com/privacy",
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
|||||||
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "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_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
|
||||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
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_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji"
|
||||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||||
|
|||||||
@@ -142,6 +142,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
|||||||
return ""
|
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:
|
def _webapp_logo_cache_key(logo_url: str) -> str:
|
||||||
return hashlib.sha256(logo_url.encode("utf-8")).hexdigest()
|
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
|
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:
|
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||||
ext = str(request.match_info.get("ext") 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"]
|
cache = request.app["webapp_settings_cache"]
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
|
if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"):
|
||||||
|
logo_url = _resolve_webapp_logo_url(settings)
|
||||||
cache["data"] = {
|
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,
|
"subscription_options": settings.subscription_options,
|
||||||
"stars_subscription_options": settings.stars_subscription_options,
|
"stars_subscription_options": settings.stars_subscription_options,
|
||||||
"traffic_packages": settings.traffic_packages,
|
"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),
|
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
|
||||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||||
|
"faviconUrl": cached["favicon_url"],
|
||||||
|
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
||||||
"apiBase": "/api",
|
"apiBase": "/api",
|
||||||
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||||
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
|
"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,
|
WEBAPP_JS_PLACEHOLDER,
|
||||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||||
)
|
)
|
||||||
|
favicon_markup = _favicon_head_markup(cached["favicon_url"])
|
||||||
|
if favicon_markup:
|
||||||
|
html = html.replace(
|
||||||
|
'<link id="app-favicon" rel="icon" href="data:," sizes="any">',
|
||||||
|
favicon_markup,
|
||||||
|
)
|
||||||
brand_asset_url = cached["logo_url"]
|
brand_asset_url = cached["logo_url"]
|
||||||
if (
|
if (
|
||||||
not brand_asset_url
|
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'<link id="app-favicon" rel="icon" href="{escaped_href}" sizes="any">\n'
|
||||||
|
f'<link rel="{rel}" href="{escaped_href}">'
|
||||||
|
)
|
||||||
|
|
||||||
|
digest = match.group(1)
|
||||||
|
base = f"{WEBAPP_FAVICON_PATH}/{digest}"
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
f'<link id="app-favicon" rel="icon" type="image/png" sizes="32x32" '
|
||||||
|
f'href="{base}/icon-32.png">'
|
||||||
|
),
|
||||||
|
f'<link rel="icon" type="image/x-icon" sizes="any" href="{base}/favicon.ico">',
|
||||||
|
f'<link rel="icon" type="image/png" sizes="16x16" href="{base}/icon-16.png">',
|
||||||
|
f'<link rel="icon" type="image/png" sizes="192x192" href="{base}/icon-192.png">',
|
||||||
|
f'<link rel="apple-touch-icon" sizes="180x180" href="{base}/apple-touch-icon.png">',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
|
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
|
||||||
start = html.find(start_marker)
|
start = html.find(start_marker)
|
||||||
if start == -1:
|
if start == -1:
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||||
webapp_uploaded_logo_route,
|
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(
|
app.router.add_get(
|
||||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||||
webapp_animated_emoji_route,
|
webapp_animated_emoji_route,
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class WebAppSettings(BaseModel):
|
|||||||
logo_use_emoji: bool
|
logo_use_emoji: bool
|
||||||
logo_emoji: str
|
logo_emoji: str
|
||||||
logo_emoji_font: str
|
logo_emoji_font: str
|
||||||
|
favicon_use_custom: bool
|
||||||
|
favicon_url: Optional[str]
|
||||||
|
logo_favicon_url: Optional[str]
|
||||||
session_ttl_seconds: int
|
session_ttl_seconds: int
|
||||||
session_secret: str
|
session_secret: str
|
||||||
webhook_secret_token: str
|
webhook_secret_token: str
|
||||||
@@ -379,6 +382,9 @@ class Settings(BaseSettings):
|
|||||||
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
|
"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))
|
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||||
WEBHOOK_SECRET_TOKEN: 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)
|
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_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
|
||||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||||
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
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_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||||
session_secret=self.WEBAPP_SESSION_SECRET,
|
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||||
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||||
@@ -864,6 +873,21 @@ class Settings(BaseSettings):
|
|||||||
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
|
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
|
||||||
return "system"
|
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
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49
|
|||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
aiocryptopay==0.4.8
|
aiocryptopay==0.4.8
|
||||||
PyJWT[crypto]==2.12.1
|
PyJWT[crypto]==2.12.1
|
||||||
|
Pillow==12.2.0
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class SettingsTests(unittest.TestCase):
|
|||||||
WEBAPP_LOGO_USE_EMOJI=True,
|
WEBAPP_LOGO_USE_EMOJI=True,
|
||||||
WEBAPP_LOGO_EMOJI="🔥",
|
WEBAPP_LOGO_EMOJI="🔥",
|
||||||
WEBAPP_LOGO_EMOJI_FONT="twemoji",
|
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")
|
self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a")
|
||||||
@@ -46,6 +49,9 @@ class SettingsTests(unittest.TestCase):
|
|||||||
self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI)
|
self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI)
|
||||||
self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥")
|
self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥")
|
||||||
self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system")
|
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):
|
def test_tariffs_config_missing_uses_legacy_fallback(self):
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -8,7 +9,10 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from bot.app.web import subscription_webapp
|
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 bot.app.web.webapp import assets as webapp_assets
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from config.webapp_themes_config import builtin_webapp_themes_config
|
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), "")
|
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):
|
def test_initial_theme_head_markup_includes_css_and_tokens(self):
|
||||||
cfg = builtin_webapp_themes_config("#123456")
|
cfg = builtin_webapp_themes_config("#123456")
|
||||||
theme = cfg.theme_by_key("light")
|
theme = cfg.theme_by_key("light")
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
("GET", "/api/admin/themes"): "admin_themes_get_route",
|
||||||
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
("PUT", "/api/admin/themes"): "admin_themes_save_route",
|
||||||
("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_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",
|
("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")
|
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):
|
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def _settings(self):
|
def _settings(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user