refactor: brand logo customization and emoji font selection
This commit is contained in:
+2
-1
@@ -30,5 +30,6 @@ __pycache__/
|
|||||||
locales/ru_backup.json
|
locales/ru_backup.json
|
||||||
locales/en_backup.json
|
locales/en_backup.json
|
||||||
db/models_old.py
|
db/models_old.py
|
||||||
data/tariffs.json
|
data/*
|
||||||
|
!data/tariffs.example.json
|
||||||
docker-compose-dev.yml
|
docker-compose-dev.yml
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
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 включён"),
|
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 месяц"),
|
||||||
@@ -488,8 +506,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
for field in SETTINGS_MANIFEST:
|
for field in SETTINGS_MANIFEST:
|
||||||
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
|
||||||
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
|
||||||
items.append(
|
item = {
|
||||||
{
|
|
||||||
"key": field.key,
|
"key": field.key,
|
||||||
"type": field.type,
|
"type": field.type,
|
||||||
"section": field.section,
|
"section": field.section,
|
||||||
@@ -504,5 +521,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
"optional": field.optional,
|
"optional": field.optional,
|
||||||
"secret": field.secret,
|
"secret": field.secret,
|
||||||
}
|
}
|
||||||
)
|
if field.choices:
|
||||||
|
item["choices"] = [{"value": v, "label": lbl} for v, lbl in field.choices]
|
||||||
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||||
import { Tooltip } from "$components/ui/primitives.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 PreviewBoard from "./PreviewBoard.svelte";
|
||||||
import AdminPanel from "./admin/AdminPanel.svelte";
|
import AdminPanel from "./admin/AdminPanel.svelte";
|
||||||
import WebAppShell from "./webapp/WebAppShell.svelte";
|
import WebAppShell from "./webapp/WebAppShell.svelte";
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
WEBAPP_LANGUAGE_ORDER,
|
WEBAPP_LANGUAGE_ORDER,
|
||||||
} from "./lib/webapp/constants.js";
|
} 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 { createApiClient } from "./lib/webapp/publicApi.js";
|
||||||
import { createI18n } from "./lib/webapp/i18n.js";
|
import { createI18n } from "./lib/webapp/i18n.js";
|
||||||
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
||||||
@@ -241,6 +246,13 @@
|
|||||||
|
|
||||||
$: brandTitle = CFG.title || "/minishop";
|
$: brandTitle = CFG.title || "/minishop";
|
||||||
$: brandEmoji = CFG.logoEmoji || "🫥";
|
$: brandEmoji = CFG.logoEmoji || "🫥";
|
||||||
|
$: brandEmojiFont = CFG.logoEmojiFont || "system";
|
||||||
|
$: brand = normalizeBrand({
|
||||||
|
title: brandTitle,
|
||||||
|
logoUrl: CFG.logoUrl,
|
||||||
|
emoji: brandEmoji,
|
||||||
|
emojiFont: brandEmojiFont,
|
||||||
|
});
|
||||||
$: accent = CFG.primaryColor || "#00fe7a";
|
$: accent = CFG.primaryColor || "#00fe7a";
|
||||||
$: 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 : [];
|
||||||
@@ -344,7 +356,7 @@
|
|||||||
: telegramLoginUnavailable
|
: telegramLoginUnavailable
|
||||||
? t("wa_auth_telegram_not_configured")
|
? t("wa_auth_telegram_not_configured")
|
||||||
: "";
|
: "";
|
||||||
$: applyFavicon(CFG.logoUrl, brandEmoji);
|
$: applyFavicon(brand);
|
||||||
$: syncBodyScrollLock(
|
$: syncBodyScrollLock(
|
||||||
paymentModalOpen ||
|
paymentModalOpen ||
|
||||||
changeModalOpen ||
|
changeModalOpen ||
|
||||||
@@ -903,7 +915,7 @@
|
|||||||
<div class="app-shell" style={`--accent: ${accent};`}>
|
<div class="app-shell" style={`--accent: ${accent};`}>
|
||||||
{#if mode === "loading"}
|
{#if mode === "loading"}
|
||||||
<div class="loader">
|
<div class="loader">
|
||||||
<BrandMark class="brand-mark-lg" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
<BrandMark {brand} size="md" />
|
||||||
<div>{t("wa_loading")}</div>
|
<div>{t("wa_loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if mode === "login"}
|
{:else if mode === "login"}
|
||||||
@@ -911,7 +923,7 @@
|
|||||||
{screen}
|
{screen}
|
||||||
{CFG}
|
{CFG}
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
{brandEmoji}
|
{brand}
|
||||||
bind:email={$authStore.email}
|
bind:email={$authStore.email}
|
||||||
bind:emailCode={$authStore.emailCode}
|
bind:emailCode={$authStore.emailCode}
|
||||||
{pendingEmail}
|
{pendingEmail}
|
||||||
@@ -952,8 +964,7 @@
|
|||||||
onSettingsSaved={handleAdminPersistedSaved}
|
onSettingsSaved={handleAdminPersistedSaved}
|
||||||
onTariffsSaved={handleAdminPersistedSaved}
|
onTariffsSaved={handleAdminPersistedSaved}
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
logoUrl={CFG.logoUrl}
|
{brand}
|
||||||
logoEmoji={brandEmoji}
|
|
||||||
appVersion={CFG.appVersion}
|
appVersion={CFG.appVersion}
|
||||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||||
{currentLang}
|
{currentLang}
|
||||||
@@ -966,9 +977,8 @@
|
|||||||
<WebAppShell
|
<WebAppShell
|
||||||
{screen}
|
{screen}
|
||||||
{activeTab}
|
{activeTab}
|
||||||
{CFG}
|
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
{brandEmoji}
|
{brand}
|
||||||
{devicesEnabled}
|
{devicesEnabled}
|
||||||
{hasUnlinkedIdentity}
|
{hasUnlinkedIdentity}
|
||||||
{isAdmin}
|
{isAdmin}
|
||||||
@@ -981,9 +991,8 @@
|
|||||||
>
|
>
|
||||||
{#if screen === "home"}
|
{#if screen === "home"}
|
||||||
<HomeScreen
|
<HomeScreen
|
||||||
{CFG}
|
|
||||||
{appSettings}
|
{appSettings}
|
||||||
{brandEmoji}
|
{brand}
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
{canChangeTariff}
|
{canChangeTariff}
|
||||||
{currentTariffName}
|
{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 { Select } from "$components/ui/primitives.js";
|
||||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.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 AdsSection from "./sections/AdsSection.svelte";
|
||||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||||
import LogsSection from "./sections/LogsSection.svelte";
|
import LogsSection from "./sections/LogsSection.svelte";
|
||||||
@@ -71,9 +71,8 @@
|
|||||||
export let onSectionChange = () => {};
|
export let onSectionChange = () => {};
|
||||||
export let onSettingsSaved = () => {};
|
export let onSettingsSaved = () => {};
|
||||||
export let onTariffsSaved = () => {};
|
export let onTariffsSaved = () => {};
|
||||||
|
export let brand = {};
|
||||||
export let brandTitle = "/minishop";
|
export let brandTitle = "/minishop";
|
||||||
export let logoUrl = "";
|
|
||||||
export let logoEmoji = "🫥";
|
|
||||||
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";
|
||||||
@@ -180,8 +179,7 @@
|
|||||||
|
|
||||||
function readReduceMotion() {
|
function readReduceMotion() {
|
||||||
return (
|
return (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +421,7 @@
|
|||||||
|
|
||||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||||
<div class="admin-sidebar-brand">
|
<div class="admin-sidebar-brand">
|
||||||
<BrandMark class="admin-brand-mark" {logoUrl} emoji={logoEmoji} />
|
<BrandMark class="admin-brand-mark" {brand} />
|
||||||
<div>
|
<div>
|
||||||
<strong class="admin-brand-title">{brandTitle}</strong>
|
<strong class="admin-brand-title">{brandTitle}</strong>
|
||||||
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
|
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
|
||||||
import { Accordion, Switch } from "$components/ui/primitives.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";
|
import { getContext, onMount } from "svelte";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
@@ -162,6 +167,15 @@
|
|||||||
value={valueFor(field) || ""}
|
value={valueFor(field) || ""}
|
||||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
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"}
|
{:else if field.type === "int" || field.type === "float"}
|
||||||
<input
|
<input
|
||||||
class="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("'", "'");
|
.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;
|
if (typeof document === "undefined") return;
|
||||||
const favicon = document.getElementById("app-favicon");
|
const favicon = document.getElementById("app-favicon");
|
||||||
if (!favicon) return;
|
if (!favicon) return;
|
||||||
|
|
||||||
const normalizedLogoUrl = String(logoUrl || "").trim();
|
const href = brandFaviconHref(brand);
|
||||||
if (normalizedLogoUrl) {
|
favicon.setAttribute("href", href);
|
||||||
favicon.setAttribute("href", normalizedLogoUrl);
|
if (href.startsWith("data:image/svg+xml")) {
|
||||||
return;
|
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",
|
primaryColor: "#00fe7a",
|
||||||
logoUrl: "",
|
logoUrl: "",
|
||||||
logoEmoji: "🫥",
|
logoEmoji: "🫥",
|
||||||
|
logoEmojiFont: "system",
|
||||||
apiBase: "/api",
|
apiBase: "/api",
|
||||||
supportUrl: "https://t.me/support",
|
supportUrl: "https://t.me/support",
|
||||||
privacyPolicyUrl: "https://example.com/privacy",
|
privacyPolicyUrl: "https://example.com/privacy",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-setting-control .input,
|
.admin-setting-control .input,
|
||||||
|
.admin-setting-control .admin-setting-select,
|
||||||
.admin-setting-control input[type="text"],
|
.admin-setting-control input[type="text"],
|
||||||
.admin-setting-control input[type="number"] {
|
.admin-setting-control input[type="number"] {
|
||||||
flex: 1 1 160px;
|
flex: 1 1 160px;
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ a {
|
|||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
|
|||||||
@@ -7,15 +7,14 @@
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
} from "$components/ui/icons.js";
|
} from "$components/ui/icons.js";
|
||||||
|
|
||||||
import BrandMark from "../BrandMark.svelte";
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||||
|
|
||||||
export let activeTab = "home";
|
export let activeTab = "home";
|
||||||
|
export let brand = {};
|
||||||
export let brandTitle = "";
|
export let brandTitle = "";
|
||||||
export let devicesEnabled = false;
|
export let devicesEnabled = false;
|
||||||
export let hasUnlinkedIdentity = false;
|
export let hasUnlinkedIdentity = false;
|
||||||
export let isAdmin = false;
|
export let isAdmin = false;
|
||||||
export let logoEmoji = "";
|
|
||||||
export let logoUrl = "";
|
|
||||||
export let onAdmin = () => {};
|
export let onAdmin = () => {};
|
||||||
export let onDevices = () => {};
|
export let onDevices = () => {};
|
||||||
export let onHome = () => {};
|
export let onHome = () => {};
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
|
|
||||||
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
|
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
|
||||||
<div class="rail-brand" aria-hidden="true">
|
<div class="rail-brand" aria-hidden="true">
|
||||||
<BrandMark {logoUrl} emoji={logoEmoji} />
|
<BrandMark {brand} />
|
||||||
<strong>{brandTitle}</strong>
|
<strong>{brandTitle}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
|
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import BrandMark from "../BrandMark.svelte";
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||||
import BottomNav from "./BottomNav.svelte";
|
import BottomNav from "./BottomNav.svelte";
|
||||||
|
|
||||||
export let screen;
|
export let screen;
|
||||||
export let activeTab;
|
export let activeTab;
|
||||||
export let CFG;
|
export let brand = {};
|
||||||
export let brandTitle;
|
export let brandTitle;
|
||||||
export let brandEmoji;
|
|
||||||
export let devicesEnabled;
|
export let devicesEnabled;
|
||||||
export let hasUnlinkedIdentity;
|
export let hasUnlinkedIdentity;
|
||||||
export let isAdmin;
|
export let isAdmin;
|
||||||
@@ -22,7 +21,7 @@
|
|||||||
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
{#if screen === "invite" || screen === "devices" || screen === "settings"}
|
||||||
<header class="app-header accent-title">
|
<header class="app-header accent-title">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<BrandMark logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
<BrandMark {brand} />
|
||||||
<strong>{brandTitle}</strong>
|
<strong>{brandTitle}</strong>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -32,12 +31,11 @@
|
|||||||
|
|
||||||
<BottomNav
|
<BottomNav
|
||||||
{activeTab}
|
{activeTab}
|
||||||
|
{brand}
|
||||||
{brandTitle}
|
{brandTitle}
|
||||||
{devicesEnabled}
|
{devicesEnabled}
|
||||||
{hasUnlinkedIdentity}
|
{hasUnlinkedIdentity}
|
||||||
{isAdmin}
|
{isAdmin}
|
||||||
logoEmoji={brandEmoji}
|
|
||||||
logoUrl={CFG.logoUrl}
|
|
||||||
onAdmin={openAdminPanel}
|
onAdmin={openAdminPanel}
|
||||||
onDevices={goDevices}
|
onDevices={goDevices}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Tooltip } from "$components/ui/primitives.js";
|
import { Tooltip } from "$components/ui/primitives.js";
|
||||||
|
|
||||||
import Button from "$components/ui/button.svelte";
|
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 Card from "$components/ui/card.svelte";
|
||||||
import Input from "$components/ui/input.svelte";
|
import Input from "$components/ui/input.svelte";
|
||||||
import Spinner from "$components/ui/spinner.svelte";
|
import Spinner from "$components/ui/spinner.svelte";
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
export let screen;
|
export let screen;
|
||||||
export let CFG;
|
export let CFG;
|
||||||
|
export let brand = {};
|
||||||
export let brandTitle;
|
export let brandTitle;
|
||||||
export let brandEmoji;
|
|
||||||
export let email;
|
export let email;
|
||||||
export let emailCode;
|
export let emailCode;
|
||||||
export let pendingEmail;
|
export let pendingEmail;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="auth-card-wrap">
|
<div class="auth-card-wrap">
|
||||||
<div class="login-brand login-brand-auth">
|
<div class="login-brand login-brand-auth">
|
||||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
<BrandMark {brand} size="xl" />
|
||||||
<h1>{brandTitle}</h1>
|
<h1>{brandTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card class="auth-card">
|
<Card class="auth-card">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "$components/ui/icons.js";
|
} 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 Button from "$components/ui/button.svelte";
|
||||||
import Card from "$components/ui/card.svelte";
|
import Card from "$components/ui/card.svelte";
|
||||||
import { LinearProgress } from "$components/patterns/webapp/index.js";
|
import { LinearProgress } from "$components/patterns/webapp/index.js";
|
||||||
@@ -25,9 +25,8 @@
|
|||||||
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
|
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
|
||||||
} from "../../lib/webapp/traffic.js";
|
} from "../../lib/webapp/traffic.js";
|
||||||
|
|
||||||
export let CFG = {};
|
|
||||||
export let appSettings = {};
|
export let appSettings = {};
|
||||||
export let brandEmoji = "";
|
export let brand = {};
|
||||||
export let brandTitle = "";
|
export let brandTitle = "";
|
||||||
export let canChangeTariff = false;
|
export let canChangeTariff = false;
|
||||||
export let premiumTrafficTopupBarClickable = false;
|
export let premiumTrafficTopupBarClickable = false;
|
||||||
@@ -83,7 +82,7 @@
|
|||||||
|
|
||||||
<main class="home-layout">
|
<main class="home-layout">
|
||||||
<div class="login-brand home-brand">
|
<div class="login-brand home-brand">
|
||||||
<BrandMark class="brand-mark-xl" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
|
<BrandMark {brand} size="xl" />
|
||||||
<h1>{brandTitle}</h1>
|
<h1>{brandTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ logger = logging.getLogger(__name__)
|
|||||||
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
|
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
|
||||||
ASSET_DIR = TEMPLATE_PATH.parent
|
ASSET_DIR = TEMPLATE_PATH.parent
|
||||||
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
|
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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_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_WINDOW_SECONDS = 60
|
||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||||
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
|
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
|
||||||
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
|
||||||
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
|
||||||
@@ -179,6 +182,8 @@ def create_subscription_webapp_application(
|
|||||||
|
|
||||||
async def _startup(app_obj: web.Application) -> None:
|
async def _startup(app_obj: web.Application) -> None:
|
||||||
await _ensure_shared_http_session()
|
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:
|
async def _shutdown(app_obj: web.Application) -> None:
|
||||||
await _close_shared_http_session()
|
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("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||||
app.router.add_get("/health", health_route)
|
app.router.add_get("/health", health_route)
|
||||||
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_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.css", css_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_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)
|
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)
|
parsed_logo_url = urlsplit(raw_logo_url)
|
||||||
if parsed_logo_url.scheme == "https":
|
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"}:
|
if parsed_logo_url.scheme in {"http", "data"}:
|
||||||
return raw_logo_url
|
return raw_logo_url
|
||||||
if raw_logo_url.startswith("/"):
|
if raw_logo_url.startswith("/"):
|
||||||
@@ -272,6 +282,39 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
|
|||||||
return ""
|
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]:
|
def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]:
|
||||||
token_prefix = str(bot_token or "").strip().split(":", 1)[0]
|
token_prefix = str(bot_token or "").strip().split(":", 1)[0]
|
||||||
if not token_prefix.isdigit():
|
if not token_prefix.isdigit():
|
||||||
@@ -430,38 +473,254 @@ async def webapp_logo_route(request: web.Request) -> web.Response:
|
|||||||
if not raw_logo_url:
|
if not raw_logo_url:
|
||||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||||
|
|
||||||
parsed_logo_url = urlsplit(raw_logo_url)
|
if not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||||
if parsed_logo_url.scheme != "https" or not parsed_logo_url.hostname:
|
|
||||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
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):
|
if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname):
|
||||||
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
raise web.HTTPNotFound(text="webapp_logo_not_proxied")
|
||||||
|
|
||||||
source_logo_url = raw_logo_url
|
source_logo_url = raw_logo_url
|
||||||
logo_cache: Optional[Tuple[bytes, str]] = request.app.get("webapp_logo_cache")
|
logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache")
|
||||||
if logo_cache is None:
|
if logo_cache is None or logo_cache[0] != source_logo_url:
|
||||||
cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"]
|
cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"]
|
||||||
async with cache_lock:
|
async with cache_lock:
|
||||||
logo_cache = request.app.get("webapp_logo_cache")
|
logo_cache = request.app.get("webapp_logo_cache")
|
||||||
if logo_cache is None:
|
if logo_cache is None or logo_cache[0] != source_logo_url:
|
||||||
logo_cache = await _fetch_webapp_logo(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
|
request.app["webapp_logo_cache"] = logo_cache
|
||||||
|
|
||||||
if not logo_cache:
|
if not logo_cache:
|
||||||
raise web.HTTPNotFound(text="webapp_logo_unavailable")
|
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 = 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
|
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]]:
|
async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||||
"""Fetch and cache the configured logo on the server side."""
|
"""Fetch and cache the configured logo on the server side."""
|
||||||
try:
|
try:
|
||||||
session = await _get_shared_http_session()
|
session = await _get_shared_http_session()
|
||||||
timeout = ClientTimeout(total=3)
|
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:
|
if response.status != 200:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.",
|
"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),
|
timeout=ClientTimeout(total=30),
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": "Mozilla/5.0",
|
"User-Agent": "Mozilla/5.0",
|
||||||
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
|
"Accept": "*/*",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return _SHARED_HTTP_SESSION
|
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; "
|
f"script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://telegram.org; "
|
||||||
"frame-src https://oauth.telegram.org; "
|
"frame-src https://oauth.telegram.org; "
|
||||||
"frame-ancestors https://web.telegram.org https://t.me; "
|
"frame-ancestors https://web.telegram.org https://t.me; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
|
||||||
"font-src 'self' https://cdn.jsdelivr.net data:; "
|
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
|
||||||
"img-src 'self' data: https: http:; "
|
"img-src 'self' data: https: http:; "
|
||||||
"connect-src 'self' https://oauth.telegram.org; "
|
"connect-src 'self' https://oauth.telegram.org; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
@@ -782,6 +1041,7 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
|
||||||
"logoUrl": cached["logo_url"],
|
"logoUrl": cached["logo_url"],
|
||||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||||
|
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||||
"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,
|
||||||
@@ -821,6 +1081,19 @@ 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>',
|
||||||
)
|
)
|
||||||
|
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")
|
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">
|
<link id="app-favicon" rel="icon" href="data:," sizes="any">
|
||||||
<title>/minishop</title>
|
<title>/minishop</title>
|
||||||
<link rel="stylesheet" href="/subscription_webapp.css">
|
<link rel="stylesheet" href="/subscription_webapp.css">
|
||||||
|
<link rel="preload" id="logo-preload" href="" as="image" fetchpriority="high" crossorigin="anonymous">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class WebAppSettings(BaseModel):
|
|||||||
primary_color: str
|
primary_color: str
|
||||||
logo_url: Optional[str]
|
logo_url: Optional[str]
|
||||||
logo_emoji: str
|
logo_emoji: str
|
||||||
|
logo_emoji_font: str
|
||||||
session_ttl_seconds: int
|
session_ttl_seconds: int
|
||||||
session_secret: str
|
session_secret: str
|
||||||
webhook_secret_token: str
|
webhook_secret_token: str
|
||||||
@@ -352,6 +353,13 @@ class Settings(BaseSettings):
|
|||||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||||
|
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
||||||
|
default="system",
|
||||||
|
description=(
|
||||||
|
"Emoji font for logo fallback: system, noto-color, noto-color-animated, "
|
||||||
|
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
|
||||||
|
),
|
||||||
|
)
|
||||||
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)
|
||||||
@@ -521,6 +529,7 @@ class Settings(BaseSettings):
|
|||||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||||
logo_url=self.WEBAPP_LOGO_URL,
|
logo_url=self.WEBAPP_LOGO_URL,
|
||||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||||
|
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -84,7 +85,16 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
def test_https_webapp_logo_uses_same_origin_proxy(self):
|
def test_https_webapp_logo_uses_same_origin_proxy(self):
|
||||||
settings = SimpleNamespace(WEBAPP_LOGO_URL="https://cdn.example.com/logo.png")
|
settings = SimpleNamespace(WEBAPP_LOGO_URL="https://cdn.example.com/logo.png")
|
||||||
|
|
||||||
self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "/webapp-logo")
|
self.assertRegex(
|
||||||
|
subscription_webapp._resolve_webapp_logo_url(settings),
|
||||||
|
r"^/webapp-logo\?v=[0-9a-f]{12}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_animated_emoji_asset_path_uses_same_origin_route(self):
|
||||||
|
self.assertEqual(
|
||||||
|
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
|
||||||
|
"/webapp-emoji/1f929/512.gif",
|
||||||
|
)
|
||||||
|
|
||||||
def test_telegram_avatar_url_uses_same_origin_account_route(self):
|
def test_telegram_avatar_url_uses_same_origin_account_route(self):
|
||||||
avatar = SimpleNamespace(
|
avatar = SimpleNamespace(
|
||||||
@@ -210,3 +220,79 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
|
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
|
||||||
)
|
)
|
||||||
self.assertEqual(response.text, "console.log('minified');")
|
self.assertEqual(response.text, "console.log('minified');")
|
||||||
|
|
||||||
|
def test_webapp_logo_disk_cache_roundtrip(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
logo_url = "https://cdn.example.com/logo.png"
|
||||||
|
logo = (b"png-bytes", "image/png")
|
||||||
|
|
||||||
|
with patch.object(subscription_webapp, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)):
|
||||||
|
subscription_webapp._write_webapp_logo_to_disk(logo_url, logo)
|
||||||
|
|
||||||
|
self.assertEqual(subscription_webapp._read_webapp_logo_from_disk(logo_url), logo)
|
||||||
|
|
||||||
|
async def test_warm_webapp_logo_cache_uses_disk_cache(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
logo_url = "https://cdn.example.com/logo.png"
|
||||||
|
logo = (b"cached-logo", "image/png")
|
||||||
|
app = {
|
||||||
|
"settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url),
|
||||||
|
"webapp_logo_cache": None,
|
||||||
|
"webapp_logo_cache_lock": asyncio.Lock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(subscription_webapp, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)),
|
||||||
|
patch.object(
|
||||||
|
subscription_webapp,
|
||||||
|
"_hostname_resolves_to_public_address",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(subscription_webapp, "_fetch_webapp_logo") as fetch_logo,
|
||||||
|
):
|
||||||
|
subscription_webapp._write_webapp_logo_to_disk(logo_url, logo)
|
||||||
|
|
||||||
|
await subscription_webapp._warm_webapp_logo_cache(app)
|
||||||
|
|
||||||
|
fetch_logo.assert_not_called()
|
||||||
|
self.assertEqual(app["webapp_logo_cache"], (logo_url, logo[0], logo[1]))
|
||||||
|
|
||||||
|
def test_webapp_animated_emoji_disk_cache_roundtrip(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(subscription_webapp, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)):
|
||||||
|
subscription_webapp._write_webapp_animated_emoji_to_disk(
|
||||||
|
"1f929",
|
||||||
|
"gif",
|
||||||
|
(b"gif-bytes", "image/gif"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
subscription_webapp._read_webapp_animated_emoji_from_disk("1f929", "gif"),
|
||||||
|
(b"gif-bytes", "image/gif"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_warm_webapp_animated_emoji_cache_uses_disk_cache(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
app = {
|
||||||
|
"settings": SimpleNamespace(
|
||||||
|
WEBAPP_LOGO_EMOJI="🤩",
|
||||||
|
WEBAPP_LOGO_EMOJI_FONT="noto-color-animated",
|
||||||
|
),
|
||||||
|
"webapp_emoji_cache": {},
|
||||||
|
"webapp_emoji_cache_lock": asyncio.Lock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(subscription_webapp, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)),
|
||||||
|
patch.object(subscription_webapp, "_fetch_webapp_animated_emoji") as fetch_emoji,
|
||||||
|
):
|
||||||
|
subscription_webapp._write_webapp_animated_emoji_to_disk(
|
||||||
|
"1f929",
|
||||||
|
"gif",
|
||||||
|
(b"cached-gif", "image/gif"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await subscription_webapp._warm_webapp_animated_emoji_cache(app)
|
||||||
|
|
||||||
|
fetch_emoji.assert_not_called()
|
||||||
|
self.assertEqual(app["webapp_emoji_cache"]["1f929:gif"], (b"cached-gif", "image/gif"))
|
||||||
|
|||||||
Reference in New Issue
Block a user