refactor: brand logo customization and emoji font selection
This commit is contained in:
@@ -81,6 +81,24 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"string",
|
||||
"appearance",
|
||||
"Шрифт эмоджи-логотипа",
|
||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||
choices=(
|
||||
("system", "Системный (по умолчанию)"),
|
||||
("noto-color", "Noto Color Emoji"),
|
||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||
("noto-emoji", "Noto Emoji"),
|
||||
("twemoji", "Twitter Emoji"),
|
||||
("openmoji", "OpenMoji"),
|
||||
("apple", "Apple Color Emoji (local)"),
|
||||
("segoe", "Segoe UI Emoji (local)"),
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
|
||||
# ─── Subscription periods & pricing ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
@@ -488,21 +506,22 @@ def manifest_payload() -> List[dict]:
|
||||
for field in SETTINGS_MANIFEST:
|
||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||
items.append(
|
||||
{
|
||||
"key": field.key,
|
||||
"type": field.type,
|
||||
"section": field.section,
|
||||
"section_order": sections_order.get(field.section, 99),
|
||||
"subsection": field.subsection,
|
||||
"label": field.label,
|
||||
"description": field.description,
|
||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||
"i18n_description_key": field.i18n_description_key
|
||||
or (auto_description_i18n_key if field.description else None),
|
||||
"placeholder": field.placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
)
|
||||
item = {
|
||||
"key": field.key,
|
||||
"type": field.type,
|
||||
"section": field.section,
|
||||
"section_order": sections_order.get(field.section, 99),
|
||||
"subsection": field.subsection,
|
||||
"label": field.label,
|
||||
"description": field.description,
|
||||
"i18n_label_key": field.i18n_label_key or auto_label_i18n_key,
|
||||
"i18n_description_key": field.i18n_description_key
|
||||
or (auto_description_i18n_key if field.description else None),
|
||||
"placeholder": field.placeholder,
|
||||
"optional": field.optional,
|
||||
"secret": field.secret,
|
||||
}
|
||||
if field.choices:
|
||||
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import BrandMark from "./BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import PreviewBoard from "./PreviewBoard.svelte";
|
||||
import AdminPanel from "./admin/AdminPanel.svelte";
|
||||
import WebAppShell from "./webapp/WebAppShell.svelte";
|
||||
@@ -29,7 +29,12 @@
|
||||
WEBAPP_LANGUAGE_ORDER,
|
||||
} from "./lib/webapp/constants.js";
|
||||
|
||||
import { applyFavicon, readJsonScript, structuredCloneSafe } from "./lib/webapp/browser.js";
|
||||
import {
|
||||
applyFavicon,
|
||||
normalizeBrand,
|
||||
readJsonScript,
|
||||
structuredCloneSafe,
|
||||
} from "./lib/webapp/browser.js";
|
||||
import { createApiClient } from "./lib/webapp/publicApi.js";
|
||||
import { createI18n } from "./lib/webapp/i18n.js";
|
||||
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
||||
@@ -241,6 +246,13 @@
|
||||
|
||||
$: brandTitle = CFG.title || "/minishop";
|
||||
$: brandEmoji = CFG.logoEmoji || "🫥";
|
||||
$: brandEmojiFont = CFG.logoEmojiFont || "system";
|
||||
$: brand = normalizeBrand({
|
||||
title: brandTitle,
|
||||
logoUrl: CFG.logoUrl,
|
||||
emoji: brandEmoji,
|
||||
emojiFont: brandEmojiFont,
|
||||
});
|
||||
$: accent = CFG.primaryColor || "#00fe7a";
|
||||
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
|
||||
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
|
||||
@@ -344,7 +356,7 @@
|
||||
: telegramLoginUnavailable
|
||||
? t("wa_auth_telegram_not_configured")
|
||||
: "";
|
||||
$: applyFavicon(CFG.logoUrl, brandEmoji);
|
||||
$: applyFavicon(brand);
|
||||
$: syncBodyScrollLock(
|
||||
paymentModalOpen ||
|
||||
changeModalOpen ||
|
||||
@@ -903,7 +915,7 @@
|
||||
<div class="app-shell" style={`--accent: ${accent};`}>
|
||||
{#if mode === "loading"}
|
||||
<div class="loader">
|
||||
<BrandMark class="brand-mark-lg" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="md" />
|
||||
<div>{t("wa_loading")}</div>
|
||||
</div>
|
||||
{:else if mode === "login"}
|
||||
@@ -911,7 +923,7 @@
|
||||
{screen}
|
||||
{CFG}
|
||||
{brandTitle}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
bind:email={$authStore.email}
|
||||
bind:emailCode={$authStore.emailCode}
|
||||
{pendingEmail}
|
||||
@@ -952,8 +964,7 @@
|
||||
onSettingsSaved={handleAdminPersistedSaved}
|
||||
onTariffsSaved={handleAdminPersistedSaved}
|
||||
{brandTitle}
|
||||
logoUrl={CFG.logoUrl}
|
||||
logoEmoji={brandEmoji}
|
||||
{brand}
|
||||
appVersion={CFG.appVersion}
|
||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||
{currentLang}
|
||||
@@ -966,9 +977,8 @@
|
||||
<WebAppShell
|
||||
{screen}
|
||||
{activeTab}
|
||||
{CFG}
|
||||
{brandTitle}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
{devicesEnabled}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
@@ -981,9 +991,8 @@
|
||||
>
|
||||
{#if screen === "home"}
|
||||
<HomeScreen
|
||||
{CFG}
|
||||
{appSettings}
|
||||
{brandEmoji}
|
||||
{brand}
|
||||
{brandTitle}
|
||||
{canChangeTariff}
|
||||
{currentTariffName}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<script>
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 2600;
|
||||
|
||||
export let logoUrl = "";
|
||||
export let emoji = "🫥";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
|
||||
$: normalizedLogoUrl = String(logoUrl || "").trim();
|
||||
$: normalizedEmoji = String(emoji || "🫥").trim() || "🫥";
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
|
||||
onDestroy(clearLogoLoadTimeout);
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
on:load={() => (loaded = true)}
|
||||
on:error={() => (failed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span>{normalizedEmoji}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
import { Select } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import AdsSection from "./sections/AdsSection.svelte";
|
||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||
import LogsSection from "./sections/LogsSection.svelte";
|
||||
@@ -71,9 +71,8 @@
|
||||
export let onSectionChange = () => {};
|
||||
export let onSettingsSaved = () => {};
|
||||
export let onTariffsSaved = () => {};
|
||||
export let brand = {};
|
||||
export let brandTitle = "/minishop";
|
||||
export let logoUrl = "";
|
||||
export let logoEmoji = "🫥";
|
||||
export let appVersion = "dev+local";
|
||||
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
||||
export let currentLang = "ru";
|
||||
@@ -180,8 +179,7 @@
|
||||
|
||||
function readReduceMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,7 +421,7 @@
|
||||
|
||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||
<div class="admin-sidebar-brand">
|
||||
<BrandMark class="admin-brand-mark" {logoUrl} emoji={logoEmoji} />
|
||||
<BrandMark class="admin-brand-mark" {brand} />
|
||||
<div>
|
||||
<strong class="admin-brand-title">{brandTitle}</strong>
|
||||
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script>
|
||||
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
|
||||
import { Accordion, Switch } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
export let at;
|
||||
@@ -162,6 +167,15 @@
|
||||
value={valueFor(field) || ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if field.choices && field.choices.length > 0}
|
||||
<AdminSelect
|
||||
class="admin-setting-select"
|
||||
value={valueFor(field) || ""}
|
||||
items={field.choices}
|
||||
ariaLabel={fieldLabelText(field)}
|
||||
placeholder={field.placeholder || fieldLabelText(field)}
|
||||
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
||||
/>
|
||||
{:else if field.type === "int" || field.type === "float"}
|
||||
<input
|
||||
class="input"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import { cn } from "../utils.js";
|
||||
import { animatedEmojiAssetUrls, normalizeBrand } from "./browser.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
const EMOJI_FONT_OPTIONS = {
|
||||
"noto-color": {
|
||||
cssFamily: "Noto Color Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
"noto-emoji": {
|
||||
cssFamily: "Noto Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
twemoji: {
|
||||
cssFamily: "Twemoji Mozilla",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/twemoji-colr-font@15.0.3/twemoji.css",
|
||||
},
|
||||
openmoji: {
|
||||
cssFamily: "OpenMoji Color",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/@openmoji/font@15.1.0/css/openmoji-color.css",
|
||||
},
|
||||
};
|
||||
|
||||
export let brand = {};
|
||||
export let logoUrl = "";
|
||||
export let emoji = "";
|
||||
export let emojiFont = "";
|
||||
export let size = "sm";
|
||||
export let animate = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "",
|
||||
md: "brand-mark-lg",
|
||||
lg: "brand-mark-xl",
|
||||
xl: "brand-mark-xl",
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
let fontLoaded = false;
|
||||
let loadedFontKey = "";
|
||||
let animatedEmojiError = false;
|
||||
let animatedEmojiStaticFallback = false;
|
||||
let lastAnimatedEmoji = "";
|
||||
|
||||
$: normalizedBrand = normalizeBrand({
|
||||
...brand,
|
||||
logoUrl: logoUrl || brand?.logoUrl,
|
||||
emoji: emoji || brand?.emoji || brand?.logoEmoji,
|
||||
emojiFont: emojiFont || brand?.emojiFont || brand?.logoEmojiFont,
|
||||
});
|
||||
$: normalizedLogoUrl = normalizedBrand.logoUrl;
|
||||
$: normalizedEmoji = normalizedBrand.emoji;
|
||||
$: normalizedEmojiFont = normalizedBrand.emojiFont;
|
||||
$: sizeClass = SIZE_CLASSES[size] || "";
|
||||
$: animatedEmojiAssets = animatedEmojiAssetUrls(normalizedEmoji);
|
||||
$: animatedEmojiSrc = animatedEmojiAssets.gif;
|
||||
$: animatedEmojiFallbackSrc = animatedEmojiAssets.webp;
|
||||
$: useAnimatedEmoji =
|
||||
!normalizedLogoUrl &&
|
||||
normalizedEmojiFont === "noto-color-animated" &&
|
||||
animatedEmojiSrc &&
|
||||
!animatedEmojiError;
|
||||
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
$: if (`${normalizedEmojiFont}:${normalizedEmoji}` !== lastAnimatedEmoji) {
|
||||
lastAnimatedEmoji = `${normalizedEmojiFont}:${normalizedEmoji}`;
|
||||
animatedEmojiError = false;
|
||||
animatedEmojiStaticFallback = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearLogoLoadTimeout();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
});
|
||||
|
||||
$: if (normalizedEmojiFont && normalizedEmoji) {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
}
|
||||
|
||||
function loadEmojiFont(font, text) {
|
||||
if (typeof document === "undefined") return;
|
||||
if (font === "system" || font === "noto-color-animated" || !font) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
const fontOption = EMOJI_FONT_OPTIONS[font];
|
||||
if (!fontOption) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = font;
|
||||
return;
|
||||
}
|
||||
|
||||
const fontUrl = fontOption.stylesheet(text);
|
||||
const fontKey = `${font}:${text}`;
|
||||
if (loadedFontKey === fontKey) return;
|
||||
|
||||
fontLoaded = false;
|
||||
loadedFontKey = fontKey;
|
||||
|
||||
const existing = document.querySelector(`link[data-brand-emoji-font="${fontKey}"]`);
|
||||
if (existing) {
|
||||
fontLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = fontUrl;
|
||||
link.dataset.brandEmojiFont = fontKey;
|
||||
link.onload = () => {
|
||||
fontLoaded = true;
|
||||
if (document.fonts && fontOption.cssFamily) {
|
||||
document.fonts.load(`1em "${fontOption.cssFamily}"`, text).finally(() => {
|
||||
fontLoaded = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
link.onerror = () => {
|
||||
fontLoaded = true;
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function getEmojiFontClass(font) {
|
||||
if (font === "noto-color") return "emoji-font-noto-color";
|
||||
if (font === "noto-emoji") return "emoji-font-noto-emoji";
|
||||
if (font === "twemoji") return "emoji-font-twemoji";
|
||||
if (font === "openmoji") return "emoji-font-openmoji";
|
||||
if (font === "apple") return "emoji-font-apple";
|
||||
if (font === "segoe") return "emoji-font-segoe";
|
||||
if (font === "noto-local") return "emoji-font-noto-local";
|
||||
return "";
|
||||
}
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
sizeClass,
|
||||
animate && "brand-mark-animate",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:load={() => {
|
||||
loaded = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
on:error={() => {
|
||||
failed = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
/>
|
||||
{:else if useAnimatedEmoji}
|
||||
<img
|
||||
class="brand-mark-animated-emoji loaded"
|
||||
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:error={() => {
|
||||
if (!animatedEmojiStaticFallback && animatedEmojiFallbackSrc) {
|
||||
animatedEmojiStaticFallback = true;
|
||||
} else {
|
||||
animatedEmojiError = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
|
||||
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
|
||||
>
|
||||
{normalizedEmoji}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-mark img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-lg {
|
||||
width: 4.125rem;
|
||||
height: 4.125rem;
|
||||
font-size: 2.875rem;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-xl {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
font-size: 4.375rem;
|
||||
}
|
||||
|
||||
.brand-mark img.brand-mark-animated-emoji {
|
||||
object-fit: contain;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-animate {
|
||||
animation: brand-mark-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-mark-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-mark-spinner::after {
|
||||
content: "";
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: brand-mark-spin 0.8s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes brand-mark-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-mark-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark-emoji {
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(0.02em);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.brand-mark-xl .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.brand-mark-lg .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.emoji-font-noto-color {
|
||||
font-family: "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-emoji {
|
||||
color: var(--accent);
|
||||
font-family: "Noto Emoji", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emoji-font-twemoji {
|
||||
font-family: "Twemoji Mozilla", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-openmoji {
|
||||
font-family: "OpenMoji Color", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-apple {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-segoe {
|
||||
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-local {
|
||||
font-family: "Noto Color Emoji", "Noto Emoji", sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -26,19 +26,57 @@ export function escapeHtml(value) {
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function applyFavicon(logoUrl, emoji) {
|
||||
export function normalizeBrand(brand = {}) {
|
||||
return {
|
||||
title: String(brand.title || "/minishop").trim() || "/minishop",
|
||||
logoUrl: String(brand.logoUrl || "").trim(),
|
||||
emoji: String(brand.emoji || brand.logoEmoji || "🫥").trim() || "🫥",
|
||||
emojiFont: String(brand.emojiFont || brand.logoEmojiFont || "system").trim() || "system",
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiToCodepoints(value) {
|
||||
return Array.from(String(value || "").trim())
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export function animatedEmojiAssetUrls(emoji) {
|
||||
const codepoints = emojiToCodepoints(emoji);
|
||||
if (!codepoints) return { gif: "", webp: "" };
|
||||
return {
|
||||
gif: `/webapp-emoji/${codepoints}/512.gif`,
|
||||
webp: `/webapp-emoji/${codepoints}/512.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
export function brandFaviconHref(brand = {}) {
|
||||
const normalizedBrand = normalizeBrand(brand);
|
||||
if (normalizedBrand.logoUrl) return normalizedBrand.logoUrl;
|
||||
|
||||
if (normalizedBrand.emojiFont === "noto-color-animated") {
|
||||
return animatedEmojiAssetUrls(normalizedBrand.emoji).gif;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedBrand.emoji)}</text></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function applyFavicon(brand = {}) {
|
||||
if (typeof document === "undefined") return;
|
||||
const favicon = document.getElementById("app-favicon");
|
||||
if (!favicon) return;
|
||||
|
||||
const normalizedLogoUrl = String(logoUrl || "").trim();
|
||||
if (normalizedLogoUrl) {
|
||||
favicon.setAttribute("href", normalizedLogoUrl);
|
||||
return;
|
||||
const href = brandFaviconHref(brand);
|
||||
favicon.setAttribute("href", href);
|
||||
if (href.startsWith("data:image/svg+xml")) {
|
||||
favicon.setAttribute("type", "image/svg+xml");
|
||||
} else if (href.endsWith(".gif")) {
|
||||
favicon.setAttribute("type", "image/gif");
|
||||
} else if (href.endsWith(".webp")) {
|
||||
favicon.setAttribute("type", "image/webp");
|
||||
} else {
|
||||
favicon.removeAttribute("type");
|
||||
}
|
||||
|
||||
const normalizedEmoji = String(emoji || "????").trim() || "????";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedEmoji)}</text></svg>`;
|
||||
const encoded = encodeURIComponent(svg);
|
||||
favicon.setAttribute("href", `data:image/svg+xml,${encoded}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const DEV_MOCK = {
|
||||
primaryColor: "#00fe7a",
|
||||
logoUrl: "",
|
||||
logoEmoji: "🫥",
|
||||
logoEmojiFont: "system",
|
||||
apiBase: "/api",
|
||||
supportUrl: "https://t.me/support",
|
||||
privacyPolicyUrl: "https://example.com/privacy",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
}
|
||||
|
||||
.admin-setting-control .input,
|
||||
.admin-setting-control .admin-setting-select,
|
||||
.admin-setting-control input[type="text"],
|
||||
.admin-setting-control input[type="number"] {
|
||||
flex: 1 1 160px;
|
||||
@@ -290,4 +291,4 @@
|
||||
|
||||
.admin-message-actions .admin-btn {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ a {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
font-size: 26px;
|
||||
@@ -2316,4 +2316,4 @@ a {
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
Smartphone,
|
||||
} from "$components/ui/icons.js";
|
||||
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
|
||||
export let activeTab = "home";
|
||||
export let brand = {};
|
||||
export let brandTitle = "";
|
||||
export let devicesEnabled = false;
|
||||
export let hasUnlinkedIdentity = false;
|
||||
export let isAdmin = false;
|
||||
export let logoEmoji = "";
|
||||
export let logoUrl = "";
|
||||
export let onAdmin = () => {};
|
||||
export let onDevices = () => {};
|
||||
export let onHome = () => {};
|
||||
@@ -26,7 +25,7 @@
|
||||
|
||||
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
|
||||
<div class="rail-brand" aria-hidden="true">
|
||||
<BrandMark {logoUrl} emoji={logoEmoji} />
|
||||
<BrandMark {brand} />
|
||||
<strong>{brandTitle}</strong>
|
||||
</div>
|
||||
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script>
|
||||
import BrandMark from "../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import BottomNav from "./BottomNav.svelte";
|
||||
|
||||
export let screen;
|
||||
export let activeTab;
|
||||
export let CFG;
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let brandEmoji;
|
||||
export let devicesEnabled;
|
||||
export let hasUnlinkedIdentity;
|
||||
export let isAdmin;
|
||||
@@ -22,7 +21,7 @@
|
||||
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
||||
<header class="app-header accent-title">
|
||||
<div class="brand-row">
|
||||
<BrandMark logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} />
|
||||
<strong>{brandTitle}</strong>
|
||||
</div>
|
||||
</header>
|
||||
@@ -32,12 +31,11 @@
|
||||
|
||||
<BottomNav
|
||||
{activeTab}
|
||||
{brand}
|
||||
{brandTitle}
|
||||
{devicesEnabled}
|
||||
{hasUnlinkedIdentity}
|
||||
{isAdmin}
|
||||
logoEmoji={brandEmoji}
|
||||
logoUrl={CFG.logoUrl}
|
||||
onAdmin={openAdminPanel}
|
||||
onDevices={goDevices}
|
||||
onHome={goHome}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import BrandMark from "../../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import Input from "$components/ui/input.svelte";
|
||||
import Spinner from "$components/ui/spinner.svelte";
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
export let screen;
|
||||
export let CFG;
|
||||
export let brand = {};
|
||||
export let brandTitle;
|
||||
export let brandEmoji;
|
||||
export let email;
|
||||
export let emailCode;
|
||||
export let pendingEmail;
|
||||
@@ -87,7 +87,7 @@
|
||||
{:else}
|
||||
<div class="auth-card-wrap">
|
||||
<div class="login-brand login-brand-auth">
|
||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="xl" />
|
||||
<h1>{brandTitle}</h1>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
RefreshCw,
|
||||
} from "$components/ui/icons.js";
|
||||
|
||||
import BrandMark from "../../BrandMark.svelte";
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { LinearProgress } from "$components/patterns/webapp/index.js";
|
||||
@@ -25,9 +25,8 @@
|
||||
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
|
||||
} from "../../lib/webapp/traffic.js";
|
||||
|
||||
export let CFG = {};
|
||||
export let appSettings = {};
|
||||
export let brandEmoji = "";
|
||||
export let brand = {};
|
||||
export let brandTitle = "";
|
||||
export let canChangeTariff = false;
|
||||
export let premiumTrafficTopupBarClickable = false;
|
||||
@@ -83,7 +82,7 @@
|
||||
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
||||
<BrandMark {brand} size="xl" />
|
||||
<h1>{brandTitle}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ logger = logging.getLogger(__name__)
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
|
||||
ASSET_DIR = TEMPLATE_PATH.parent
|
||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
||||
WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -72,6 +74,7 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
|
||||
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
||||
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
||||
@@ -179,6 +182,8 @@ def create_subscription_webapp_application(
|
||||
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
await _close_shared_http_session()
|
||||
@@ -220,6 +225,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
app.router.add_get("/health", health_route)
|
||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
|
||||
app.router.add_get("/subscription_webapp.js", js_asset_route)
|
||||
@@ -264,7 +273,8 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme == "https":
|
||||
return WEBAPP_LOGO_PROXY_PATH
|
||||
cache_key = hashlib.sha256(raw_logo_url.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{WEBAPP_LOGO_PROXY_PATH}?v={cache_key}"
|
||||
if parsed_logo_url.scheme in {"http", "data"}:
|
||||
return raw_logo_url
|
||||
if raw_logo_url.startswith("/"):
|
||||
@@ -272,6 +282,39 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _webapp_logo_cache_key(logo_url: str) -> str:
|
||||
return hashlib.sha256(logo_url.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _webapp_logo_disk_paths(logo_url: str) -> Tuple[Path, Path]:
|
||||
cache_key = _webapp_logo_cache_key(logo_url)
|
||||
return WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.bin", WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
|
||||
def _is_proxyable_webapp_logo_url(logo_url: str) -> bool:
|
||||
parsed_logo_url = urlsplit(logo_url)
|
||||
return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname)
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
|
||||
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
|
||||
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
|
||||
codepoints = _emoji_to_codepoints(emoji)
|
||||
if not codepoints or ext not in {"gif", "webp"}:
|
||||
return ""
|
||||
return f"/webapp-emoji/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||
token_prefix = str(bot_token or "").strip().split(":", 1)[0]
|
||||
if not token_prefix.isdigit():
|
||||
@@ -430,38 +473,254 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
if not raw_logo_url:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if parsed_logo_url.scheme != "https" or not parsed_logo_url.hostname:
|
||||
if not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname):
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||
|
||||
source_logo_url = raw_logo_url
|
||||
logo_cache: Optional[Tuple[bytes, str]] = request.app.get("webapp_logo_cache")
|
||||
if logo_cache is None:
|
||||
logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache")
|
||||
if logo_cache is None or logo_cache[0] != source_logo_url:
|
||||
cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"]
|
||||
async with cache_lock:
|
||||
logo_cache = request.app.get("webapp_logo_cache")
|
||||
if logo_cache is None:
|
||||
logo_cache = await _fetch_webapp_logo(source_logo_url)
|
||||
if logo_cache is None or logo_cache[0] != source_logo_url:
|
||||
fetched_logo = await _load_or_fetch_webapp_logo(source_logo_url)
|
||||
logo_cache = (
|
||||
(source_logo_url, fetched_logo[0], fetched_logo[1]) if fetched_logo else None
|
||||
)
|
||||
request.app["webapp_logo_cache"] = logo_cache
|
||||
|
||||
if not logo_cache:
|
||||
raise web.HTTPNotFound(text="webapp_logo_unavailable")
|
||||
|
||||
body, content_type = logo_cache
|
||||
_, body, content_type = logo_cache
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
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()
|
||||
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_not_found")
|
||||
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
async with cache_lock:
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if emoji_cache:
|
||||
emoji_caches[emoji_cache_key] = emoji_cache
|
||||
|
||||
if not emoji_cache:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
|
||||
|
||||
body, content_type = emoji_cache
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||
return
|
||||
|
||||
parsed_logo_url = urlsplit(raw_logo_url)
|
||||
if not parsed_logo_url.hostname or not await _hostname_resolves_to_public_address(
|
||||
parsed_logo_url.hostname
|
||||
):
|
||||
return
|
||||
|
||||
cache_lock: asyncio.Lock = app["webapp_logo_cache_lock"]
|
||||
async with cache_lock:
|
||||
logo_cache: Optional[Tuple[str, bytes, str]] = app.get("webapp_logo_cache")
|
||||
if logo_cache and logo_cache[0] == raw_logo_url:
|
||||
return
|
||||
loaded_logo = await _load_or_fetch_webapp_logo(raw_logo_url)
|
||||
app["webapp_logo_cache"] = (
|
||||
(raw_logo_url, loaded_logo[0], loaded_logo[1]) if loaded_logo else None
|
||||
)
|
||||
|
||||
|
||||
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
|
||||
return
|
||||
|
||||
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
|
||||
if not codepoints:
|
||||
return
|
||||
|
||||
app.setdefault("webapp_emoji_cache", {})
|
||||
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
|
||||
|
||||
for ext in ("gif", "webp"):
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
if emoji_cache_key in emoji_caches:
|
||||
continue
|
||||
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if loaded_emoji:
|
||||
emoji_caches[emoji_cache_key] = loaded_emoji
|
||||
if ext == "gif":
|
||||
return
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_animated_emoji(
|
||||
codepoints: str, ext: str
|
||||
) -> Optional[Tuple[bytes, str]]:
|
||||
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
|
||||
if disk_emoji:
|
||||
return disk_emoji
|
||||
|
||||
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if fetched_emoji:
|
||||
await asyncio.to_thread(
|
||||
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
|
||||
)
|
||||
return fetched_emoji
|
||||
|
||||
|
||||
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
body = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return None
|
||||
return body, "image/gif" if ext == "gif" else "image/webp"
|
||||
|
||||
|
||||
def _write_webapp_animated_emoji_to_disk(
|
||||
codepoints: str, ext: str, emoji: Tuple[bytes, str]
|
||||
) -> None:
|
||||
body, _content_type = emoji
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return
|
||||
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(body)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
|
||||
|
||||
|
||||
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
try:
|
||||
session = await _get_shared_http_session()
|
||||
timeout = ClientTimeout(total=4)
|
||||
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
|
||||
async with session.get(
|
||||
source_url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
|
||||
if content_type and content_type != expected_content_type:
|
||||
return None
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
return bytes(body), expected_content_type
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
|
||||
if disk_logo:
|
||||
return disk_logo
|
||||
|
||||
fetched_logo = await _fetch_webapp_logo(logo_url)
|
||||
if fetched_logo:
|
||||
await asyncio.to_thread(_write_webapp_logo_to_disk, logo_url, fetched_logo)
|
||||
return fetched_logo
|
||||
|
||||
|
||||
def _read_webapp_logo_from_disk(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
body_path, meta_path = _webapp_logo_disk_paths(logo_url)
|
||||
try:
|
||||
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
if metadata.get("source_url") != logo_url:
|
||||
return None
|
||||
content_type = str(metadata.get("content_type") or "").strip().lower()
|
||||
if not content_type.startswith("image/"):
|
||||
return None
|
||||
body = body_path.read_bytes()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
return None
|
||||
return body, content_type
|
||||
|
||||
|
||||
def _write_webapp_logo_to_disk(logo_url: str, logo: Tuple[bytes, str]) -> None:
|
||||
body, content_type = logo
|
||||
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
|
||||
return
|
||||
|
||||
body_path, meta_path = _webapp_logo_disk_paths(logo_url)
|
||||
try:
|
||||
WEBAPP_LOGO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
body_path.write_bytes(body)
|
||||
meta_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source_url": logo_url,
|
||||
"content_type": content_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"bytes": len(body),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write WEBAPP_LOGO_URL cache: %s", exc)
|
||||
|
||||
|
||||
async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Fetch and cache the configured logo on the server side."""
|
||||
try:
|
||||
session = await _get_shared_http_session()
|
||||
timeout = ClientTimeout(total=3)
|
||||
async with session.get(logo_url, allow_redirects=False, timeout=timeout) as response:
|
||||
async with session.get(
|
||||
logo_url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(
|
||||
"WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.",
|
||||
@@ -504,7 +763,7 @@ async def _get_shared_http_session() -> ClientSession:
|
||||
timeout=ClientTimeout(total=30),
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
||||
"Accept": "*/*",
|
||||
},
|
||||
)
|
||||
return _SHARED_HTTP_SESSION
|
||||
@@ -582,8 +841,8 @@ async def _security_headers_middleware(request: web.Request, handler):
|
||||
f"script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://telegram.org; "
|
||||
"frame-src https://oauth.telegram.org; "
|
||||
"frame-ancestors https://web.telegram.org https://t.me; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' https://cdn.jsdelivr.net data:; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
|
||||
"img-src 'self' data: https: http:; "
|
||||
"connect-src 'self' https://oauth.telegram.org; "
|
||||
"object-src 'none'; "
|
||||
@@ -782,6 +1041,7 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||
"logoUrl": cached["logo_url"],
|
||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||
"apiBase": "/api",
|
||||
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
|
||||
@@ -821,6 +1081,19 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
WEBAPP_JS_PLACEHOLDER,
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated":
|
||||
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
|
||||
if brand_asset_url:
|
||||
html = html.replace(
|
||||
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">',
|
||||
f'<link rel="preload" href="{brand_asset_url}" as="image" fetchpriority="high" crossorigin="anonymous">',
|
||||
)
|
||||
else:
|
||||
html = html.replace(
|
||||
'<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">',
|
||||
"",
|
||||
)
|
||||
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||
<title>/minishop</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||
<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user