From b6ee5e8790ff010cb8ccc2c2cfb8be5f5f4b5d6d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 14 May 2026 16:33:50 +0300 Subject: [PATCH 01/45] feat: custom themes initial --- .env.example | 2 + bot/app/web/admin_api.py | 2 + bot/app/web/admin_api_impl/common.py | 4 + bot/app/web/admin_api_impl/routes.py | 2 + bot/app/web/admin_api_impl/themes.py | 63 ++ bot/app/web/frontend/src/App.svelte | 34 +- .../web/frontend/src/admin/AdminPanel.svelte | 15 + .../src/admin/sections/ThemesSection.svelte | 267 +++++ .../src/lib/admin/stores/themesStore.js | 113 +++ .../frontend/src/lib/components/ui/icons.js | 1 + .../web/frontend/src/lib/webapp/constants.js | 1 + .../web/frontend/src/lib/webapp/mockApi.js | 24 + .../frontend/src/lib/webapp/previewMock.js | 98 ++ .../web/frontend/src/lib/webapp/themeStyle.js | 141 +++ bot/app/web/frontend/src/styles/admin.css | 75 +- bot/app/web/frontend/src/styles/base.css | 36 +- .../web/frontend/src/styles/components.css | 17 +- bot/app/web/frontend/src/styles/controls.css | 10 +- bot/app/web/frontend/src/styles/dialogs.css | 8 +- bot/app/web/frontend/src/styles/webapp.css | 175 ++-- bot/app/web/themes/ascii/style.css | 953 ++++++++++++++++++ bot/app/web/themes/ascii/theme.json | 17 + bot/app/web/themes/dark/theme.json | 27 + bot/app/web/themes/light/style.css | 46 + bot/app/web/themes/light/theme.json | 16 + bot/app/web/themes/windows95/icons/README.md | 9 + .../web/themes/windows95/icons/arrow-left.png | Bin 0 -> 610 bytes .../themes/windows95/icons/arrow-right.png | Bin 0 -> 423 bytes .../web/themes/windows95/icons/bitcoin.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/check.png | Bin 0 -> 340 bytes .../web/themes/windows95/icons/chevrons.png | Bin 0 -> 375 bytes bot/app/web/themes/windows95/icons/coins.png | Bin 0 -> 424 bytes .../web/themes/windows95/icons/dashboard.png | Bin 0 -> 371 bytes .../web/themes/windows95/icons/database.png | Bin 0 -> 356 bytes .../web/themes/windows95/icons/download.png | Bin 0 -> 419 bytes bot/app/web/themes/windows95/icons/error.png | Bin 0 -> 372 bytes .../web/themes/windows95/icons/file-text.png | Bin 0 -> 388 bytes bot/app/web/themes/windows95/icons/folder.png | Bin 0 -> 378 bytes bot/app/web/themes/windows95/icons/gift.png | Bin 0 -> 424 bytes bot/app/web/themes/windows95/icons/globe.png | Bin 0 -> 636 bytes bot/app/web/themes/windows95/icons/help.png | Bin 0 -> 364 bytes bot/app/web/themes/windows95/icons/home.png | Bin 0 -> 390 bytes bot/app/web/themes/windows95/icons/info.png | Bin 0 -> 385 bytes bot/app/web/themes/windows95/icons/key.png | Bin 0 -> 415 bytes bot/app/web/themes/windows95/icons/lock.png | Bin 0 -> 356 bytes .../web/themes/windows95/icons/megaphone.png | Bin 0 -> 393 bytes .../web/themes/windows95/icons/paintbrush.png | Bin 0 -> 474 bytes .../themes/windows95/icons/payment-card.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/print.png | Bin 0 -> 461 bytes .../web/themes/windows95/icons/refresh.png | Bin 0 -> 327 bytes bot/app/web/themes/windows95/icons/save.png | Bin 0 -> 411 bytes bot/app/web/themes/windows95/icons/search.png | Bin 0 -> 395 bytes bot/app/web/themes/windows95/icons/send.png | Bin 0 -> 415 bytes .../web/themes/windows95/icons/settings.png | Bin 0 -> 393 bytes bot/app/web/themes/windows95/icons/shield.png | Bin 0 -> 422 bytes .../web/themes/windows95/icons/sliders.png | Bin 0 -> 478 bytes .../web/themes/windows95/icons/smartphone.png | Bin 0 -> 500 bytes .../web/themes/windows95/icons/sparkles.png | Bin 0 -> 589 bytes bot/app/web/themes/windows95/icons/tag.png | Bin 0 -> 392 bytes bot/app/web/themes/windows95/icons/ticket.png | Bin 0 -> 392 bytes bot/app/web/themes/windows95/icons/trash.png | Bin 0 -> 384 bytes bot/app/web/themes/windows95/icons/user.png | Bin 0 -> 419 bytes bot/app/web/themes/windows95/icons/users.png | Bin 0 -> 403 bytes .../web/themes/windows95/icons/warning.png | Bin 0 -> 371 bytes bot/app/web/themes/windows95/icons/x.png | Bin 0 -> 344 bytes bot/app/web/themes/windows95/style.css | 850 ++++++++++++++++ bot/app/web/themes/windows95/theme.json | 17 + bot/app/web/webapp/_runtime.py | 11 + bot/app/web/webapp/assets.py | 119 +++ bot/app/web/webapp/routes.py | 2 + bot/app/web/webapp/serializers.py | 7 + config/settings.py | 26 + config/webapp_themes_config.py | 586 +++++++++++ docs/remnawave-minishop.webp | Bin 0 -> 122284 bytes locales/en.json | 14 + locales/ru.json | 14 + tests/test_webapp_assets.py | 127 +++ tests/test_webapp_route_contract.py | 3 + tests/test_webapp_themes_config.py | 371 +++++++ 79 files changed, 4151 insertions(+), 152 deletions(-) create mode 100644 bot/app/web/admin_api_impl/themes.py create mode 100644 bot/app/web/frontend/src/admin/sections/ThemesSection.svelte create mode 100644 bot/app/web/frontend/src/lib/admin/stores/themesStore.js create mode 100644 bot/app/web/frontend/src/lib/webapp/themeStyle.js create mode 100644 bot/app/web/themes/ascii/style.css create mode 100644 bot/app/web/themes/ascii/theme.json create mode 100644 bot/app/web/themes/dark/theme.json create mode 100644 bot/app/web/themes/light/style.css create mode 100644 bot/app/web/themes/light/theme.json create mode 100644 bot/app/web/themes/windows95/icons/README.md create mode 100644 bot/app/web/themes/windows95/icons/arrow-left.png create mode 100644 bot/app/web/themes/windows95/icons/arrow-right.png create mode 100644 bot/app/web/themes/windows95/icons/bitcoin.png create mode 100644 bot/app/web/themes/windows95/icons/check.png create mode 100644 bot/app/web/themes/windows95/icons/chevrons.png create mode 100644 bot/app/web/themes/windows95/icons/coins.png create mode 100644 bot/app/web/themes/windows95/icons/dashboard.png create mode 100644 bot/app/web/themes/windows95/icons/database.png create mode 100644 bot/app/web/themes/windows95/icons/download.png create mode 100644 bot/app/web/themes/windows95/icons/error.png create mode 100644 bot/app/web/themes/windows95/icons/file-text.png create mode 100644 bot/app/web/themes/windows95/icons/folder.png create mode 100644 bot/app/web/themes/windows95/icons/gift.png create mode 100644 bot/app/web/themes/windows95/icons/globe.png create mode 100644 bot/app/web/themes/windows95/icons/help.png create mode 100644 bot/app/web/themes/windows95/icons/home.png create mode 100644 bot/app/web/themes/windows95/icons/info.png create mode 100644 bot/app/web/themes/windows95/icons/key.png create mode 100644 bot/app/web/themes/windows95/icons/lock.png create mode 100644 bot/app/web/themes/windows95/icons/megaphone.png create mode 100644 bot/app/web/themes/windows95/icons/paintbrush.png create mode 100644 bot/app/web/themes/windows95/icons/payment-card.png create mode 100644 bot/app/web/themes/windows95/icons/print.png create mode 100644 bot/app/web/themes/windows95/icons/refresh.png create mode 100644 bot/app/web/themes/windows95/icons/save.png create mode 100644 bot/app/web/themes/windows95/icons/search.png create mode 100644 bot/app/web/themes/windows95/icons/send.png create mode 100644 bot/app/web/themes/windows95/icons/settings.png create mode 100644 bot/app/web/themes/windows95/icons/shield.png create mode 100644 bot/app/web/themes/windows95/icons/sliders.png create mode 100644 bot/app/web/themes/windows95/icons/smartphone.png create mode 100644 bot/app/web/themes/windows95/icons/sparkles.png create mode 100644 bot/app/web/themes/windows95/icons/tag.png create mode 100644 bot/app/web/themes/windows95/icons/ticket.png create mode 100644 bot/app/web/themes/windows95/icons/trash.png create mode 100644 bot/app/web/themes/windows95/icons/user.png create mode 100644 bot/app/web/themes/windows95/icons/users.png create mode 100644 bot/app/web/themes/windows95/icons/warning.png create mode 100644 bot/app/web/themes/windows95/icons/x.png create mode 100644 bot/app/web/themes/windows95/style.css create mode 100644 bot/app/web/themes/windows95/theme.json create mode 100644 config/webapp_themes_config.py create mode 100644 docs/remnawave-minishop.webp create mode 100644 tests/test_webapp_themes_config.py diff --git a/.env.example b/.env.example index 7882f23..7f71da8 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 # WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color +WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets +WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index 8dcb349..385a0e3 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import ( stats as _stats, sync as _sync, tariffs as _tariffs, + themes as _themes, users as _users, ) @@ -34,6 +35,7 @@ _MODULES = ( _ads, _settings, _tariffs, + _themes, _panel, _routes, ) diff --git a/bot/app/web/admin_api_impl/common.py b/bot/app/web/admin_api_impl/common.py index 7a337c6..2ef0e05 100644 --- a/bot/app/web/admin_api_impl/common.py +++ b/bot/app/web/admin_api_impl/common.py @@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: path.write_text(payload, encoding="utf-8") +def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + def _panel_node_uuid_key(node: Dict[str, Any]) -> str: uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") return str(uid).strip().lower() if uid else "" diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index ad2c89d..4f1e93a 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -54,4 +54,6 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/tariffs", admin_tariffs_get_route) router.add_put("/api/admin/tariffs", admin_tariffs_save_route) + router.add_get("/api/admin/themes", admin_themes_get_route) + router.add_put("/api/admin/themes", admin_themes_save_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py new file mode 100644 index 0000000..983618d --- /dev/null +++ b/bot/app/web/admin_api_impl/themes.py @@ -0,0 +1,63 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + +from config.webapp_themes_config import ( + WebappThemesConfig, + ensure_webapp_core_themes, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +async def admin_themes_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + catalog = resolved_webapp_themes_catalog( + primary_accent=primary, + env_default_theme=settings.WEBAPP_DEFAULT_THEME, + theme_dir=settings.WEBAPP_THEMES_DIR, + ) + + return _ok( + { + "exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(), + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(catalog), + } + ) + + +async def admin_themes_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = WebappThemesConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_webapp_themes_config", str(exc)) + + config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a") + + try: + write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True) + except OSError as exc: + logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok( + { + "exists": True, + "themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()), + "catalog": _webapp_themes_catalog_payload(config), + } + ) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 66b2c3a..ec51a5c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -40,6 +40,13 @@ import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js"; import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js"; + import { + findThemeEntry, + resolveEffectiveThemeKey, + themeCssHref, + themeEntryToInlineStyle, + themeRootClass, + } from "./lib/webapp/themeStyle.js"; /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */ const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; @@ -243,7 +250,6 @@ 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 : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; @@ -300,6 +306,26 @@ premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT) ); $: user = data?.user || {}; + $: themesCatalog = data?.themes_catalog || + CFG.themesCatalog || { default_theme: "dark", themes: [] }; + $: resolvedThemeKey = resolveEffectiveThemeKey(themesCatalog); + $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); + $: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); + $: effectiveThemeEntry = + screen === "admin" && activeThemeEntry?.use_in_admin === false + ? darkThemeEntry || activeThemeEntry + : activeThemeEntry; + $: shellStyle = themeEntryToInlineStyle(effectiveThemeEntry, CFG.primaryColor); + $: shellToneClass = + effectiveThemeEntry?.tokens?.color_scheme === "light" ? "theme-light" : "theme-dark"; + $: shellThemeClass = themeRootClass(effectiveThemeEntry); + $: shellThemeCssHref = themeCssHref(effectiveThemeEntry); + $: if (typeof document !== "undefined" && effectiveThemeEntry?.tokens) { + const scheme = effectiveThemeEntry.tokens.color_scheme || "dark"; + document.documentElement.style.colorScheme = scheme; + const bg = effectiveThemeEntry.tokens.bg; + if (bg) document.body.style.backgroundColor = bg; + } $: isAdmin = Boolean(user?.is_admin); $: if (screen === "admin" && !isAdmin) { screen = "settings"; @@ -873,6 +899,9 @@ {brandTitle} + {#if shellThemeCssHref} + + {/if} @@ -880,7 +909,7 @@ {#if isPreviewBoard} {:else} -
+
{#if mode === "loading"}
@@ -931,6 +960,7 @@ onSectionChange={handleAdminSectionChange} onSettingsSaved={handleAdminPersistedSaved} onTariffsSaved={handleAdminPersistedSaved} + onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} appVersion={CFG.appVersion} diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 14fa264..91b7b46 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -11,6 +11,7 @@ LayoutDashboard, Megaphone, Menu, + Paintbrush, Plus, RefreshCw, Save, @@ -34,6 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; + import ThemesSection from "./sections/ThemesSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; @@ -44,6 +46,7 @@ import { createSettingsStore } from "../lib/admin/stores/settingsStore.js"; import { createStatsStore } from "../lib/admin/stores/statsStore.js"; import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js"; + import { createThemesStore } from "../lib/admin/stores/themesStore.js"; import { createUsersStore } from "../lib/admin/stores/usersStore.js"; import { fmtDate, @@ -70,6 +73,7 @@ export let onSectionChange = () => {}; export let onSettingsSaved = () => {}; export let onTariffsSaved = () => {}; + export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; export let appVersion = "dev+local"; @@ -111,6 +115,7 @@ label: at("nav_system", {}, "Система"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, + { id: "themes", label: at("nav_themes", {}, "Темы"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -153,6 +158,10 @@ title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), }, + themes: { + title: at("section_themes_title", {}, "Темы Web App"), + subtitle: at("section_themes_subtitle", {}, "Цвета, шрифты и темы оформления Mini App"), + }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"), @@ -196,6 +205,7 @@ const settingsStore = createSettingsStore({ api, onToast: flash, at }); const statsStore = createStatsStore({ api, onToast: flash, at }); const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at }); + const themesStore = createThemesStore({ api, onThemesSaved, flash, at }); const usersStore = createUsersStore({ api, onToast: flash, at }); setContext("promosStore", promosStore); @@ -207,6 +217,7 @@ setContext("settingsStore", settingsStore); setContext("usersStore", usersStore); setContext("tariffsStore", tariffsStore); + setContext("themesStore", themesStore); $: usersStore.setActive(active); $: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length; @@ -635,6 +646,10 @@ {/if} + {#if active === "themes"} + + {/if} + {#if active === "settings"} {/if} diff --git a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte new file mode 100644 index 0000000..e10a2a8 --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte @@ -0,0 +1,267 @@ + + +{#if themesLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+

{at("themes_catalog_title", {}, "Темы Web App")}

+ {at( + "themes_catalog_sub", + {}, + "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы" + )} +
+
+ + + {at("btn_refresh", {}, "Обновить")} + + + + {at("btn_save", {}, "Сохранить")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "Каталог пуст. Добавьте папку темы в data/themes и обновите список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleCardKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "Текущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + + +
+ {/each} +
+ {/if} +
+
+{/if} + + diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js new file mode 100644 index 0000000..f0f63d8 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -0,0 +1,113 @@ +function cloneCatalog(catalog) { + return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] })); +} + +import { writable } from "svelte/store"; + +export function createThemesStore({ api, onThemesSaved, flash, at }) { + const state = writable({ + themesCatalog: { default_theme: "dark", themes: [] }, + themesDir: "", + themesLoading: false, + themesSaving: false, + }); + + async function loadThemes() { + state.update((s) => ({ ...s, themesLoading: true })); + try { + const data = await api("/admin/themes"); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || "", + })); + } else { + flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить темы")); + } + } finally { + state.update((s) => ({ ...s, themesLoading: false })); + } + } + + async function saveThemes(options = {}) { + const silent = Boolean(options.silent); + let catalog = null; + state.update((s) => { + catalog = cloneCatalog(s.themesCatalog); + return { ...s, themesSaving: true }; + }); + try { + const data = await api("/admin/themes", { + method: "PUT", + body: JSON.stringify({ catalog }), + }); + if (data?.ok) { + state.update((s) => ({ + ...s, + themesCatalog: cloneCatalog(data.catalog), + themesDir: data.themes_dir || s.themesDir, + })); + if (!silent) flash(at("themes_saved", {}, "Темы сохранены")); + if (typeof onThemesSaved === "function") onThemesSaved(); + } else { + flash(data?.message || data?.error || at("themes_save_failed", {}, "Не удалось сохранить")); + } + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function setCurrentTheme(key) { + let changed = false; + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + default_theme: key, + themes: (s.themesCatalog.themes || []).map((theme) => ({ + ...theme, + default: theme.key === key, + })), + }, + })); + state.update((s) => { + changed = s.themesCatalog.default_theme === key; + return s; + }); + if (changed) await saveThemes({ silent: true }); + } + + function togglePrimaryAccent(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme + ), + }, + })); + } + + function toggleAdminUse(key, enabled) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme + ), + }, + })); + } + + return { + subscribe: state.subscribe, + loadThemes, + saveThemes, + setCurrentTheme, + togglePrimaryAccent, + toggleAdminUse, + }; +} diff --git a/bot/app/web/frontend/src/lib/components/ui/icons.js b/bot/app/web/frontend/src/lib/components/ui/icons.js index b1f704d..21f1142 100644 --- a/bot/app/web/frontend/src/lib/components/ui/icons.js +++ b/bot/app/web/frontend/src/lib/components/ui/icons.js @@ -35,6 +35,7 @@ export { Menu, MessageSquare, MousePointerClick, + Paintbrush, Plus, QrCode, Radio, diff --git a/bot/app/web/frontend/src/lib/webapp/constants.js b/bot/app/web/frontend/src/lib/webapp/constants.js index 71c2f97..fd4f470 100644 --- a/bot/app/web/frontend/src/lib/webapp/constants.js +++ b/bot/app/web/frontend/src/lib/webapp/constants.js @@ -34,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([ "broadcast", "logs", "tariffs", + "themes", "settings", ]); export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/bot/app/web/frontend/src/lib/webapp/mockApi.js index f354743..ef65690 100644 --- a/bot/app/web/frontend/src/lib/webapp/mockApi.js +++ b/bot/app/web/frontend/src/lib/webapp/mockApi.js @@ -195,6 +195,30 @@ export async function mockApi(path, options = {}, context = {}) { }, }; } + if (path === "/admin/themes") { + if (String(options.method || "GET").toUpperCase() === "PUT") { + try { + const body = options?.body ? JSON.parse(String(options.body)) : {}; + const catalog = body.catalog || body; + if (catalog?.themes) { + DEV_MOCK.config.themesCatalog = clone(catalog); + DEV_MOCK.data.themes_catalog = clone(catalog); + } + } catch (_e) { + void _e; + } + return { + ok: true, + themes_dir: "data/themes", + catalog: clone(DEV_MOCK.config.themesCatalog), + }; + } + return { + ok: true, + themes_dir: "data/themes", + catalog: clone(DEV_MOCK.config.themesCatalog), + }; + } if (path === "/admin/settings") return { ok: true, sections: [] }; if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/bot/app/web/frontend/src/lib/webapp/previewMock.js index 9741502..20e963d 100644 --- a/bot/app/web/frontend/src/lib/webapp/previewMock.js +++ b/bot/app/web/frontend/src/lib/webapp/previewMock.js @@ -1,3 +1,27 @@ +const WINDOWS_95_THEME = { + key: "windows95", + names: { ru: "Windows 95", en: "Windows 95" }, + enabled: true, + default: false, + css_file: "style.css", + tokens: { + color_scheme: "light", + style_preset: "win95", + }, +}; + +const ASCII_THEME = { + key: "ascii", + names: { ru: "ASCII", en: "ASCII" }, + enabled: true, + default: false, + css_file: "style.css", + tokens: { + color_scheme: "dark", + style_preset: "ascii", + }, +}; + export const DEV_MOCK = { config: { title: "/minishop", @@ -18,6 +42,37 @@ export const DEV_MOCK = { telegramOAuthRequestAccess: ["write"], appVersion: "dev+local", appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", + themesCatalog: { + default_theme: "dark", + themes: [ + { + key: "dark", + names: { ru: "Тёмная", en: "Dark" }, + enabled: true, + default: true, + tokens: { + color_scheme: "dark", + accent: "#00fe7a", + bg: "#03070b", + panel: "#111820", + text: "#f2f7f4", + muted: "#a9b4b0", + }, + }, + { + key: "light", + names: { ru: "Светлая", en: "Light" }, + enabled: true, + default: false, + css_file: "style.css", + tokens: { + color_scheme: "light", + }, + }, + WINDOWS_95_THEME, + ASCII_THEME, + ], + }, }, data: { ok: true, @@ -124,6 +179,35 @@ export const DEV_MOCK = { { months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 }, ], }, + themes_catalog: { + default_theme: "dark", + themes: [ + { + key: "dark", + names: { ru: "Тёмная", en: "Dark" }, + enabled: true, + tokens: { + color_scheme: "dark", + accent: "#00fe7a", + bg: "#03070b", + panel: "#111820", + text: "#f2f7f4", + muted: "#a9b4b0", + }, + }, + { + key: "light", + names: { ru: "Светлая", en: "Light" }, + enabled: true, + css_file: "style.css", + tokens: { + color_scheme: "light", + }, + }, + WINDOWS_95_THEME, + ASCII_THEME, + ], + }, settings: { support_url: "https://t.me/support", traffic_mode: false, @@ -143,6 +227,20 @@ export function applyPreviewMock(kind) { const mode = String(kind || "") .trim() .toLowerCase(); + + const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => theme.key)); + if (themeKeys.has(mode)) { + DEV_MOCK.config.themesCatalog.default_theme = mode; + DEV_MOCK.data.themes_catalog.default_theme = mode; + for (const theme of DEV_MOCK.config.themesCatalog.themes || []) { + theme.default = theme.key === mode; + } + for (const theme of DEV_MOCK.data.themes_catalog.themes || []) { + theme.default = theme.key === mode; + } + return; + } + if (mode === "traffic") { DEV_MOCK.data.settings.traffic_mode = true; DEV_MOCK.data.settings.trial_available = false; diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/bot/app/web/frontend/src/lib/webapp/themeStyle.js new file mode 100644 index 0000000..e312c37 --- /dev/null +++ b/bot/app/web/frontend/src/lib/webapp/themeStyle.js @@ -0,0 +1,141 @@ +/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */ + +const TOKEN_TO_CSS_VAR = { + accent: "--accent", + bg: "--bg", + panel: "--panel", + panel_2: "--panel-2", + panel_3: "--panel-3", + border: "--border", + border_strong: "--border-strong", + text: "--text", + muted: "--muted", + dim: "--dim", + danger: "--danger", + blue: "--blue", + radius: "--radius", + font_sans: "--font-sans", + font_logo: "--font-logo", + font_mono: "--font-mono", + admin_bg: "--admin-bg", + admin_surface: "--admin-surface", + admin_surface_2: "--admin-surface-2", + admin_elev: "--admin-elev", + admin_border: "--admin-border", + admin_border_strong: "--admin-border-strong", + admin_text: "--admin-text", + admin_muted: "--admin-muted", + admin_dim: "--admin-dim", +}; + +export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) { + const t = tokens && typeof tokens === "object" ? tokens : {}; + const parts = []; + const useFallbackAccent = options.fallbackAccent !== false; + const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : ""); + if (accent) parts.push(`--accent:${accent}`); + for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) { + if (key === "accent") continue; + const value = t[key]; + if (value === undefined || value === null || value === "") continue; + parts.push(`${cssVar}:${String(value)}`); + } + return parts.join(";"); +} + +export function findThemeEntry(themesCatalog, key) { + const themes = themesCatalog?.themes || []; + return themes.find((entry) => entry && entry.key === key) || null; +} + +export function resolveEffectiveThemeKey(themesCatalog) { + const themes = themesCatalog?.themes || []; + const byKey = (k) => themes.find((entry) => entry.key === k); + const def = themesCatalog?.default_theme || themes[0]?.key || "dark"; + return byKey(def) ? def : themes[0]?.key || "dark"; +} + +export function themePresetClass(tokens) { + const preset = String(tokens?.style_preset || "") + .trim() + .toLowerCase(); + if (!preset || preset === "none") return ""; + if (preset === "win95" || preset === "windows95") return "theme-preset-win95"; + return ""; +} + +export function themeKeyClass(key) { + const safe = String(key || "") + .trim() + .toLowerCase() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return safe ? `theme-key-${safe}` : ""; +} + +export function themeCssClass(cssFile) { + const filename = String(cssFile || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .pop(); + const slug = String(filename || "") + .replace(/\.css$/i, "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug ? `theme-css-${slug}` : ""; +} + +export function themeRootClass(theme) { + return [ + themeKeyClass(theme?.key), + themeCssClass(theme?.css_file), + themePresetClass(theme?.tokens), + ] + .filter(Boolean) + .join(" "); +} + +export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") { + return themeTokensToInlineStyle(theme?.tokens, primaryFallback, { + fallbackAccent: !theme?.css_file, + }); +} + +function encodeThemeCssPath(path) { + return String(path || "") + .replace(/\\/g, "/") + .split("/") + .filter(Boolean) + .map(encodeURIComponent) + .join("/"); +} + +export function themeCssHref(theme) { + const cssFile = String(theme?.css_file || "").trim(); + if (!cssFile) return ""; + if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return ""; + if (cssFile.startsWith("/")) return cssFile; + const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); + const key = String(theme?.key || "") + .trim() + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const themedPath = + key && normalizedCssFile.split("/")[0] !== key + ? `${key}/${normalizedCssFile}` + : normalizedCssFile; + const encoded = encodeThemeCssPath(themedPath); + return encoded ? `/webapp-theme-css/${encoded}` : ""; +} + +export function localizedThemeName(theme, lang = "en") { + const names = theme?.names || {}; + const key = String(lang || "") + .trim() + .toLowerCase(); + const base = key.split("-")[0]; + return names[key] || names[base] || names.en || theme?.key || ""; +} diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css index 7790901..821ab64 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/bot/app/web/frontend/src/styles/admin.css @@ -7,17 +7,14 @@ --admin-sidebar-w: var(--desktop-rail-width); --admin-header-h: 60px; --admin-card-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); - --admin-card-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); + --admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: fixed; inset: 0; width: 100vw; height: 100dvh; - background: #02070b; + background: var(--admin-bg); color: var(--admin-text); overflow: hidden; overscroll-behavior: contain; @@ -38,9 +35,9 @@ gap: 2px; padding: 28px 14px; border-right: 1px solid var(--admin-border); - background: rgba(7, 12, 17, 0.55); + background: var(--rail-bg); backdrop-filter: blur(14px); - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02); + box-shadow: inset -1px 0 0 var(--admin-border); overflow-y: auto; } @@ -74,7 +71,8 @@ color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } @@ -115,7 +113,10 @@ text-align: left; width: 100%; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } .admin-nav-item > svg { @@ -124,7 +125,7 @@ } .admin-nav-item:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } @@ -169,7 +170,7 @@ gap: 8px; border: 1px solid var(--admin-border); border-radius: 10px; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); color: var(--admin-text); padding: 8px 10px; text-align: left; @@ -251,7 +252,7 @@ gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--admin-border); - background: color-mix(in srgb, #02070b 82%, transparent); + background: color-mix(in srgb, var(--admin-bg) 82%, transparent); backdrop-filter: blur(14px); } @@ -265,7 +266,7 @@ margin: 0; font-size: 16px; font-weight: 700; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-header-title small { @@ -427,7 +428,7 @@ .admin-stat-card .admin-stat-value { font-size: 26px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); } @@ -457,7 +458,7 @@ font-size: 14px; font-weight: 600; color: var(--admin-text); - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-dashboard-section-head small { @@ -528,7 +529,7 @@ .admin-revenue-kpi-value { font-size: 18px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); line-height: 1.15; } @@ -606,7 +607,7 @@ border: 1px solid var(--admin-border); background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); } .admin-revenue-range-popover__title { @@ -946,7 +947,7 @@ .admin-panel-dash-tile-value { font-size: 22px; font-weight: 700; - letter-spacing: -0.03em; + letter-spacing: 0; line-height: 1.1; color: color-mix(in srgb, var(--accent) 22%, var(--admin-text)); font-variant-numeric: tabular-nums; @@ -1248,7 +1249,7 @@ } .admin-table tbody tr:hover { - background: rgba(255, 255, 255, 0.035); + background: var(--surface-hover); } .admin-table td.admin-cell-mono { @@ -1531,7 +1532,9 @@ font-size: 13px; resize: vertical; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-textarea:focus, @@ -2357,7 +2360,7 @@ z-index: 80; transform: translateX(-100%); transition: transform 0.22s ease; - box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); + box-shadow: var(--shadow-strong); } .admin-screen-wrap.is-sidebar-open .admin-sidebar { @@ -2367,7 +2370,7 @@ .admin-sidebar-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: var(--overlay-scrim); z-index: 70; border: 0; cursor: pointer; @@ -2501,7 +2504,9 @@ padding: 10px 12px; width: 100%; cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + border-color 0.12s ease; } .settings-row.settings-row-admin:hover { @@ -2560,7 +2565,10 @@ border-radius: 7px; cursor: pointer; white-space: nowrap; - transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + box-shadow 0.12s ease; outline: none; } @@ -2571,7 +2579,7 @@ .admin-tabs-trigger[data-state="active"] { background: var(--admin-surface); color: var(--admin-text); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-soft); } .admin-tabs-trigger:focus-visible { @@ -2641,7 +2649,9 @@ border: 1px solid var(--admin-border); background: var(--admin-elev); cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease; + transition: + background 0.18s ease, + border-color 0.18s ease; outline: none; } @@ -2693,7 +2703,9 @@ cursor: pointer; text-align: left; outline: none; - transition: border-color 0.12s ease, box-shadow 0.12s ease; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; } .admin-select-trigger:hover { @@ -2724,10 +2736,9 @@ border: 1px solid var(--admin-border); border-radius: 10px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--admin-surface); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); padding: 4px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-popover); outline: none; display: flex; flex-direction: column; @@ -3202,4 +3213,4 @@ grid-area: meta; white-space: normal; } -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/base.css b/bot/app/web/frontend/src/styles/base.css index 199d5cc..6a5b977 100644 --- a/bot/app/web/frontend/src/styles/base.css +++ b/bot/app/web/frontend/src/styles/base.css @@ -4,21 +4,14 @@ format("woff2"); font-display: swap; unicode-range: - U+1F1E6-1F1FF, - U+1F3F4, - U+E0062-E0063, - U+E0065, - U+E0067, - U+E006C, - U+E006E, - U+E0073-E0074, - U+E0077, - U+E007F; + U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, + U+E0077, U+E007F; } :root { --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-logo: var(--font-mono); color-scheme: dark; --accent: #00fe7a; --bg: #03070b; @@ -33,6 +26,19 @@ --danger: #ff6b6b; --blue: #2d9cff; --radius: 8px; + --accent-contrast: #03100a; + --surface-sheen: rgba(255, 255, 255, 0.055); + --surface-sheen-soft: rgba(255, 255, 255, 0.018); + --surface-hover: rgba(255, 255, 255, 0.04); + --surface-muted: rgba(255, 255, 255, 0.035); + --surface-subtle-border: rgba(255, 255, 255, 0.1); + --overlay-scrim: rgba(0, 0, 0, 0.56); + --nav-bg: rgba(7, 12, 17, 0.88); + --rail-bg: rgba(7, 12, 17, 0.55); + --shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.22); + --shadow-strong: 0 26px 70px rgba(0, 0, 0, 0.46); + --shadow-popover: 0 20px 34px rgba(0, 0, 0, 0.42); + --inset-highlight: rgba(255, 255, 255, 0.05); /* Admin design tokens — kept on :root so portal-rendered admin surfaces (dialogs, bits-ui Select.Portal content) inherit them. */ @@ -59,6 +65,10 @@ font-family: var(--font-sans); } +.theme-light { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -72,7 +82,7 @@ body, } body { - background: #02070b; + background: var(--bg, #02070b); color: var(--text); -webkit-font-smoothing: antialiased; letter-spacing: 0; @@ -86,10 +96,10 @@ input { .app-shell { min-height: 100dvh; - background: #02070b !important; + background: var(--bg, #02070b) !important; } :root { --desktop-rail-width: 252px; --desktop-page-gutter: clamp(28px, 4vw, 72px); -} \ No newline at end of file +} diff --git a/bot/app/web/frontend/src/styles/components.css b/bot/app/web/frontend/src/styles/components.css index 91d8c8c..62df485 100644 --- a/bot/app/web/frontend/src/styles/components.css +++ b/bot/app/web/frontend/src/styles/components.css @@ -26,11 +26,9 @@ border-radius: 12px; border: 1px solid var(--admin-border); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight); position: relative; overflow: hidden; } @@ -80,7 +78,7 @@ margin: 0; font-size: 1.5rem; font-weight: 600; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--admin-text); font-variant-numeric: tabular-nums; line-height: 1.15; @@ -89,7 +87,7 @@ .admin-cn-card-title--section { font-size: 1.05rem; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .admin-cn-card-skeleton { @@ -227,9 +225,9 @@ display: inline-block; background: linear-gradient( 90deg, - rgba(255, 255, 255, 0.08), - rgba(255, 255, 255, 0.14), - rgba(255, 255, 255, 0.08) + color-mix(in srgb, var(--muted) 10%, transparent), + color-mix(in srgb, var(--muted) 18%, transparent), + color-mix(in srgb, var(--muted) 10%, transparent) ); background-size: 220% 100%; animation: ui-skeleton-pulse 1.2s ease-in-out infinite; @@ -293,3 +291,4 @@ background-position: -120% 0; } } + diff --git a/bot/app/web/frontend/src/styles/controls.css b/bot/app/web/frontend/src/styles/controls.css index 5aa9bee..0de8184 100644 --- a/bot/app/web/frontend/src/styles/controls.css +++ b/bot/app/web/frontend/src/styles/controls.css @@ -23,7 +23,7 @@ button:disabled { text-align: center; text-decoration: none; cursor: pointer; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + box-shadow: var(--shadow-soft); transition: transform 0.16s ease, border-color 0.16s ease, @@ -34,7 +34,7 @@ button:disabled { .btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22); + box-shadow: var(--shadow-soft); } .btn:active:not(:disabled) { @@ -45,7 +45,7 @@ button:disabled { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), - 0 14px 32px rgba(0, 0, 0, 0.22); + var(--shadow-soft); } .btn.wide, @@ -56,7 +56,7 @@ button:disabled { .btn-primary { border-color: color-mix(in srgb, var(--accent) 72%, white); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white)); - color: #03100a; + color: var(--accent-contrast); } .btn-secondary { @@ -111,7 +111,7 @@ button:disabled { margin-top: 12px; overflow: hidden; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--muted) 18%, transparent); } .progress span { diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/bot/app/web/frontend/src/styles/dialogs.css index f3ae77c..40cca5c 100644 --- a/bot/app/web/frontend/src/styles/dialogs.css +++ b/bot/app/web/frontend/src/styles/dialogs.css @@ -26,7 +26,7 @@ inset: 0; z-index: 0; border: 0; - background: rgba(0, 0, 0, 0.56); + background: var(--overlay-scrim); backdrop-filter: blur(10px); cursor: pointer; } @@ -43,9 +43,9 @@ padding: 18px; border: 1px solid var(--border); border-radius: 24px; - background: color-mix(in srgb, var(--panel) 94%, #07111a); + background: color-mix(in srgb, var(--panel) 96%, var(--bg)); color: var(--text); - box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46); + box-shadow: var(--shadow-strong); } .dialog-head { @@ -92,7 +92,7 @@ .payment-submit-button { border-color: color-mix(in srgb, var(--accent) 72%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent)); - color: #031009; + color: var(--accent-contrast); box-shadow: 0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 2561e4e..18c1416 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -12,8 +12,8 @@ a { min-height: 100dvh; margin: 0 auto; overflow-x: hidden; - padding: - max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); + padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) + max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline)); } .content { @@ -94,7 +94,7 @@ a { color: var(--text); font-size: 15px; font-weight: 850; - font-family: var(--font-mono); + font-family: var(--font-logo); text-overflow: ellipsis; white-space: nowrap; } @@ -162,20 +162,19 @@ a { border: 1px solid var(--border); border-radius: var(--radius); background: - linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)), - var(--panel); + linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--panel); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + var(--shadow-soft), + inset 0 1px 0 var(--inset-highlight); padding: 14px; } .card-active { border-color: color-mix(in srgb, var(--accent) 76%, var(--border)); box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), + var(--shadow-soft), 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.05); + inset 0 1px 0 var(--inset-highlight); } .card-compact { @@ -189,7 +188,11 @@ a { .status-card-inactive { border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); background: - linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)), + linear-gradient( + 135deg, + color-mix(in srgb, var(--danger) 14%, var(--surface-sheen-soft)), + var(--surface-sheen-soft) + ), var(--panel); } @@ -392,7 +395,7 @@ a { gap: 6px; } -.premium-server-list>div { +.premium-server-list > div { display: flex; flex-wrap: wrap; gap: 6px; @@ -401,7 +404,7 @@ a { .premium-server-list span { max-width: 100%; padding: 4px 8px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid var(--surface-subtle-border); border-radius: 999px; color: var(--text); font-size: 11px; @@ -416,7 +419,7 @@ a { padding: 12px; } -.topup-summary-card>div:not(.premium-server-list) { +.topup-summary-card > div:not(.premium-server-list) { display: grid; gap: 3px; } @@ -440,7 +443,7 @@ a { gap: 11px; } -.trial-card-head>svg { +.trial-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -475,7 +478,7 @@ a { gap: 11px; } -.devices-summary-head>svg { +.devices-summary-head > svg { color: var(--accent); } @@ -529,7 +532,7 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: var(--radius); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); color: var(--accent); } @@ -604,9 +607,9 @@ a { .settings-row { border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .period-card { @@ -652,10 +655,10 @@ a { .period-card.active, .method-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .total-card { @@ -688,11 +691,11 @@ a { gap: 12px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); + box-shadow: inset 0 1px 0 var(--inset-highlight); } .option-row-main, @@ -745,10 +748,10 @@ a { .option-row.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.06); + inset 0 1px 0 var(--inset-highlight); } .tariff-row { @@ -764,7 +767,7 @@ a { align-items: flex-start; } -.change-action-row>svg { +.change-action-row > svg { flex: 0 0 auto; margin-top: 1px; color: var(--accent); @@ -774,9 +777,9 @@ a { display: grid; gap: 6px; padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); } .topup-carryover-note p { @@ -798,11 +801,12 @@ a { display: block; overflow: hidden; border-radius: 999px; - background: - linear-gradient(90deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.14) 42%, - rgba(255, 255, 255, 0.07) 84%); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--muted) 10%, transparent) 0%, + color-mix(in srgb, var(--muted) 18%, transparent) 42%, + color-mix(in srgb, var(--muted) 10%, transparent) 84% + ); background-size: 220% 100%; animation: skeleton-shimmer 1.15s ease-in-out infinite; } @@ -893,7 +897,7 @@ a { .tariff-selected-card { min-height: 58px; - background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 7%, var(--surface-muted)); } .tariff-action-list { @@ -909,7 +913,7 @@ a { gap: 10px; border: 1px solid var(--border); border-radius: var(--radius); - background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)); + background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)); color: var(--text); padding: 12px; text-align: left; @@ -942,7 +946,7 @@ a { .tariff-action-card.active { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .tariff-warning-card { @@ -969,7 +973,7 @@ a { min-height: 56px; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); padding: 10px; text-align: center; } @@ -1105,7 +1109,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(0, 0, 0, 0.2); + background: color-mix(in srgb, var(--panel-2) 86%, transparent); color: var(--text); padding: 12px; font-size: 12px; @@ -1132,7 +1136,7 @@ a { gap: 14px; } -.bonus-card-head>svg { +.bonus-card-head > svg { flex: 0 0 auto; color: var(--accent); } @@ -1165,7 +1169,7 @@ a { gap: 3px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.02); + background: var(--surface-sheen-soft); padding: 10px 11px; } @@ -1193,7 +1197,7 @@ a { overflow: hidden; border: 1px solid var(--border); border-radius: 999px; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } .settings-avatar img { @@ -1245,15 +1249,15 @@ a { min-height: 50px; padding: 9px 10px; text-align: left; - background: rgba(255, 255, 255, 0.03); + background: var(--surface-muted); } -.settings-row>svg:first-child { +.settings-row > svg:first-child { color: var(--text); opacity: 0.9; } -.settings-row>svg:last-child { +.settings-row > svg:last-child { color: var(--muted); } @@ -1277,10 +1281,10 @@ a { .settings-row-linked { grid-template-columns: 28px minmax(0, 1fr); border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03)); + background: color-mix(in srgb, var(--accent) 11%, var(--surface-muted)); } -.settings-row-linked>svg:first-child { +.settings-row-linked > svg:first-child { color: var(--accent); } @@ -1290,12 +1294,8 @@ a { .emoji-flag { font-family: - "Twemoji Country Flags", - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji", - sans-serif; + "Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji", sans-serif; font-variant-emoji: emoji; line-height: 1; } @@ -1325,7 +1325,7 @@ a { border-radius: 6px; } -.language-select-trigger>svg { +.language-select-trigger > svg { color: var(--muted); } @@ -1361,7 +1361,7 @@ a { border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--panel-3); - box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42); + box-shadow: var(--shadow-popover); overflow: hidden; box-sizing: border-box; transform-origin: top right; @@ -1387,7 +1387,7 @@ a { } .language-select-item[data-highlighted] { - background: rgba(255, 255, 255, 0.06); + background: var(--surface-hover); } .language-select-item[data-selected] { @@ -1409,11 +1409,11 @@ a { text-overflow: ellipsis; } -.language-select-item-main>span { +.language-select-item-main > span { display: inline-block !important; } -.language-select-item-main>span:last-child { +.language-select-item-main > span:last-child { margin-left: 6px; white-space: nowrap !important; overflow: hidden; @@ -1494,8 +1494,8 @@ a { min-height: 64px; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(7, 12, 17, 0.88); - box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36); + background: var(--nav-bg); + box-shadow: var(--shadow-soft); backdrop-filter: blur(16px); } @@ -1580,7 +1580,7 @@ a { color: var(--accent); font-size: 32px; font-weight: 900; - font-family: var(--font-mono); + font-family: var(--font-logo); line-height: 1.04; } @@ -1739,13 +1739,13 @@ a { place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border)); border-radius: var(--radius); - background: rgba(255, 255, 255, 0.035); + background: var(--surface-muted); font-size: 25px; font-weight: 900; } .otp-slots span.filled { - background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04)); + background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted)); } .link-button { @@ -1790,8 +1790,8 @@ a { max-width: min(420px, calc(100vw - 32px)); border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); border-radius: var(--radius); - background: rgba(15, 21, 27, 0.92); - box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: var(--shadow-popover); color: var(--text); padding: 12px 14px; font-size: 13px; @@ -1873,8 +1873,7 @@ a { border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 18px; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), - #071017; + linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.54), inset 0 1px 0 rgba(255, 255, 255, 0.08); @@ -2011,7 +2010,6 @@ a { } @media (prefers-reduced-motion: reduce) { - .content, .home-layout, .language-select-content, @@ -2039,7 +2037,7 @@ a { @media (min-width: 1024px) { body { - background: #02070b; + background: var(--bg); } .app-shell { @@ -2060,16 +2058,16 @@ a { border-radius: 0; background: transparent; box-shadow: none; - padding: - max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); overflow-x: visible; } /* Centre and cap the width of the actual content blocks. */ - .phone-screen>.app-header, - .phone-screen>main, - .phone-screen>.home-layout, - .phone-screen>nav.bottom-nav~* { + .phone-screen > .app-header, + .phone-screen > main, + .phone-screen > .home-layout, + .phone-screen > nav.bottom-nav ~ * { max-width: 1080px; margin-left: auto; margin-right: auto; @@ -2079,12 +2077,11 @@ a { .phone-screen.auth-screen { width: min(100%, 460px); min-height: 100dvh; - padding: - max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; + padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px; margin: 0 auto; } - .phone-screen.auth-screen~.bottom-nav, + .phone-screen.auth-screen ~ .bottom-nav, .auth-screen .bottom-nav { display: none !important; } @@ -2094,8 +2091,8 @@ a { grid-template-columns: minmax(0, 1fr); align-items: stretch; min-height: 100dvh; - padding: - max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); } /* Home: keep the phone-style vertical flow, but center it in the @@ -2113,7 +2110,7 @@ a { align-items: stretch; } - .home-layout>.home-brand { + .home-layout > .home-brand { grid-column: auto; align-self: center; justify-items: center; @@ -2174,9 +2171,9 @@ a { border: 0 !important; border-right: 1px solid var(--border) !important; border-radius: 0 !important; - background: rgba(7, 12, 17, 0.55) !important; + background: var(--rail-bg) !important; backdrop-filter: blur(14px) !important; - box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; + box-shadow: inset -1px 0 0 var(--border) !important; z-index: 40; } @@ -2197,22 +2194,25 @@ a { font-size: 13px !important; color: var(--muted); border: 1px solid transparent; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease, + border-color 0.12s ease; } - .bottom-nav button>svg { + .bottom-nav button > svg { width: 20px; height: 20px; } - .bottom-nav button>span { + .bottom-nav button > span { text-align: left !important; font-size: 13px !important; font-weight: 600; } .bottom-nav button:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--text); } @@ -2235,7 +2235,7 @@ a { display: none !important; } - .phone-screen>main.content.with-nav { + .phone-screen > main.content.with-nav { padding-top: 0; } @@ -2310,7 +2310,8 @@ a { color: var(--accent); font-size: 14px; font-weight: 800; - letter-spacing: -0.01em; + font-family: var(--font-logo); + letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/bot/app/web/themes/ascii/style.css b/bot/app/web/themes/ascii/style.css new file mode 100644 index 0000000..77d1878 --- /dev/null +++ b/bot/app/web/themes/ascii/style.css @@ -0,0 +1,953 @@ +/* + * ASCII / console theme. + * Pure black background, white foreground, monospace everything, + * 1px white borders, animated ASCII spinners and block-progress bars. + */ + +.theme-key-ascii { + color-scheme: dark; + --accent: #ffffff; + --accent-contrast: #000000; + --bg: #000000; + --panel: #000000; + --panel-2: #050505; + --panel-3: #0c0c0c; + --border: #ffffff; + --border-strong: #ffffff; + --text: #ffffff; + --muted: #b0b0b0; + --dim: #6a6a6a; + --danger: #ff5555; + --blue: #ffffff; + --radius: 0px; + --font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas", + "Source Code Pro", "Courier New", ui-monospace, monospace; + --font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New", + ui-monospace, monospace; + --surface-sheen: transparent; + --surface-sheen-soft: transparent; + --surface-hover: rgba(255, 255, 255, 0.08); + --surface-muted: #0a0a0a; + --surface-subtle-border: #ffffff; + --overlay-scrim: rgba(0, 0, 0, 0.85); + --nav-bg: #000000; + --rail-bg: #000000; + --shadow-soft: none; + --shadow-strong: none; + --shadow-popover: 0 0 0 1px #ffffff; + --inset-highlight: transparent; + --admin-bg: #000000; + --admin-surface: #000000; + --admin-surface-2: #050505; + --admin-elev: #0c0c0c; + --admin-border: #ffffff; + --admin-border-strong: #ffffff; + --admin-text: #ffffff; + --admin-muted: #b0b0b0; + --admin-dim: #6a6a6a; +} + +/* ---------- Base typography ---------- */ + +.theme-key-ascii, +.theme-key-ascii body, +.theme-key-ascii button, +.theme-key-ascii input, +.theme-key-ascii textarea, +.theme-key-ascii select { + font-family: var(--font-sans); + letter-spacing: 0; + font-synthesis: none; + -webkit-font-smoothing: none; + font-smooth: never; + font-variant-ligatures: none; +} + +.theme-key-ascii.app-shell { + background: var(--bg) !important; + background-image: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025) 0, + rgba(255, 255, 255, 0.025) 1px, + transparent 1px, + transparent 3px + ) !important; +} + +/* Slight CRT-like flicker on the shell. */ +@keyframes ascii-flicker { + 0%, 96%, 100% { opacity: 1; } + 97% { opacity: 0.96; } + 98% { opacity: 1; } + 99% { opacity: 0.94; } +} + +.theme-key-ascii.app-shell::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.02) 0, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 2px + ); + animation: ascii-flicker 5s infinite; +} + +/* ---------- Panels / cards ---------- */ + +.theme-key-ascii .card, +.theme-key-ascii .period-card, +.theme-key-ascii .method-card, +.theme-key-ascii .settings-row, +.theme-key-ascii .option-row, +.theme-key-ascii .tariff-selected-card, +.theme-key-ascii .tariff-action-card, +.theme-key-ascii .tariff-warning-card, +.theme-key-ascii .topup-carryover-note, +.theme-key-ascii .input, +.theme-key-ascii .dialog-card, +.theme-key-ascii .language-select-content, +.theme-key-ascii .bottom-nav, +.theme-key-ascii .toast, +.theme-key-ascii .admin-sidebar, +.theme-key-ascii .admin-header, +.theme-key-ascii .admin-card, +.theme-key-ascii .admin-stat-card, +.theme-key-ascii .admin-revenue-panel, +.theme-key-ascii .admin-empty, +.theme-key-ascii .admin-tariff-card, +.theme-key-ascii .admin-toolbar-card, +.theme-key-ascii .admin-table-card, +.theme-key-ascii .admin-panel-dash-card, +.theme-key-ascii .admin-select-trigger, +.theme-key-ascii .admin-select-content, +.theme-key-ascii .admin-cn-card[data-slot="card"], +.theme-key-ascii .admin-dialog .dialog-card, +.theme-key-ascii .admin-theme-editor-section { + border: 1px solid #ffffff; + border-radius: 0; + background: var(--panel); + box-shadow: none; +} + +/* No ribbon/corner overlays: those caused dialog overflow scrollbars. + * The console feel comes from the crisp 1px borders, monospace text, + * and the animated marquees / glitches applied to interactive elements. */ + +/* ---------- Buttons ---------- */ + +.theme-key-ascii .btn, +.theme-key-ascii .language-select-trigger, +.theme-key-ascii .bottom-nav button, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-chip, +.theme-key-ascii .admin-tabs-trigger, +.theme-key-ascii .admin-revenue-period-btn, +.theme-key-ascii .admin-mobile-toggle, +.theme-key-ascii .admin-nav-item { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + text-transform: none; + font-family: var(--font-sans); + transform: none; + position: relative; +} + +.theme-key-ascii .btn:hover:not(:disabled), +.theme-key-ascii .admin-btn:hover:not(:disabled), +.theme-key-ascii .admin-nav-item:hover, +.theme-key-ascii .admin-tabs-trigger:hover, +.theme-key-ascii .admin-revenue-period-btn:hover, +.theme-key-ascii .bottom-nav button:hover { + background: #ffffff; + color: #000000; +} + +.theme-key-ascii .btn:active:not(:disabled), +.theme-key-ascii .bottom-nav button:active, +.theme-key-ascii .admin-btn:active:not(:disabled) { + background: #ffffff; + color: #000000; + transform: translate(1px, 1px); +} + +.theme-key-ascii .btn-primary, +.theme-key-ascii .admin-btn-primary, +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .period-card.active, +.theme-key-ascii .method-card.active, +.theme-key-ascii .option-row.active, +.theme-key-ascii .admin-nav-item.active, +.theme-key-ascii .admin-tabs-trigger[data-state="active"], +.theme-key-ascii .admin-revenue-period-btn.is-active { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.theme-key-ascii .btn-primary:hover:not(:disabled), +.theme-key-ascii .admin-btn-primary:hover:not(:disabled) { + background: #000000; + color: #ffffff; + outline: 1px solid #ffffff; + outline-offset: -2px; +} + +/* Blinking caret-style focus ring. */ +@keyframes ascii-caret { + 0%, 49% { outline-color: #ffffff; } + 50%, 100% { outline-color: transparent; } +} + +.theme-key-ascii .btn:focus-visible, +.theme-key-ascii .admin-btn:focus-visible, +.theme-key-ascii .admin-nav-item:focus-visible, +.theme-key-ascii .admin-tabs-trigger:focus-visible, +.theme-key-ascii .admin-revenue-period-btn:focus-visible, +.theme-key-ascii .admin-mobile-toggle:focus-visible, +.theme-key-ascii .language-select-trigger:focus-visible, +.theme-key-ascii .bottom-nav button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 1px; + animation: ascii-caret 1s steps(1) infinite; +} + +/* ---------- Inputs ---------- */ + +.theme-key-ascii .input, +.theme-key-ascii .admin-input, +.theme-key-ascii .admin-textarea, +.theme-key-ascii .admin-screen-wrap textarea, +.theme-key-ascii .admin-dialog textarea { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + box-shadow: none; + font-family: var(--font-mono); + caret-color: #ffffff; +} + +.theme-key-ascii .input::placeholder, +.theme-key-ascii .admin-input::placeholder, +.theme-key-ascii .admin-textarea::placeholder, +.theme-key-ascii .admin-screen-wrap textarea::placeholder, +.theme-key-ascii .admin-dialog textarea::placeholder { + color: var(--dim); + font-style: normal; +} + +.theme-key-ascii .input:focus, +.theme-key-ascii .admin-input:focus, +.theme-key-ascii .admin-textarea:focus, +.theme-key-ascii .admin-screen-wrap textarea:focus, +.theme-key-ascii .admin-dialog textarea:focus { + outline: none; + border-color: #ffffff; + box-shadow: inset 0 0 0 1px #ffffff; +} + +/* ---------- Bottom nav (desktop rail) ---------- */ + +@media (min-width: 1024px) { + .theme-key-ascii .bottom-nav { + border-right: 1px solid #ffffff !important; + background: var(--rail-bg) !important; + backdrop-filter: none !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button { + border: 1px solid #ffffff !important; + border-radius: 0 !important; + background: #000000 !important; + color: #ffffff !important; + box-shadow: none !important; + } + + .theme-key-ascii .bottom-nav button.active { + background: #ffffff !important; + color: #000000 !important; + } +} + +/* ---------- ASCII progress bar ---------- * + * Empty track: ░░░░░░░░░░░ (low-contrast dotted fill). + * Filled span: ██████ (solid white blocks). + */ + +.theme-key-ascii .progress { + height: 14px; + border: 1px solid #ffffff; + border-radius: 0 !important; + background-color: #000000; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.22) 0, + rgba(255, 255, 255, 0.22) 1px, + transparent 1px, + transparent 4px + ); + position: relative; + overflow: hidden; + font-family: var(--font-mono); +} + +.theme-key-ascii .progress span { + border-radius: 0 !important; + background: #ffffff !important; + background-image: repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.0) 0, + rgba(0, 0, 0, 0.0) 5px, + rgba(0, 0, 0, 0.35) 5px, + rgba(0, 0, 0, 0.35) 6px + ) !important; + box-shadow: none; +} + +/* Indeterminate scanning effect for any progress lacking a width-set span. */ +@keyframes ascii-scan { + 0% { background-position: 0 0; } + 100% { background-position: 12px 0; } +} + +/* ---------- ASCII spinner replacement ---------- */ + +.theme-key-ascii .ui-spinner, +.theme-key-ascii .telegram-button-spinner, +.theme-key-ascii .brand-mark-spinner { + border: none !important; + border-radius: 0 !important; + width: 1ch !important; + height: 1em !important; + background: transparent !important; + position: relative; + animation: none !important; + color: currentColor; + font-family: var(--font-mono); + font-weight: 700; + text-align: center; + vertical-align: middle; + overflow: visible; +} + +.theme-key-ascii .ui-spinner::before, +.theme-key-ascii .telegram-button-spinner::before, +.theme-key-ascii .brand-mark-spinner::before { + content: "|"; + display: inline-block; + animation: ascii-spin 0.8s steps(1) infinite; + font-family: var(--font-mono); + line-height: 1; +} + +@keyframes ascii-spin { + 0% { content: "|"; } + 25% { content: "/"; } + 50% { content: "-"; } + 75% { content: "\\"; } + 100% { content: "|"; } +} + +/* Some browsers don't animate content; fallback rotation of a glyph. */ +@supports not (animation-name: ascii-spin) { + .theme-key-ascii .ui-spinner::before, + .theme-key-ascii .telegram-button-spinner::before, + .theme-key-ascii .brand-mark-spinner::before { + content: "+"; + animation: ascii-spin-rotate 0.8s steps(4) infinite; + } + @keyframes ascii-spin-rotate { + to { transform: rotate(360deg); } + } +} + +/* Blinking cursor appended to brand text. */ +.theme-key-ascii .login-brand h1::after, +.theme-key-ascii .admin-sidebar-brand strong::after, +.theme-key-ascii .brand-row strong::after { + content: "_"; + display: inline-block; + margin-left: 0.2ch; + color: #ffffff; + animation: ascii-blink 1s steps(1) infinite; +} + +@keyframes ascii-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +/* Section heading prompt prefix. */ +.theme-key-ascii .admin-card-head h2::before, +.theme-key-ascii .admin-card-head h3::before, +.theme-key-ascii .card > h2:first-child::before, +.theme-key-ascii .card > h3:first-child::before { + content: "> "; + color: #ffffff; + opacity: 0.85; + font-family: var(--font-mono); +} + +/* ---------- Tables ---------- */ + +.theme-key-ascii .admin-table thead th { + background: #000000; + color: #ffffff; + border-bottom: 1px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; +} + +.theme-key-ascii .admin-table tbody tr { + border-bottom: 1px dashed #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* ---------- Badges / chips ---------- */ + +.theme-key-ascii .admin-badge, +.theme-key-ascii .admin-cn-badge { + border: 1px solid #ffffff; + border-radius: 0; + background: #000000; + color: #ffffff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-badge::before, +.theme-key-ascii .admin-cn-badge::before { + content: "["; +} + +.theme-key-ascii .admin-badge::after, +.theme-key-ascii .admin-cn-badge::after { + content: "]"; +} + +/* ---------- Links ---------- */ + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]), +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) { + color: #ffffff; + text-decoration: underline; + text-underline-offset: 2px; +} + +.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover, +.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover { + background: #ffffff; + color: #000000; + text-decoration: none; +} + +/* ---------- Selection ---------- */ + +.theme-key-ascii ::selection { + background: #ffffff; + color: #000000; +} + +/* ---------- Scrollbars ---------- */ + +.theme-key-ascii ::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.theme-key-ascii ::-webkit-scrollbar-track { + background-color: #000000; + background-image: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 1px, + transparent 1px, + transparent 4px + ); +} + +.theme-key-ascii ::-webkit-scrollbar-thumb { + background: #ffffff; + border: 1px solid #000000; +} + +.theme-key-ascii ::-webkit-scrollbar-thumb:active { + background: #b0b0b0; +} + +.theme-key-ascii ::-webkit-scrollbar-corner { + background: #000000; +} + +/* ---------- Lucide icons: render as crisp white outlines ---------- */ + +.theme-key-ascii svg.lucide, +.theme-key-ascii svg[class*="lucide-"] { + color: #ffffff !important; + stroke: #ffffff !important; + fill: none !important; + stroke-width: 1.75; + filter: none; +} + +.theme-key-ascii .btn-primary svg.lucide, +.theme-key-ascii .bottom-nav button.active svg.lucide, +.theme-key-ascii .period-card.active svg.lucide, +.theme-key-ascii .method-card.active svg.lucide, +.theme-key-ascii .option-row.active svg.lucide, +.theme-key-ascii .admin-btn-primary svg.lucide, +.theme-key-ascii .admin-nav-item.active svg.lucide, +.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide, +.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide { + color: #000000 !important; + stroke: #000000 !important; +} + +/* ---------- Toast / language select polish ---------- */ + +.theme-key-ascii .toast { + background: #000000; + border: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .language-select-item { + border-radius: 0; +} + +.theme-key-ascii .language-select-item[data-highlighted], +.theme-key-ascii .language-select-item[data-selected] { + background: #ffffff; + color: #000000 !important; +} + +/* ---------- Headings: stronger console feel ---------- */ + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii h4 { + font-family: var(--font-mono); + letter-spacing: 0; + text-transform: none; +} + +.theme-key-ascii .admin-header { + background: #000000; + border-bottom: 1px solid #ffffff; + color: #ffffff; +} + +.theme-key-ascii .admin-header-title h2, +.theme-key-ascii .admin-header-title small { + color: #ffffff; +} + +/* Make any element with role progressbar but no inner span show animated stripes. */ +.theme-key-ascii [role="progressbar"]:not(.progress) { + background: + repeating-linear-gradient( + 90deg, + #ffffff 0, + #ffffff 6px, + #000000 6px, + #000000 8px + ); + animation: ascii-scan 0.6s linear infinite; + border: 1px solid #ffffff; + border-radius: 0; + color: #000000; +} + +/* ============================================================ + * Console-themed extras + * ============================================================ */ + +/* ---------- ASCII skeletons ---------- * + * Subtle dark shimmer with a single bright scan line moving across. + */ + +@keyframes ascii-skeleton-scan { + 0% { background-position: -120% 0; } + 100% { background-position: 220% 0; } +} + +.theme-key-ascii .ui-skeleton, +.theme-key-ascii .admin-skeleton, +.theme-key-ascii .skeleton-line, +.theme-key-ascii .skeleton-dot, +.theme-key-ascii .skeleton-pay-button, +.theme-key-ascii .ui-skeleton-line, +.theme-key-ascii .admin-skeleton-line, +.theme-key-ascii .admin-skeleton-line-strong, +.theme-key-ascii .admin-skeleton-line-soft, +.theme-key-ascii .admin-skeleton-line-short, +.theme-key-ascii .admin-skeleton-line-tiny, +.theme-key-ascii .ui-skeleton-title, +.theme-key-ascii .ui-skeleton-short, +.theme-key-ascii .ui-skeleton-tiny, +.theme-key-ascii .ui-skeleton-badge, +.theme-key-ascii .admin-skeleton-badge, +.theme-key-ascii .admin-skeleton-avatar, +.theme-key-ascii .admin-stat-skeleton-card, +.theme-key-ascii .admin-stat-skeleton-wide, +.theme-key-ascii .admin-cn-card-skeleton--tall { + border-radius: 0 !important; + border: 1px solid #ffffff !important; + background-color: #050505 !important; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + rgba(255, 255, 255, 0.18) 50%, + transparent 60%, + transparent 100% + ) !important; + background-size: 200% 100% !important; + background-repeat: no-repeat !important; + color: #ffffff !important; + animation: ascii-skeleton-scan 1.6s linear infinite !important; +} + +.theme-key-ascii .admin-skeleton-avatar { + width: 32px !important; + height: 32px !important; +} + +/* ---------- Empty / loading state console message ---------- */ +.theme-key-ascii .admin-empty { + position: relative; +} + +.theme-key-ascii .admin-empty::before { + content: "$ tail -f /var/log/empty.log"; + display: block; + font-family: var(--font-mono); + color: var(--muted); + margin-bottom: 8px; + letter-spacing: 0; +} + +/* ---------- Buttons: glitch on hover ---------- */ + +@keyframes ascii-glitch { + 0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); } + 20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); } + 40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); } + 60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); } + 80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); } +} + +.theme-key-ascii .btn:hover:not(:disabled)::after, +.theme-key-ascii .admin-btn:hover:not(:disabled)::after { + content: attr(data-label, ""); + pointer-events: none; +} + +/* Disable glitch text duplication if the button has no data-label. + * Apply a subtle scanline overlay instead, which is content-agnostic. */ +.theme-key-ascii .btn, +.theme-key-ascii .admin-btn, +.theme-key-ascii .admin-nav-item, +.theme-key-ascii .bottom-nav button { + overflow: hidden; +} + +.theme-key-ascii .btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-btn:hover:not(:disabled)::before, +.theme-key-ascii .admin-nav-item:hover::before, +.theme-key-ascii .bottom-nav button:hover::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0, + rgba(0, 0, 0, 0.4) 1px, + transparent 1px, + transparent 3px + ); + animation: ascii-glitch 0.6s steps(1) infinite; + z-index: 1; +} + +/* ---------- Bottom nav active markers "> item <" ---------- */ + +.theme-key-ascii .bottom-nav button.active::before, +.theme-key-ascii .admin-nav-item.active::before { + content: ">"; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active::after, +.theme-key-ascii .admin-nav-item.active::after { + content: "<"; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-mono); + color: #000000; + animation: ascii-blink 1s steps(1) infinite; + animation-delay: 0.5s; + z-index: 2; +} + +.theme-key-ascii .bottom-nav button.active, +.theme-key-ascii .admin-nav-item.active { + position: relative; +} + +/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */ +@media (max-width: 1023px) { + .theme-key-ascii .bottom-nav button.active::before, + .theme-key-ascii .bottom-nav button.active::after { + content: none; + } +} + +/* ---------- ASCII block progress fill ---------- * + * The actual fill renders as alternating █ blocks via the existing span + * gradient. We also overlay a slow scanning highlight to make it feel + * "live", and add a soft typed counter to the right edge. + */ + +/* (duplicate progress fill rules removed — see definition above) */ + +/* ---------- Headings: subtle CRT glitch on hover ---------- */ + +@keyframes ascii-heading-jitter { + 0%, 92%, 100% { transform: translate(0, 0); } + 93% { transform: translate(-1px, 0); } + 94% { transform: translate(1px, 0); } + 95% { transform: translate(0, -1px); } + 96% { transform: translate(0, 1px); } +} + +.theme-key-ascii h1, +.theme-key-ascii h2, +.theme-key-ascii h3, +.theme-key-ascii .login-brand h1, +.theme-key-ascii .admin-sidebar-brand strong, +.theme-key-ascii .admin-card-head h2, +.theme-key-ascii .admin-card-head h3 { + display: inline-block; + animation: ascii-heading-jitter 7s steps(1) infinite; +} + +/* ---------- App-shell boot banner ---------- * + * A non-blocking strip at the very top of the viewport that displays a + * typed "booting…" line, then settles. Pure CSS so it cannot interfere + * with any DOM. The animation runs once on mount. + */ + +@keyframes ascii-boot-type { + 0% { width: 0; } + 85% { width: 28ch; } + 100% { width: 28ch; } +} + +@keyframes ascii-boot-fade { + 0%, 70% { opacity: 1; } + 100% { opacity: 0; visibility: hidden; } +} + +.theme-key-ascii.app-shell::after { + content: "$ remnawave --start --tty=0"; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + display: block; + padding: 2px 8px; + max-width: 28ch; + overflow: hidden; + white-space: nowrap; + background: #000000; + color: #ffffff; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + pointer-events: none; + animation: + ascii-boot-type 1.6s steps(28) 1 both, + ascii-boot-fade 3s linear 1.6s 1 forwards; +} + +/* ---------- Toggle / checkbox squareification (best-effort) ---------- */ + +.theme-key-ascii input[type="checkbox"], +.theme-key-ascii input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 1em; + height: 1em; + border: 1px solid #ffffff; + background: #000000; + border-radius: 0 !important; + position: relative; + vertical-align: middle; + cursor: pointer; +} + +.theme-key-ascii input[type="checkbox"]:checked::after, +.theme-key-ascii input[type="radio"]:checked::after { + content: "x"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-weight: 700; + color: #ffffff; + line-height: 1; +} + +/* ---------- Code-like "$ " prefix on toast messages ---------- */ + +.theme-key-ascii .toast::before { + content: "$ "; + color: #ffffff; + font-family: var(--font-mono); + font-weight: 700; +} + +/* ---------- Disabled state — strikethrough hatching ---------- */ + +.theme-key-ascii .btn:disabled, +.theme-key-ascii .admin-btn:disabled, +.theme-key-ascii button:disabled { + background-image: repeating-linear-gradient( + -45deg, + transparent 0, + transparent 4px, + rgba(255, 255, 255, 0.18) 4px, + rgba(255, 255, 255, 0.18) 5px + ); + color: var(--dim) !important; + border-color: var(--dim) !important; + cursor: not-allowed; +} + +/* ============================================================ + * Square everything: drop all rounded corners on touched surfaces. + * ============================================================ */ + +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .period-card, .method-card, .settings-row, .option-row, + .tariff-selected-card, .tariff-action-card, .tariff-warning-card, + .topup-carryover-note, .language-select-content, .language-select-item, + .language-select-trigger, .bottom-nav, .bottom-nav button, + .field-error-tooltip, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide, + .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, .admin-select-item, + .admin-cn-card, .admin-cn-badge, .admin-badge, + .admin-cn-card-skeleton--tall, + .admin-input, .admin-textarea, .admin-btn, .admin-chip, + .admin-tabs-trigger, .admin-tabs-list, + .admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle, + .admin-header, .admin-sidebar, .admin-sidebar-brand, + .admin-dialog, + .admin-theme-editor-section, + [data-slot="card"], [data-slot="card-header"], + [data-slot="card-content"], [data-slot="card-footer"] +), +.theme-key-ascii :is( + .card, .dialog-card, .toast, + .btn, .input, + .admin-card, .admin-card-head, .admin-card-body, + .admin-stat-card, .admin-revenue-panel, .admin-empty, + .admin-tariff-card, .admin-toolbar-card, .admin-table-card, + .admin-panel-dash-card, + .admin-select-trigger, .admin-select-content, + .admin-cn-card, + .admin-input, .admin-textarea, .admin-btn, + .admin-nav-item, .admin-tabs-trigger +) * { + border-radius: 0 !important; +} + +.theme-key-ascii img, +.theme-key-ascii .admin-avatar, +.theme-key-ascii .admin-skeleton-avatar { + border-radius: 0 !important; +} + +/* ============================================================ + * Console-style tables: cell borders, header underline, + * row separator using dashed line. + * ============================================================ */ + +.theme-key-ascii .admin-table, +.theme-key-ascii table { + border-collapse: collapse; + border: 1px solid #ffffff; + font-family: var(--font-mono); +} + +.theme-key-ascii .admin-table th, +.theme-key-ascii .admin-table td, +.theme-key-ascii table th, +.theme-key-ascii table td { + border: 1px solid #ffffff; + border-radius: 0 !important; + padding: 6px 10px; +} + +.theme-key-ascii .admin-table thead th, +.theme-key-ascii table thead th { + background: #000000; + border-bottom: 2px solid #ffffff; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.theme-key-ascii .admin-table tbody tr, +.theme-key-ascii table tbody tr { + border-bottom: 1px solid #ffffff; +} + +.theme-key-ascii .admin-table tbody tr:hover, +.theme-key-ascii table tbody tr:hover { + background: rgba(255, 255, 255, 0.07); +} + +.theme-key-ascii .admin-table tbody tr:hover td, +.theme-key-ascii table tbody tr:hover td { + color: #ffffff; +} diff --git a/bot/app/web/themes/ascii/theme.json b/bot/app/web/themes/ascii/theme.json new file mode 100644 index 0000000..cd89c99 --- /dev/null +++ b/bot/app/web/themes/ascii/theme.json @@ -0,0 +1,17 @@ +{ + "key": "ascii", + "names": { + "ru": "ASCII", + "en": "ASCII" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "ascii" + } +} diff --git a/bot/app/web/themes/dark/theme.json b/bot/app/web/themes/dark/theme.json new file mode 100644 index 0000000..d620dcb --- /dev/null +++ b/bot/app/web/themes/dark/theme.json @@ -0,0 +1,27 @@ +{ + "key": "dark", + "names": { + "ru": "Темная", + "en": "Dark" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "bg": "#03070b", + "panel": "#111820", + "panel_2": "#0b1118", + "panel_3": "#17212b", + "border": "rgba(255, 255, 255, 0.12)", + "border_strong": "rgba(255, 255, 255, 0.2)", + "text": "#f2f7f4", + "muted": "#a9b4b0", + "dim": "#68736f", + "danger": "#ff6b6b", + "blue": "#2d9cff", + "radius": "8px" + } +} diff --git a/bot/app/web/themes/light/style.css b/bot/app/web/themes/light/style.css new file mode 100644 index 0000000..1317e33 --- /dev/null +++ b/bot/app/web/themes/light/style.css @@ -0,0 +1,46 @@ +.theme-key-light { + color-scheme: light; + --accent: #047857; + --bg: #f7f8fb; + --panel: #ffffff; + --panel-2: #f1f5f9; + --panel-3: #e8edf3; + --border: rgba(15, 23, 42, 0.11); + --border-strong: rgba(15, 23, 42, 0.2); + --text: #0f172a; + --muted: #475569; + --dim: #64748b; + --danger: #dc2626; + --blue: #2563eb; + --radius: 8px; + --accent-contrast: #ffffff; + --surface-sheen: rgba(15, 23, 42, 0.035); + --surface-sheen-soft: rgba(15, 23, 42, 0.012); + --surface-hover: rgba(15, 23, 42, 0.045); + --surface-muted: rgba(15, 23, 42, 0.035); + --surface-subtle-border: rgba(15, 23, 42, 0.1); + --overlay-scrim: rgba(15, 23, 42, 0.34); + --nav-bg: rgba(255, 255, 255, 0.88); + --rail-bg: rgba(255, 255, 255, 0.72); + --shadow-soft: 0 16px 38px rgba(15, 23, 42, 0.08); + --shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.16); + --shadow-popover: 0 18px 34px rgba(15, 23, 42, 0.14); + --inset-highlight: rgba(255, 255, 255, 0.75); + --admin-bg: #f7f8fb; + --admin-surface: #ffffff; + --admin-surface-2: #f1f5f9; + --admin-elev: #e8edf3; + --admin-border: rgba(15, 23, 42, 0.1); + --admin-border-strong: rgba(15, 23, 42, 0.18); + --admin-text: #0f172a; + --admin-muted: #64748b; + --admin-dim: #94a3b8; +} + +.theme-key-light.app-shell { + background: var(--bg) !important; +} + +.theme-key-light .phone-screen { + background: var(--bg); +} diff --git a/bot/app/web/themes/light/theme.json b/bot/app/web/themes/light/theme.json new file mode 100644 index 0000000..a228433 --- /dev/null +++ b/bot/app/web/themes/light/theme.json @@ -0,0 +1,16 @@ +{ + "key": "light", + "names": { + "ru": "Светлая", + "en": "Light" + }, + "enabled": true, + "default": false, + "use_primary_accent": true, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "light" + } +} diff --git a/bot/app/web/themes/windows95/icons/README.md b/bot/app/web/themes/windows95/icons/README.md new file mode 100644 index 0000000..83e5f70 --- /dev/null +++ b/bot/app/web/themes/windows95/icons/README.md @@ -0,0 +1,9 @@ +Windows 95 theme button icons +============================= + +These PNG icons were sourced from the Windows 98 Icon Viewer: + +https://win98icons.alexmeub.com/ + +Original filenames were normalized for theme CSS usage. They are loaded by +`bot/app/web/themes/windows95/style.css` through `/webapp-theme-assets/...`. diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/bot/app/web/themes/windows95/icons/arrow-left.png new file mode 100644 index 0000000000000000000000000000000000000000..d08f5e96bcebec55887b39f029fd915e9cefc652 GIT binary patch literal 610 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vJfvmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp&sB9;tEpG&~V_ufrbVk z3&dgg4+0DfKm_Cic_607#P3W%4r57>UoeBivm0q3PLj8~3%gii$7>*my~NYkmHiQ$ z6oZcZ_+ptQE9i(`n!`O=Gf{aPFZ4isjrjCthFGj*~A@0N1@1qGbb;?7CDXI*o2 zhG|T?#**jTfvOAkkx!0BwrQ+lykzpp*oS9JliZfiYkW3dxnQ-r zEqfQsWfs@mTJfJ6od@T#1?FAd=dobYly{#6S%j2sZwuv|ewSZ%Mayj)n+C6nWPVny z1)J=fdmaQt+St5nHIC!{`tXLXN%FzD`VEVs-;49i?w<9?`|^s=TottzhRdHC{`%;* zDNVU+ULE|{|Kt5X3>GUo7ARZIeF_Xo)e_f;l9a@fRIB8oR3OD*WMF8aYhb2pWENsz vU}a)rWo)QxU}9xpAh=6G3PnS1eoAIqC2kD~&u1(HYGCkm^>bP0l+XkK{50WP literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/bot/app/web/themes/windows95/icons/arrow-right.png new file mode 100644 index 0000000000000000000000000000000000000000..422537f439b8901377a8b80064ef83dec04be08c GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFS2MYfG{~t&IMIrQ!X%R_4QO1%WzhDN3XE)M7oFs2|7ml`jQ>%a+_7YEDSN2D2 zQVcrslUq$RfkFkIE{-7_*OLNGi-_8v-#!^9D07`YKZlV!w8dHS>f5L3UI&Au zZ})pQNIYt~t-a*nw`tsMT~09padCVMHY=1Gmjs_W1+-hW#5JNMC9x#cD!C{XNHG{0 z7+UBWnCTjsg%}uEnV47^8|oUESQ!`y?oyCK(U6;;l9^VCTSLP08Owkg7(8A5T-G@y GGywp$b%qN7 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/bot/app/web/themes/windows95/icons/bitcoin.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b721218f67a85d127c464eab5bd2ff11e98489 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFO`~`P`^#J8V&xy1i|n|jK>ZF$JyT%Xt# zdDZrJ8F&{LwpTU)ty3*=jVMV;EJ?LWE=mPb3`Pcq7PgTe~DWM4fdtQEo literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/check.png b/bot/app/web/themes/windows95/icons/check.png new file mode 100644 index 0000000000000000000000000000000000000000..78e209406d19be2b16425cff49f53077f85c3e19 GIT binary patch literal 340 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!93?!50ihlx9Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8Uop6XFV_L7;)*|Ns9j z8yAWJ*^DJYe!&b5&u*lFI7!~_F3f^FJ|NrJOFVsD*&ne8Gb)<}EIk$u6teJiaSY+O zo}A#oaFKzH?bw>7JUlt8!z>v(Yx#JBR)6zl5Za$`=4>s(?=B zS}5|TNL0KWXohNuYeY#(Vo9o1a#1RfVlXl=w9qv$(KWOTF)*|;HnlP}0dlPj49wkT g1AT|2AvZrIGp!Q01~+Ntk3bC!p00i_>zopr06;}o-2eap literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/bot/app/web/themes/windows95/icons/chevrons.png new file mode 100644 index 0000000000000000000000000000000000000000..cb925ce5c1a131ae830f92c6a25de19f0858a16f GIT binary patch literal 375 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFEal|aXt3TLEZ)f9_K*!SccOE3_byx+9{0R;@h_H3LRQr?;+Q7#~|(c&3ebO zRdP`(kYX@0FtpG$Fw-?M2{AIZGBC62_Vx`_3TBUAY)08UoeBivm0q3PLj8~3+s>km&bt|_7YEDSN2D2 zQVhCgCv@hr0EG%YT^vI=t|uoXh_Em!vaz`(Cnu|`YdCvy9^qhR6i{BX#rTxS5f1&i zk~~Ro_zFuoZ|WX7@OGD}iAmLJf##AEc?todNeOp&MQ*aB7) z;n+lF_cCn*2UiE43A211W;t?lG%zvLTvFV;C&u;|&~nuh*NBpo#FA92@P5Lb|bhKBzP4F5rZ z;lP0d3=KdrARmGo%rq~66f>3t`2{mLJiCzw;v{*yyD$s#_-x;Tb#TsJ*q$k(92)A~?dy+NRP@k6#Hv$fR~*mgEv^j&`L-l4l^gQGnD zEZ5HsesEr5@l^4ME=~#7iD|s59NNW)H`u#Y^6$Rq-zcAGK6A^}FF`Bob9k1i)&J75 z{L;DP!GZL$Ju!SQi~{{c-+mJ);FYh;6T1=}$dV1TUA4qDq9i4;B-JXpC>2OC7#SE^ z=o*;m8kvL`8Cw~cTA3Q?8kkra7<}EkeJYBE-29Zxv`X9>S|0Fg0yQvry85}Sb4q9e E0P^^O{r~^~ literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/error.png b/bot/app/web/themes/windows95/icons/error.png new file mode 100644 index 0000000000000000000000000000000000000000..5d046a3881257e9d04975fd3b2b1d2302feb6aa3 GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFYWe&iKOOKPuhx*R9wz-Ez+F)*+)F|sl-)ip4&GB9|gR~wF^AvZrIGp!Q0hRMZ8 RUjsETc)I$ztaD0e0syTmZ+8Fy literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/bot/app/web/themes/windows95/icons/file-text.png new file mode 100644 index 0000000000000000000000000000000000000000..c1ab35e8dce4a852ef06f7621b5db266fae8ebed GIT binary patch literal 388 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF21sKVp+&&^1u> zw=e_>#e2FqhHzZh?LElbV8C;vDS}~w6+`IF2G$O#dp1&TFa9Y%*!Cm!Zmfvl%rJo) zei5@)WO12r>bdikn=a1u++cX}=&_Xgi5^K=|DSBLyXbUDe#)t-&V3zcM0cfRTsr-n zIl4nk)X1CX9?&Y)64!{5l*E!$tK_0oAjM#0U}&LhV5Vzi7GhvvWnyY&0_2)l85kt0 gvcE&okei>9nO2EggFwZ8P_Qz1y85}Sb4q9e05XJjmH+?% literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/folder.png b/bot/app/web/themes/windows95/icons/folder.png new file mode 100644 index 0000000000000000000000000000000000000000..437efbf3b6f7f4a6e4513567bf122c9da9548981 GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF# z5u1cKqcCS>{A8ezzo(012*>s0ga&3uM@56P8IPn^FeG&>UAmN$i_6HMV@HF-45nre z4UQ>}5)U65ObqJqu!vbAqQoS^!EWf)xIpFvgCqwF7mtru!h!=0j10$IB&F8#{aykz zTD8PAq9i4;B-JXpC>2OC7#SE^=o*;m8kvL`8Cw|`S{YjE8kkra7`$zXIfkMkH$Npa XtrE9}IX9nx{KMes>gTe~DWM4f9$j!V literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/gift.png b/bot/app/web/themes/windows95/icons/gift.png new file mode 100644 index 0000000000000000000000000000000000000000..06979223c2a9e0d7ec68ebeb55c61a38b7acd708 GIT binary patch literal 424 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF2_Vx`_3TBUAY)08UoeBivm0q3PLj8~3+s>km&bt|_7YEDSN2D2 zQVhCgCv@hr0EG%YT^vI=t|uoXh_Em!vaz`(Cnu|`YdCvy9^qhR6i{BX#rTxS5f1&i zk~~Ro_zFuoZ|WX7@OGD}iAmLJf##AEc?todNeOp&MQ*aB7) z;n+lF_cCn*2UiE43A211W;t?lG%zvLTvFV;C&u;|&~nuh*NBpo#FA92NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp%~y3;tEpG(D48N{{sgO zF#HE{KmrUvRs)E|@c+M}gxEVEgRvyYFPOpM*^M+1C&}C0g(;1@CI!f0FY)wsWq-sb z#bBZI)Omd_P}Oogu^H>6)Xd-c1YEt>ZvC$E?)LxB?mKUP#eUCmYGA|M z+0DR^RxNRjC`m~yNwrEYN(E93Mh1o!x&~&tMrI)f23DqKR>mf}1}0Vp25mnX98fgm b=BH$)RpQq0eaVhLKn)C@u6{1-oD!M<^#kqm literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/help.png b/bot/app/web/themes/windows95/icons/help.png new file mode 100644 index 0000000000000000000000000000000000000000..1aa4921a70af7591a201d2fe2810f3d693323b37 GIT binary patch literal 364 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF8RSQ6wH%;50sMjD8d21sKVp**=MsPWzzJwQ zgO{g^V+hCfn4(Dq~~{RB%4x8p3jngVETbhMbVI(pOTqYiCe>rEDlYe1_n=8KbLh* G2~7Y@*k33B literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/home.png b/bot/app/web/themes/windows95/icons/home.png new file mode 100644 index 0000000000000000000000000000000000000000..a713e61d4ea75608137884be750683b87b0476af GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF|fuNuuqZY#+mIXZG;sS?naGc<3?pQIqLE_l4V@KcS zB?y*wcL{CqD6FpD%^cuzl$FIn$ssazLWfY(!Z^MZ4(6p-q8oS?SaC3OILwk^@H-&7 zX2vE{exNm~C9V-ADTyViR>?)FK#IZ0z|cb1z)aW3B*e(r%FxKl6v#EPGB9|`VC#*d dAvZrIGp!Q0hAFYF-+>w!JYD@<);T3K0RUz8Zo~ip literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/info.png b/bot/app/web/themes/windows95/icons/info.png new file mode 100644 index 0000000000000000000000000000000000000000..d540eb7dcdb9bed8d94e275eb04e37ed6365cd67 GIT binary patch literal 385 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF21sKVp+&(9)a2IC&~i zDBjb>F@)oK=-Gq32NXD1FXYEKNLf4Be_dy&mvHC6-*U}cUoO-?WMyIyn8?sQ;R=sM zQOUX98!H*v!-LFX%#tGK{y4sKVps0n34Z2T8L>zTxI1#`n zY^YhzV%Q`idig*P&+{!W71)AjeEzb@X+qXjm*nK~30ccj1gC2%C_Z&%;+dzQxQvI9 zO>Dx1B7q6dIj~>+Ba;v#V=F^5D`OK~0~0F)gSRUG4xwns%}>cptHiBgPNiENPy>Uf LtDnm{r-UW|7-xN6 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/lock.png b/bot/app/web/themes/windows95/icons/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..224052ba0df61d9b4de4bb4093401cc40332fee3 GIT binary patch literal 356 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFAf83^RCmw5WRvOi*z5Eqc$D-j5^hQZ0x z#W95AdU8TSf`CX01G8JIGqZv7iH1OlGXiYNB5ey6Y+&$Mt02(O;u6!qaE61G$$(iy z*~P&{VA)*>7KLq1j0_PUxCAr5|Je#OO|`@|q9i4;B-JXpC>2OC7#SE^=o*;m8kvL` v8Cw~eSs7XC8kkra7&t`EpM#7-sS literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/bot/app/web/themes/windows95/icons/megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..a697102535d82469f2aea9399585c272b031655a GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFE;PA<-^rAi=YeM|KF%KGhP}h?11Vl2ohYqEsNoU}Ruup=)5KYh)5)WNc+?sMwD1!`dMboFyt=akR{0J?{8S^xk5 literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/bot/app/web/themes/windows95/icons/paintbrush.png new file mode 100644 index 0000000000000000000000000000000000000000..b8957002c3ae6852fc3d70f5fa3ecda17dd8fa42 GIT binary patch literal 474 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>(?JYe*FLc|Ftu2oCm69ED7=pW^j0R17sd!lDE5y#O%ygIUt9<#M9T6{SliKgRa>L zow+PPp?Xgj#}J9jq32#pH7W40J(zoPQ3%K0|JJ!aM!)6$y-96mWcbG9PTj4yQ!0 zIT)_aj|;P1xb^HR^En%WZ)?R_XN2!AW4W^L*+$J1@pd*stO3FrM_qtUQ7v(eC`m~y zNwrEYN(E93Mh1o!x&~&tMrI)f2397fR;DJp1}0Vp23h*KAh#nlmdKI;Vst0DDE7_y7O^ literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/bot/app/web/themes/windows95/icons/payment-card.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b721218f67a85d127c464eab5bd2ff11e98489 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFO`~`P`^#J8V&xy1i|n|jK>ZF$JyT%Xt# zdDZrJ8F&{LwpTU)ty3*=jVMV;EJ?LWE=mPb3`Pcq7PgTe~DWM4fdtQEo literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/print.png b/bot/app/web/themes/windows95/icons/print.png new file mode 100644 index 0000000000000000000000000000000000000000..04e2839a4bc7f3d2464806c7c18eaf3297c04e86 GIT binary patch literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>*eL;2M!!y_|K3wli|#nGyfSF&M+8Hn>KCc%$W-oEZMYa z)BpefUD@^q097-V1o;IsI6S+N2I3@nySs=?x_{CX$YC$>^mS!_#3sdHAYQHc&%Q#N&VoZ7o0Y@hb6s8q45qUM=v zPs!g@PO+BMNMl+0kS}fGapTK#+5$MzHea_kE~x0@cy{Ja`3I2~tn#%Ve0K0&?w>d# zUxA&O@r$C#2Ak^eqX+)Dd=;*H=zr8QUT^CM?qFV4i3r9Gu|SuombgZgq$HN4S|t~y z0x1R~149d412bJCvk(IVD-#PVBV%0y6DtFQfXx?`Q8eV{r(~v8;?|(}E^|Im1B0il KpUXO@geCxW&7pn( literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/bot/app/web/themes/windows95/icons/refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..be089258633da7b7a3805369996ae31267a7e2d4 GIT binary patch literal 327 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFp1!W^kJzLbbWH~>+BeM_#11l3#D^p`#0~0F)gY_N3T__rI^HVa@DsgML TclEUyPy>UftDnm{r-UW|Ii*)u literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/save.png b/bot/app/web/themes/windows95/icons/save.png new file mode 100644 index 0000000000000000000000000000000000000000..2e70ec12d62acf256662afe3d1ae0f4e106e8c27 GIT binary patch literal 411 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFd9# zKJ)*7Ap8IS|7U>c%$WlR4s6=A=|2O5PRUAPpgN|KAirP+hO)_LEr2}6ByV>Yw#2j@ zwLlJgiKnkC`y)09aSla+oUKtnp)^ky#}JO|wkLLTHaG~hJapV3;4Y%~eP(vvCeP1X#Sbw4Ed6gOI(e8H!lvJ%3oOg<>TRk7?)FK#IZ0 zz|cb1z)aW3B*e(r%D}?P*jU%V#LB?n`w@M66b-rgDVb@NxHYuRoZbx7z~JfX=d#Wz Gp$P!eZ-cD> literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/search.png b/bot/app/web/themes/windows95/icons/search.png new file mode 100644 index 0000000000000000000000000000000000000000..0102a91d72df614e6f5452a3a6f399197dc02e36 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF`sfBQ_}pV?L97 zDUdleDd#KJ<*kGSYQzl zq`CFKK@G!11)Gyh2770+{bakpA?v#0;fNWV4}~1~G-svbs!6^_YhnyT6_QSVojL2# zT}G3dph*I=%U=Gx1+-4J#5JNMC9x#cD!C{XNHG{07+UBWnCTjsg%}uEnHpLdn&=vs lSQ!|oG03=}Xvob^$xN%ntsy*i$q%3g22WQ%mvv4FO#m&Uc)S1r literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/send.png b/bot/app/web/themes/windows95/icons/send.png new file mode 100644 index 0000000000000000000000000000000000000000..67b9ae1b8a9989af8343e7b30c1a84d67571d495 GIT binary patch literal 415 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF%UlaPd6su>XOBdjo_%3GYu%#sN1J0@1G6i?u3q9`y|&08 zVw;aeE3e2WowIk%TN=dHpZy+m(O$JiY+=N6BN?FOswJ)wB`Jv|saDBFsX&Us$iUD- z*T78I$Rxzb*vin{$^^(Yu`)0?-~4SNiiX_$l+3hB+#2@X^V0=tVDNPHb6Mw<&;$UN Cje~9g literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/settings.png b/bot/app/web/themes/windows95/icons/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..0d56f79b7f67a488e418a0ad1da2fb172d24be5b GIT binary patch literal 393 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF+#H5MV7p{ATEt&;*LOk!d_k*BC|b^!|; z7Y{@8RgsgwCVjsPv`@9fHKHUXu_V>=^B}Z7#LWY8d@2d=o*+< l85peSmtKydAvZrIGp!Q0hMSibO9M49c)I$ztaD0e0sx4nbjAPx literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/shield.png b/bot/app/web/themes/windows95/icons/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..8ce794a092e7addcabb5204a781fd402f71f787b GIT binary patch literal 422 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF@P5LX}#1Pvg-@c;k+ z0|yTL|Ig3>7Gq!lvKrJa3lf1`#*!evUYmi~;Jfj|y>iKnkC`y&=% zMrD1Ib9vK&LZzNAjv*Y^lM@m|5*ip7-8jyw99XgB$dV;T4(Q}$JX$66=tRP-X<`zO zgr1y85R!_z@$d>$N=vKgt{tlwBv-C%)zi}x5DZTa?&#ROaicQx;spyhW-Me1VVD+F zSRz3fdvm;95>X)@b(IHOE&T_to?)F zK#IZ0z|cb1z(m*3GQ_~p%GlJ(#2m=AGB7yB(&&exAvZrIGp!Q0hTV~tpMV+|JYD@< J);T3K0RU}wf)oG% literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/bot/app/web/themes/windows95/icons/sliders.png new file mode 100644 index 0000000000000000000000000000000000000000..d3badce15a71a6c085cbe573fb619ef856c65091 GIT binary patch literal 478 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>c-DEnfz`?>9x21DqS(#{1nBNl@?2={n~fJv`6x@(DnUOMW$6mo7Ws=JL9Wheb(cnY>G_?{NAnxBKNZf%nXS{hqRMJxR1L%V2W(|41gAX$ zIz+X^HKHUXu_V>=^B}Z7#LWY8d@0{>l&C?85rbhsA{5U$jwj5 YOsmALL8f%>RG14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>|MK#3pqc{*4$Pd%aQE)r$B!R}hK63hetpxXP5=MT zocaHZudi>~nVDz)&pZPHGaFnF{RW!LR1)L|bYt1%vlc)eW0JSK3%C6BBL{#S_7YED zSN2D265?FKuX@`efI|JAE{-7*mqX7xiZvT>uv{pKGHq+fT($Dr@B5n%2a9i-{hy;n zV4c$r(GM(nAFqBr?3mD#?EX4f$#F)qn|;cXC9f1_vbe3-FJLO1l0RSVl=DJ%L#;i| zK673eJns3VHq&U6)_eijrngMantOX+PLNSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp%UN|;tEpG(7@2Z@c;k+ z0|yQ;{09pB2MT}yPzc0rShC@FI*?{83GxeOaCmkj4a7FMGaqH%uhgpJwF4kE|S`>ad)*YIi9WryPmtj}+y*iBq_@Zr)& zK?MdSR#r>g?->0M=MX*47q+8*-TR&;FYnad{{DFP`@Q8oe<$l4FU(uF_qPD&jekEX zCvY&_+2?bhm$j0AzIp;HBl{PD^6$bsM4Q%>Gky+c=~#JSk>&ov&8)0HV&h7bH^{vX z?w#;0K5@I8+7~M`+s;Xv%;-R zj%%4ND!_14Epd$~Nl7e8wMs5Z1yT$~28I^824=cOW+4U!R;FfF#>Tn^CRPRp&T)3< eP&DM`r(~v8;?`iq+*SnCz~JfX=d#Wzp$PyY$KBch literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/tag.png b/bot/app/web/themes/windows95/icons/tag.png new file mode 100644 index 0000000000000000000000000000000000000000..13364e304b5752a8ece817fa14760cc43420d8b1 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF?NMQuI!Ikgc+5M z70w&o01Cx=x;Tb#Tu)ADV07dVNDweG3j;!-L;*f;Zf{?2Hls=1moEi1@+>U*+GDa= zWrN3RS2hnVT^;4MqAH@(rio2so8|w_YgGUM literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/bot/app/web/themes/windows95/icons/ticket.png new file mode 100644 index 0000000000000000000000000000000000000000..13364e304b5752a8ece817fa14760cc43420d8b1 GIT binary patch literal 392 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF?NMQuI!Ikgc+5M z70w&o01Cx=x;Tb#Tu)ADV07dVNDweG3j;!-L;*f;Zf{?2Hls=1moEi1@+>U*+GDa= zWrN3RS2hnVT^;4MqAH@(rio2so8|w_YgGUM literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/trash.png b/bot/app/web/themes/windows95/icons/trash.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ca801629c4536e76cd014d03c1a1edceea6456 GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFvs9>28llhsWz2A2T!B-MrAgUn6+ zUp{^FjM>X7vs3b&QGvlZ3*pamrOVY)n4|=QW~_gAvTp4`r|Ky;mpoy=6?K69%^$u? z-)^N%0@|cn;u=wsl30>zm0Xkxq!^403@vmG%yf;+LJSP7Of0PoOmq!QtPBh+J~zKZ d(U6;;l9^VCTZ891Yfz9fc)I$ztaD0e0sv}Qc3}Vj literal 0 HcmV?d00001 diff --git a/bot/app/web/themes/windows95/icons/user.png b/bot/app/web/themes/windows95/icons/user.png new file mode 100644 index 0000000000000000000000000000000000000000..fa18aafa3c0da1910e296156f65c6234273e5e7c GIT binary patch literal 419 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFeA)83oRAq;NQ~9^jeKFiCO3Lk$g$HB2Fog)}r4rKFuW zkQAk%v4mwxlBO<8mj<`Nj-~C)SG5==Gc%i8B;c!?X0Etn9HzsWQNHdlM`2{mLJiCzw;v{*yyD$s#_K53|Gffl+&borHAI2X*zx zB8CGK<-|61|9SAs*Z$O%*$SzKEWECNyDg5b_tJ9Zv2>qZ7}A?`TI5}p#gtOJ#)s#E zUbAian)Ubl-(w#bIG*yyI5Rdp0NSZq;u=wsl30>zm0Xkxq!^403@vmG%yf;+LJSP7 tOiiqe&2Eal|aox7(Aa8>K5A$5!1vw9N8gB6jIDVhssp7i#x~H8a!#U>GfbA8Y%I{UZqEElr zbyQn&@1kR?Q&pB2&39_*c=WXP(@Sx0PadN#&NGMXZ)frGZ@ndP0BE>siEBhjN@7W> zRdP`(kYX@0FtpG$Fw-?M3o$UTGBL6;G1oOPu`)0y-I!B{q9HdwB{QuOw+5CsR}KL+ OFnGH9xvX!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8U)v6XFV_{{zwg|LKw` zazGAaNswPKgTu2MX&_FLx4R2d8h1?!ki%Z$>Fdh=h)s$?N9|0*9z~##y{C&~2*-8R zo{hW=h8)cOhoo(OFY$cO^ez6WV`k3D<;E$FCGt-UGP9QRn|FTZI4v9~yk*+`hqKhO zOn%C)`OZAsj!Udhp^+bGlxm4 web.Response: return web.json_response({"ok": True}) @@ -10,6 +17,111 @@ async def css_asset_route(request: web.Request) -> web.Response: return await _serve_template_asset(request, "subscription_webapp.css", "text/css") +def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180) + + +def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]: + return _safe_theme_relative_path( + raw_path, + allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES), + max_length=220, + ) + + +def _safe_theme_relative_path( + raw_path: str, + *, + allowed_suffixes: set[str], + max_length: int, +) -> Optional[Path]: + value = str(raw_path or "").replace("\\", "/").strip().lstrip("/") + if not value or len(value) > max_length or "\x00" in value: + return None + parts = [part for part in value.split("/") if part] + if len(parts) < 2 or any(part in {".", ".."} for part in parts): + return None + if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts): + return None + rel_path = Path(*parts) + if rel_path.suffix.lower() not in allowed_suffixes: + return None + return rel_path + + +async def theme_css_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_css_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + try: + if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES: + raise web.HTTPNotFound(text="theme_css_too_large") + text = path.read_text(encoding="utf-8") + except OSError: + defaults = default_webapp_theme_css_files() + text = defaults.get(rel_path.as_posix()) + if text is None: + raise web.HTTPNotFound(text="theme_css_not_found") from None + + response = web.Response(text=text, content_type="text/css", charset="utf-8") + response.headers["Cache-Control"] = "no-cache" + return response + + +async def theme_asset_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR) + rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "") + if rel_path is None: + raise web.HTTPNotFound(text="theme_asset_not_found") + + root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve() + path = (root / rel_path).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + + suffix = rel_path.suffix.lower() + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix) + if not content_type: + raise web.HTTPNotFound(text="theme_asset_not_found") + + try: + if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_too_large") + body = path.read_bytes() + except OSError: + fallback = default_webapp_theme_asset_file(rel_path) + if fallback is None: + raise web.HTTPNotFound(text="theme_asset_not_found") from None + body, fallback_suffix = fallback + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) + + if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: + raise web.HTTPNotFound(text="theme_asset_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=3600" + return response + + def _resolve_webapp_logo_url(settings: Settings) -> str: raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: @@ -628,9 +740,16 @@ async def index_route(request: web.Request) -> web.Response: html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) + themes_catalog = settings.webapp_themes_catalog config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "themesCatalog": public_themes_catalog_payload( + themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), + "themesDir": settings.WEBAPP_THEMES_DIR, "logoUrl": cached["logo_url"], "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index ee78959..d6a5862 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -20,6 +20,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: webapp_animated_emoji_route, ) app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) + app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_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_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) diff --git a/bot/app/web/webapp/serializers.py b/bot/app/web/webapp/serializers.py index 174cd53..9ae8300 100644 --- a/bot/app/web/webapp/serializers.py +++ b/bot/app/web/webapp/serializers.py @@ -1,6 +1,8 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from config.webapp_themes_config import public_themes_catalog_payload + async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: settings: Settings = request.app["settings"] @@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A stars_traffic_packages=cached["stars_traffic_packages"], ), "payment_methods": _serialize_payment_methods(settings, request.app), + "themes_catalog": public_themes_catalog_payload( + settings.webapp_themes_catalog, + settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + enabled_only=True, + ), "settings": { "support_url": settings.SUPPORT_LINK, "traffic_mode": bool(settings.traffic_sale_mode), diff --git a/config/settings.py b/config/settings.py index 31d8302..be1078a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va from pydantic_settings import BaseSettings, SettingsConfigDict from config.tariffs_config import TariffsConfig, load_tariffs_config +from config.webapp_themes_config import ( + WebappThemesConfig, + resolved_webapp_themes_catalog, +) def _split_csv(value: Optional[str]) -> List[str]: @@ -351,6 +355,19 @@ class Settings(BaseSettings): WEBAPP_SERVER_PORT: int = Field(default=8081) WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a") + WEBAPP_THEMES_DIR: str = Field( + default="data/themes", + description=( + "Directory with per-theme folders. Each theme lives in " + "/theme.json with optional CSS/assets next to it." + ), + ) + WEBAPP_DEFAULT_THEME: Optional[str] = Field( + default=None, + description=( + "Override the descriptor-marked default theme when set to an existing theme key." + ), + ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI_FONT: str = Field( @@ -810,6 +827,15 @@ class Settings(BaseSettings): def tariffs_config(self) -> Optional[TariffsConfig]: return load_tariffs_config(self.TARIFFS_CONFIG_PATH) + @computed_field + @property + def webapp_themes_catalog(self) -> WebappThemesConfig: + return resolved_webapp_themes_catalog( + primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a", + env_default_theme=self.WEBAPP_DEFAULT_THEME, + theme_dir=self.WEBAPP_THEMES_DIR, + ) + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py new file mode 100644 index 0000000..e301498 --- /dev/null +++ b/config/webapp_themes_config.py @@ -0,0 +1,586 @@ +"""File-backed catalog of Web App UI themes.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field, model_validator + +logger = logging.getLogger(__name__) + +ColorScheme = Literal["light", "dark"] + + +class ThemeTokens(BaseModel): + """CSS design tokens for the subscription Mini App shell.""" + + model_config = {"extra": "ignore"} + + color_scheme: ColorScheme = "dark" + style_preset: Optional[str] = None + accent: Optional[str] = None + bg: Optional[str] = None + panel: Optional[str] = None + panel_2: Optional[str] = None + panel_3: Optional[str] = None + border: Optional[str] = None + border_strong: Optional[str] = None + text: Optional[str] = None + muted: Optional[str] = None + dim: Optional[str] = None + danger: Optional[str] = None + blue: Optional[str] = None + radius: Optional[str] = None + font_sans: Optional[str] = None + font_logo: Optional[str] = None + font_mono: Optional[str] = None + admin_bg: Optional[str] = None + admin_surface: Optional[str] = None + admin_surface_2: Optional[str] = None + admin_elev: Optional[str] = None + admin_border: Optional[str] = None + admin_border_strong: Optional[str] = None + admin_text: Optional[str] = None + admin_muted: Optional[str] = None + admin_dim: Optional[str] = None + + +class WebappTheme(BaseModel): + """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" + + model_config = {"extra": "ignore"} + + key: str = Field(min_length=1, max_length=64) + names: Dict[str, str] = Field(default_factory=dict) + enabled: bool = True + default: bool = False + use_primary_accent: bool = True + use_in_admin: bool = True + css_file: Optional[str] = None + assets_version: int = 1 + tokens: ThemeTokens = Field(default_factory=ThemeTokens) + + +class WebappThemesConfig(BaseModel): + """Runtime catalog assembled from individual theme descriptor files.""" + + model_config = {"extra": "ignore"} + + default_theme: str = "dark" + themes: List[WebappTheme] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_default_and_keys(self) -> WebappThemesConfig: + keys = [t.key for t in self.themes] + if len(keys) != len(set(keys)): + raise ValueError("duplicate theme keys") + if self.themes and self.default_theme not in keys: + raise ValueError("default_theme must match a theme key") + return self + + def theme_by_key(self, key: str) -> Optional[WebappTheme]: + for theme in self.themes: + if theme.key == key: + return theme + return None + + def enabled_themes(self) -> List[WebappTheme]: + return [theme for theme in self.themes if theme.enabled] + + +DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii") +THEME_DESCRIPTOR_FILENAME = "theme.json" +DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes" + + +def _safe_theme_key(value: str) -> Optional[str]: + key = str(value or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return key + return None + + +def _theme_dir_path(theme_dir: str | Path, key: str) -> Path: + safe_key = _safe_theme_key(key) + if not safe_key: + raise ValueError(f"invalid theme key: {key!r}") + return Path(theme_dir).expanduser() / safe_key + + +def _theme_file_path(theme_dir: str | Path, key: str) -> Path: + return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME + + +def default_webapp_theme_css_files() -> Dict[str, str]: + """Read default theme CSS from repository files.""" + out: Dict[str, str] = {} + for key in DEFAULT_THEME_KEYS: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + for source_path in sorted(source_dir.rglob("*.css")): + rel_path = Path(key) / source_path.relative_to(source_dir) + try: + content = source_path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning( + "Default webapp theme CSS source file is missing: %s (%s)", + source_path, + exc, + ) + continue + out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n" + return out + + +def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]: + """Read a default theme asset from the repository theme folder.""" + relative = Path(rel_path) + if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts: + return None + source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve() + try: + source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve()) + except ValueError: + return None + try: + return source_path.read_bytes(), source_path.suffix.lower() + except OSError: + return None + + +def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]: + """Read default theme descriptors from repository files.""" + out: Dict[str, Dict[str, Any]] = {} + for key in DEFAULT_THEME_KEYS: + source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME + try: + raw = json.loads(source_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning( + "Default webapp theme descriptor is missing or invalid: %s (%s)", + source_path, + exc, + ) + continue + if not isinstance(raw, dict): + continue + safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name)) + if safe_key: + raw["key"] = safe_key + out[safe_key] = raw + return out + + +def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]: + if not isinstance(raw, dict): + logger.warning("Ignoring theme descriptor %s: expected JSON object", path) + return None + data = dict(raw) + data["key"] = data.get("key") or ( + path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem + ) + safe_key = _safe_theme_key(str(data["key"])) + if not safe_key: + logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"]) + return None + data["key"] = safe_key + try: + return WebappTheme.model_validate(data) + except ValueError as exc: + logger.warning("Ignoring theme descriptor %s: %s", path, exc) + return None + + +def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]: + theme_path = Path(path).expanduser() + try: + raw = json.loads(theme_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc) + return None + return _theme_from_descriptor(theme_path, raw) + + +def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]: + root = Path(theme_dir).expanduser() + if not root.exists(): + return [] + themes_by_key: Dict[str, WebappTheme] = {} + for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")): + if path.parent.name.startswith("_"): + continue + theme = load_webapp_theme_file(path) + if theme is None: + continue + if theme.key in themes_by_key: + logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path) + continue + themes_by_key[theme.key] = theme + return list(themes_by_key.values()) + + +def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig: + data = config.model_dump(mode="json", exclude_none=True) + default_theme = str(data.get("default_theme") or "dark") + themes = data.get("themes", []) + for theme in themes: + if isinstance(theme, dict): + theme["default"] = theme.get("key") == default_theme + + def _sort_key(item: tuple[int, Any]) -> tuple[int, int]: + idx, theme = item + if not isinstance(theme, dict): + return (len(THEME_DISPLAY_ORDER), idx) + try: + priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or "")) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, idx) + + data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)] + return WebappThemesConfig.model_validate(data) + + +THEME_DISPLAY_ORDER = ("dark", "light") + + +def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: + try: + priority = THEME_DISPLAY_ORDER.index(theme.key) + except ValueError: + priority = len(THEME_DISPLAY_ORDER) + return (priority, index) + + +def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: + return [theme for _, theme in sorted( + ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), + key=lambda pair: pair[0], + )] + + +def _themes_config_from_list( + default_theme: Optional[str], + themes: List[WebappTheme], +) -> WebappThemesConfig: + keys = {theme.key for theme in themes} + descriptor_default = next( + (theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS), + None, + ) or next((theme.key for theme in themes if theme.default), None) + resolved_default = default_theme or descriptor_default or "dark" + if themes and resolved_default not in keys: + resolved_default = "dark" if "dark" in keys else themes[0].key + config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes)) + return _config_with_synced_default_flags(config) + + +def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = theme.model_dump(mode="json", exclude_none=True) + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + try: + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(path) + except PermissionError: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + path.write_text(payload, encoding="utf-8") + + +def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None: + source_dir = DEFAULT_THEMES_SOURCE_DIR / key + if not source_dir.exists(): + return + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME: + continue + rel_path = source_path.relative_to(source_dir) + target_path = target_dir / rel_path + if target_path.exists() and not overwrite: + continue + try: + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(source_path.read_bytes()) + except OSError as exc: + logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc) + + +def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: + style_path = target_dir / "style.css" + try: + style = style_path.read_text(encoding="utf-8") + except OSError: + return True + if key == "light": + return ".theme-key-light.app-shell" not in style + if key == "ascii": + return ( + ".theme-key-ascii" not in style + or "ascii-spin" not in style + or "ascii-skeleton-scan" not in style + or "ascii-boot-type" not in style + or "Console-style tables" not in style + ) + if key != "windows95": + return False + required_icons = ( + "arrow-right.png", + "dashboard.png", + "megaphone.png", + "paintbrush.png", + "sliders.png", + "sparkles.png", + "tag.png", + ) + return ( + "lucide-house" not in style + or "lucide-earth" not in style + or "lucide-circle-check" not in style + or "border-radius: 0 !important" not in style + or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) + ) + + +def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None: + """Seed source-controlled default theme folders into the mounted data directory.""" + if not theme_dir: + return + root = Path(theme_dir).expanduser() + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + logger.warning("Could not create webapp themes directory at %s: %s", root, exc) + return + + existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root)) + existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)} + for key, descriptor in default_webapp_theme_descriptors().items(): + theme_dir_path = _theme_dir_path(root, key) + path = _theme_file_path(root, key) + existing = existing_by_key.get(key) + source_assets_version = int(descriptor.get("assets_version") or 1) + should_sync_assets = ( + existing is not None + and key in DEFAULT_THEME_KEYS + and ( + int(existing.assets_version or 0) < source_assets_version + or _builtin_theme_assets_need_refresh(key, theme_dir_path) + ) + ) + if not path.exists(): + seed_descriptor = dict(descriptor) + if existing_has_default: + seed_descriptor["default"] = False + theme = _theme_from_descriptor(path, seed_descriptor) + if theme is not None: + try: + _write_webapp_theme_file(path, theme) + except OSError as exc: + logger.warning( + "Could not create default webapp theme descriptor %s: %s", + path, + exc, + ) + elif should_sync_assets and existing is not None: + data = existing.model_dump(mode="json", exclude_none=True) + data["assets_version"] = source_assets_version + if descriptor.get("css_file"): + data["css_file"] = descriptor["css_file"] + try: + theme = WebappTheme.model_validate(data) + _write_webapp_theme_file(path, theme) + except (OSError, ValueError) as exc: + logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc) + _copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets) + + +def write_webapp_theme_dir( + theme_dir: str | Path, + config: WebappThemesConfig, + *, + delete_missing: bool = False, +) -> None: + """Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/.""" + root = Path(theme_dir).expanduser() + root.mkdir(parents=True, exist_ok=True) + normalized = _config_with_synced_default_flags(config) + keep_paths = set() + for theme in normalized.themes: + path = _theme_file_path(root, theme.key) + _write_webapp_theme_file(path, theme) + keep_paths.add(path.resolve()) + + if not delete_missing: + return + for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"): + if path.parent.name.startswith("_") or path.resolve() in keep_paths: + continue + try: + path.unlink() + if not any(path.parent.iterdir()): + path.parent.rmdir() + except OSError as exc: + logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc) + + +def ensure_webapp_core_themes( + config: WebappThemesConfig, primary_accent: str +) -> tuple[WebappThemesConfig, bool]: + """Keep dark, light and Windows 95 themes available without clobbering custom edits.""" + data = config.model_dump(mode="json", exclude_none=True) + themes = data.setdefault("themes", []) + by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)} + changed = False + + for builtin in builtin_webapp_themes_config(primary_accent).themes: + builtin_data = builtin.model_dump(mode="json", exclude_none=True) + existing = by_key.get(builtin.key) + if existing is None: + themes.append(builtin_data) + by_key[builtin.key] = builtin_data + changed = True + continue + + if existing.get("enabled") is False: + existing["enabled"] = True + changed = True + + if "use_primary_accent" not in existing: + existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True) + changed = True + + if "use_in_admin" not in existing: + existing["use_in_admin"] = builtin_data.get("use_in_admin", True) + changed = True + + if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1): + existing["assets_version"] = builtin_data.get("assets_version", 1) + changed = True + + if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"): + existing["css_file"] = builtin_data.get("css_file") + changed = True + + tokens = existing.setdefault("tokens", {}) + builtin_tokens = builtin_data.get("tokens", {}) + for token_key in ("color_scheme", "style_preset"): + if token_key in builtin_tokens and not tokens.get(token_key): + tokens[token_key] = builtin_tokens[token_key] + changed = True + + keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)} + if themes and data.get("default_theme") not in keys: + data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark") + changed = True + + normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + if normalized.model_dump(mode="json", exclude_none=True) != data: + changed = True + return normalized, changed + + +def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig: + """Default catalog backed by repository theme descriptor files.""" + accent = (primary_accent or "#00fe7a").strip() or "#00fe7a" + themes: List[WebappTheme] = [] + descriptors = default_webapp_theme_descriptors() + for key in DEFAULT_THEME_KEYS: + raw = descriptors.get(key) + if not raw: + continue + theme = _theme_from_descriptor( + DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME, + raw, + ) + if theme is None: + continue + if theme.key == "dark" and not theme.tokens.accent: + theme.tokens.accent = accent + themes.append(theme) + return _themes_config_from_list(None, themes) + + +def apply_webapp_theme_env_overrides( + config: WebappThemesConfig, env_default_theme: Optional[str] +) -> WebappThemesConfig: + """If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme.""" + raw = (env_default_theme or "").strip() + if not raw: + return config + if config.theme_by_key(raw) is None: + logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw) + return config + data = config.model_dump(mode="json", exclude_none=True) + data["default_theme"] = raw + return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data)) + + +def resolved_webapp_themes_catalog( + *, + theme_dir: str | Path, + primary_accent: str, + env_default_theme: Optional[str], +) -> WebappThemesConfig: + """Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible.""" + ensure_default_webapp_theme_descriptor_files(theme_dir) + + themes = load_webapp_theme_dir(theme_dir) + config = _themes_config_from_list(None, themes) + config, changed = ensure_webapp_core_themes(config, primary_accent) + if changed: + try: + write_webapp_theme_dir(theme_dir, config, delete_missing=False) + except OSError as exc: + logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc) + return apply_webapp_theme_env_overrides(config, env_default_theme) + + +def merge_primary_accent_into_theme_tokens( + theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True +) -> ThemeTokens: + """Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent.""" + base = theme.tokens.model_copy(deep=True) + accent = (primary_accent or "").strip() + if not accent: + return base + if only_if_token_missing and base.accent: + return base + base.accent = accent + return base + + +def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: + tokens = ( + merge_primary_accent_into_theme_tokens(theme, primary_accent) + if theme.use_primary_accent + else theme.tokens + ) + payload: Dict[str, object] = { + "key": theme.key, + "names": dict(theme.names), + "enabled": bool(theme.enabled), + "use_primary_accent": bool(theme.use_primary_accent), + "use_in_admin": bool(theme.use_in_admin), + "tokens": tokens.model_dump(mode="json", exclude_none=True), + } + if theme.css_file: + payload["css_file"] = theme.css_file + return payload + + +def public_themes_catalog_payload( + config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False +) -> Dict[str, object]: + themes = [theme for theme in config.themes if not enabled_only or theme.enabled] + return { + "default_theme": config.default_theme, + "themes": [public_theme_payload(theme, primary_accent) for theme in themes], + } diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp new file mode 100644 index 0000000000000000000000000000000000000000..f864834875fea9fc84e1d9820c1996d591d3d603 GIT binary patch literal 122284 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%0DF;gKnwuzSx!&` z0K~xQ1ONi@CjbCCN4_`!^pppy(YXf6W1jCx6^}r?gie;pQLIB>kNmu%xa-Rf_0?-n z{5iiid4~clArz1U)gLn zKW;~Z!qQRRM|GKevF(loAU6kz^V~WENHUNS!iB=DuKzc0=3f_(k=+UPil1wup}`;`Sk1m$uc$1j^Z#=pd7T zL{g?UY-<*k^DEKdB?p@U^V^
s39e<~Y+%n>_8igFnxA8}nTo0pR7%Z=>)Maz@T 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 Zm&W5@U=52G;i8oSbYXndLhvn#n*hXo$T|Q3 literal 0 HcmV?d00001 diff --git a/locales/en.json b/locales/en.json index 1dc21ed..6812dc6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -768,6 +768,9 @@ "wa_activate": "Activate", "wa_settings_avatar_alt": "User avatar", "wa_settings_language": "Language", + "wa_settings_theme": "Appearance theme", + "wa_settings_theme_follow_default": "Server default", + "wa_settings_theme_update_failed": "Failed to update theme", "wa_settings_link_telegram": "Telegram linked", "wa_settings_not_linked": "Not linked", "wa_settings_link_email": "Email linked", @@ -852,6 +855,7 @@ "admin_nav_logs": "Logs", "admin_nav_system": "System", "admin_nav_tariffs": "Tariffs", + "admin_nav_themes": "Themes", "admin_nav_settings": "Settings", "admin_section_stats_title": "Dashboard", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "User events and admin actions", "admin_section_tariffs_title": "Tariffs", "admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits", + "admin_section_themes_title": "Web App themes", + "admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance", "admin_section_settings_title": "App Settings", "admin_section_settings_subtitle": "Overrides for .env, applied instantly", "admin_filter_all": "All", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " groups", "admin_id": "ID", "admin_status_default": "Default", + "admin_status_current": "Current", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "Disabled", "admin_tariffs_stat_disabled_hint": "Hidden from showcase", "admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.", + "admin_themes_catalog_title": "Web App themes", + "admin_themes_catalog_sub": "Select the current theme from a card; edit appearance through files in the theme folder", + "admin_themes_use_primary_accent": "Use primary accent color", + "admin_themes_use_in_admin": "Use in admin panel", + "admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.", + "admin_themes_saved": "Themes saved", + "admin_themes_save_failed": "Failed to save themes", "admin_no_description": "No description", "admin_tariff_model_traffic": "Traffic", "admin_tariff_model_periods": "Periods", diff --git a/locales/ru.json b/locales/ru.json index dc6dbdf..113ee5a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -768,6 +768,9 @@ "wa_activate": "Активировать", "wa_settings_avatar_alt": "Аватар пользователя", "wa_settings_language": "Выбор языка", + "wa_settings_theme": "Тема оформления", + "wa_settings_theme_follow_default": "По умолчанию (сервер)", + "wa_settings_theme_update_failed": "Не удалось обновить тему", "wa_settings_link_telegram": "Привязка Telegram", "wa_settings_not_linked": "Не привязан", "wa_settings_link_email": "Привязка почты", @@ -852,6 +855,7 @@ "admin_nav_logs": "Логи", "admin_nav_system": "Система", "admin_nav_tariffs": "Тарифы", + "admin_nav_themes": "Темы", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Дашборд", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", @@ -869,6 +873,8 @@ "admin_section_logs_subtitle": "События пользователей и админ-действия", "admin_section_tariffs_title": "Тарифы", "admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты", + "admin_section_themes_title": "Темы Web App", + "admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App", "admin_section_settings_title": "Настройки приложения", "admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно", "admin_filter_all": "Все", @@ -1047,6 +1053,7 @@ "admin_stats_queue_groups": " групп", "admin_id": "ID", "admin_status_default": "По умолчанию", + "admin_status_current": "Текущая", "admin_tariff_squads": "Squads", "admin_tariff_premium": "Premium", "admin_settings_badge_secret": "Secret", @@ -1221,6 +1228,13 @@ "admin_tariffs_stat_disabled": "Отключено", "admin_tariffs_stat_disabled_hint": "Скрыто с витрины", "admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.", + "admin_themes_catalog_title": "Темы Web App", + "admin_themes_catalog_sub": "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы", + "admin_themes_use_primary_accent": "Протягивать акцентный цвет", + "admin_themes_use_in_admin": "Использовать в админке", + "admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.", + "admin_themes_saved": "Темы сохранены", + "admin_themes_save_failed": "Не удалось сохранить темы", "admin_no_description": "Без описания", "admin_tariff_model_traffic": "Трафик", "admin_tariff_model_periods": "Периоды", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 4d3be96..5e32b62 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -222,6 +222,133 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(response.text, "console.log('minified');") + async def test_theme_css_asset_route_serves_file_from_configured_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom").mkdir() + (themes_dir / "custom" / "theme.css").write_text( + ".theme-key-custom { --bg: red; }", encoding="utf-8" + ) + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/theme.css"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual(response.content_type, "text/css") + self.assertEqual(response.headers["Cache-Control"], "no-cache") + self.assertIn("--bg: red", response.text) + + async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "light/style.css"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual(response.content_type, "text/css") + self.assertIn(".theme-key-light", response.text) + self.assertTrue((Path(tmpdir) / "light" / "theme.json").exists()) + self.assertTrue((Path(tmpdir) / "light" / "style.css").exists()) + + async def test_theme_css_asset_route_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "../secret.css"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_css_asset_route(request) + + async def test_theme_asset_route_serves_image_from_configured_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom" / "icons").mkdir(parents=True) + (themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes") + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/icons/save.png"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual(response.content_type, "image/png") + self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600") + self.assertEqual(response.body, b"png-bytes") + + async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "windows95/icons/save.png"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual(response.content_type, "image/png") + self.assertGreater(len(response.body), 0) + self.assertTrue((Path(tmpdir) / "windows95" / "theme.json").exists()) + self.assertTrue((Path(tmpdir) / "windows95" / "icons" / "save.png").exists()) + + async def test_theme_asset_route_rejects_path_traversal(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "../secret.png"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_asset_route(request) + + async def test_theme_asset_route_rejects_non_image_suffix(self): + with tempfile.TemporaryDirectory() as tmpdir: + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=tmpdir, + ) + }, + match_info={"path": "custom/icons/readme.txt"}, + ) + + with self.assertRaises(webapp_assets.web.HTTPNotFound): + await subscription_webapp.theme_asset_route(request) + def test_webapp_logo_disk_cache_roundtrip(self): with tempfile.TemporaryDirectory() as tmpdir: logo_url = "https://cdn.example.com/logo.png" diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 444b07e..0e3d597 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -59,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/webapp-logo"): "webapp_logo_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/subscription_webapp.css"): "css_asset_route", + ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", ("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route", ("GET", "/subscription_webapp.js"): "js_asset_route", ("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route", @@ -136,6 +137,8 @@ class WebAppRouteContractTests(unittest.TestCase): ("PATCH", "/api/admin/settings"): "admin_settings_patch_route", ("GET", "/api/admin/tariffs"): "admin_tariffs_get_route", ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", + ("GET", "/api/admin/themes"): "admin_themes_get_route", + ("PUT", "/api/admin/themes"): "admin_themes_save_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py new file mode 100644 index 0000000..65b83e1 --- /dev/null +++ b/tests/test_webapp_themes_config.py @@ -0,0 +1,371 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from config.webapp_themes_config import ( + WebappThemesConfig, + apply_webapp_theme_env_overrides, + builtin_webapp_themes_config, + default_webapp_theme_descriptors, + ensure_webapp_core_themes, + load_webapp_theme_dir, + public_themes_catalog_payload, + resolved_webapp_themes_catalog, + write_webapp_theme_dir, +) + + +class WebappThemesConfigTests(unittest.TestCase): + def test_builtin_has_core_themes(self): + cfg = builtin_webapp_themes_config("#abcdef") + self.assertEqual(cfg.default_theme, "dark") + keys = {theme.key for theme in cfg.themes} + self.assertEqual(keys, {"dark", "light", "windows95", "ascii"}) + dark = cfg.theme_by_key("dark") + self.assertIsNotNone(dark) + self.assertTrue(dark.default) + self.assertEqual(dark.tokens.accent, "#abcdef") + win95 = cfg.theme_by_key("windows95") + self.assertIsNotNone(win95) + self.assertEqual(cfg.theme_by_key("light").css_file, "style.css") + self.assertEqual(win95.css_file, "style.css") + self.assertEqual(win95.tokens.style_preset, "win95") + self.assertFalse(win95.use_primary_accent) + self.assertTrue(win95.use_in_admin) + self.assertEqual(win95.assets_version, 1) + ascii_theme = cfg.theme_by_key("ascii") + self.assertIsNotNone(ascii_theme) + self.assertEqual(ascii_theme.css_file, "style.css") + self.assertFalse(ascii_theme.use_primary_accent) + self.assertTrue(ascii_theme.use_in_admin) + self.assertEqual(ascii_theme.assets_version, 1) + + def test_env_override_default_theme(self): + cfg = builtin_webapp_themes_config("#00fe7a") + out = apply_webapp_theme_env_overrides(cfg, "light") + self.assertEqual(out.default_theme, "light") + self.assertTrue(out.theme_by_key("light").default) + self.assertFalse(out.theme_by_key("dark").default) + + def test_core_themes_are_merged_when_missing(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "use_primary_accent": False, + "tokens": {"color_scheme": "dark"}, + } + ], + ) + + merged, changed = ensure_webapp_core_themes(cfg, "#00fe7a") + + self.assertTrue(changed) + self.assertEqual( + {theme.key for theme in merged.themes}, + {"custom", "dark", "light", "windows95", "ascii"}, + ) + self.assertEqual(merged.default_theme, "custom") + self.assertTrue(merged.theme_by_key("custom").default) + self.assertEqual(merged.theme_by_key("light").css_file, "style.css") + self.assertTrue(merged.theme_by_key("custom").use_in_admin) + self.assertFalse(merged.theme_by_key("custom").use_primary_accent) + + def test_default_theme_descriptors_are_read_from_source_files(self): + descriptors = default_webapp_theme_descriptors() + + self.assertEqual(set(descriptors), {"dark", "light", "windows95", "ascii"}) + self.assertTrue(descriptors["dark"]["default"]) + self.assertEqual(descriptors["windows95"]["css_file"], "style.css") + self.assertEqual(descriptors["ascii"]["css_file"], "style.css") + + def test_resolved_creates_default_files_when_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#abc123", + env_default_theme=None, + ) + + self.assertTrue((themes_dir / "dark" / "theme.json").exists()) + self.assertTrue((themes_dir / "light" / "theme.json").exists()) + self.assertTrue((themes_dir / "light" / "style.css").exists()) + self.assertTrue((themes_dir / "windows95" / "theme.json").exists()) + self.assertTrue((themes_dir / "windows95" / "style.css").exists()) + self.assertTrue((themes_dir / "windows95" / "icons" / "save.png").exists()) + self.assertTrue((themes_dir / "ascii" / "theme.json").exists()) + self.assertTrue((themes_dir / "ascii" / "style.css").exists()) + self.assertEqual(cfg.default_theme, "dark") + self.assertIsNone(cfg.theme_by_key("dark").tokens.accent) + + def test_load_theme_dir_uses_filename_as_key_when_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "custom").mkdir() + (themes_dir / "custom" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Custom"}, + "enabled": True, + "css_file": "style.css", + "tokens": {"color_scheme": "light"}, + } + ), + encoding="utf-8", + ) + + themes = load_webapp_theme_dir(themes_dir) + + self.assertEqual(len(themes), 1) + self.assertEqual(themes[0].key, "custom") + self.assertEqual(themes[0].names["en"], "Custom") + + def test_resolved_catalog_includes_custom_mounted_theme_descriptor(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "css_file": "style.css", + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(cfg.default_theme, "neon") + self.assertIsNotNone(cfg.theme_by_key("neon")) + self.assertEqual( + {theme.key for theme in cfg.themes}, + {"dark", "light", "windows95", "ascii", "neon"}, + ) + + def test_env_default_overrides_descriptor_default(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme="windows95", + ) + + self.assertEqual(cfg.default_theme, "windows95") + self.assertTrue(cfg.theme_by_key("windows95").default) + self.assertFalse(cfg.theme_by_key("neon").default) + + def test_custom_descriptor_default_wins_over_seeded_core_default(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + themes_dir.mkdir() + (themes_dir / "dark").mkdir() + (themes_dir / "dark" / "theme.json").write_text( + json.dumps( + { + "key": "dark", + "names": {"en": "Dark"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + (themes_dir / "neon").mkdir() + (themes_dir / "neon" / "theme.json").write_text( + json.dumps( + { + "names": {"en": "Neon"}, + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark"}, + } + ), + encoding="utf-8", + ) + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(cfg.default_theme, "neon") + self.assertTrue(cfg.theme_by_key("neon").default) + self.assertFalse(cfg.theme_by_key("dark").default) + + def test_theme_dir_writer_writes_descriptors_with_single_default(self): + with tempfile.TemporaryDirectory() as tmp: + cfg = builtin_webapp_themes_config("#00fe7a") + cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) + themes_dir = Path(tmp) / "themes" + + write_webapp_theme_dir(themes_dir, cfg) + + dark = json.loads((themes_dir / "dark" / "theme.json").read_text(encoding="utf-8")) + win95 = json.loads( + (themes_dir / "windows95" / "theme.json").read_text(encoding="utf-8") + ) + self.assertFalse(dark["default"]) + self.assertTrue(win95["default"]) + + resolved = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + self.assertEqual(resolved.default_theme, "windows95") + + def test_resolved_falls_back_to_dark_when_saved_theme_is_missing(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + cfg = builtin_webapp_themes_config("#00fe7a") + cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) + write_webapp_theme_dir(themes_dir, cfg) + windows_theme = themes_dir / "windows95" / "theme.json" + windows_theme.unlink() + + resolved = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + self.assertEqual(resolved.default_theme, "dark") + self.assertTrue(resolved.theme_by_key("dark").default) + + def test_public_payload_injects_primary_accent_when_enabled(self): + cfg = builtin_webapp_themes_config("#abc123") + + payload = public_themes_catalog_payload(cfg, "#abc123") + light = next(theme for theme in payload["themes"] if theme["key"] == "light") + dark = next(theme for theme in payload["themes"] if theme["key"] == "dark") + win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95") + + self.assertEqual(light["css_file"], "style.css") + self.assertEqual(light["tokens"]["accent"], "#abc123") + self.assertEqual(dark["tokens"]["accent"], "#abc123") + self.assertFalse(win95["use_primary_accent"]) + self.assertTrue(win95["use_in_admin"]) + self.assertNotIn("accent", win95["tokens"]) + + def test_public_payload_keeps_admin_usage_flag(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "names": {"en": "Custom"}, + "enabled": True, + "default": True, + "use_in_admin": False, + "tokens": {"color_scheme": "dark"}, + } + ], + ) + + payload = public_themes_catalog_payload(cfg, "#abc123") + + self.assertFalse(payload["themes"][0]["use_in_admin"]) + + def test_resolved_refreshes_stale_builtin_windows95_assets(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + stale_theme_dir = themes_dir / "windows95" + stale_theme_dir.mkdir(parents=True) + (stale_theme_dir / "theme.json").write_text( + json.dumps( + { + "key": "windows95", + "names": {"en": "Windows 95"}, + "enabled": True, + "default": False, + "use_primary_accent": True, + "css_file": "style.css", + "assets_version": 1, + "tokens": {"color_scheme": "light", "style_preset": "win95"}, + } + ), + encoding="utf-8", + ) + (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") + + cfg = resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8")) + css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") + self.assertEqual( + descriptor["assets_version"], + cfg.theme_by_key("windows95").assets_version, + ) + self.assertEqual(descriptor["assets_version"], 1) + self.assertIn("lucide-house", css) + self.assertIn("lucide-earth", css) + self.assertIn("lucide-circle-check", css) + self.assertIn("lucide-circle-check-big", css) + self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists()) + + def test_resolved_refreshes_stale_builtin_light_assets(self): + with tempfile.TemporaryDirectory() as tmp: + themes_dir = Path(tmp) / "themes" + stale_theme_dir = themes_dir / "light" + stale_theme_dir.mkdir(parents=True) + (stale_theme_dir / "theme.json").write_text( + json.dumps( + { + "key": "light", + "names": {"en": "Light"}, + "enabled": True, + "default": False, + "use_primary_accent": True, + "css_file": "style.css", + "assets_version": 1, + "tokens": {"color_scheme": "light"}, + } + ), + encoding="utf-8", + ) + (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") + + resolved_webapp_themes_catalog( + theme_dir=themes_dir, + primary_accent="#00fe7a", + env_default_theme=None, + ) + + css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") + self.assertIn(".theme-key-light.app-shell", css) From af7cee5014a17cd6a5dcce539e51163d2defee3d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 11:17:27 +0300 Subject: [PATCH 02/45] feat: custom themes polishing --- .env.example | 3 - bot/app/web/admin_api_impl/routes.py | 1 + bot/app/web/admin_api_impl/settings.py | 7 + bot/app/web/admin_api_impl/themes.py | 169 ++++ bot/app/web/admin_settings_manifest.py | 1 + bot/app/web/frontend/src/App.svelte | 30 +- .../web/frontend/src/admin/AdminPanel.svelte | 14 +- .../admin/sections/AppearanceSection.svelte | 743 ++++++++++++++++++ .../src/admin/sections/SettingsSection.svelte | 16 +- .../src/admin/sections/ThemesSection.svelte | 267 ------- .../src/lib/admin/stores/settingsStore.js | 22 +- .../src/lib/admin/stores/themesStore.js | 84 ++ .../frontend/src/lib/webapp/BrandMark.svelte | 5 +- .../web/frontend/src/lib/webapp/constants.js | 2 +- .../web/frontend/src/lib/webapp/mockApi.js | 68 +- .../frontend/src/lib/webapp/previewMock.js | 1 + .../web/frontend/src/lib/webapp/publicApi.js | 6 +- bot/app/web/webapp/_runtime.py | 5 +- bot/app/web/webapp/assets.py | 155 +++- bot/app/web/webapp/routes.py | 12 +- bot/services/email_templates.py | 8 +- config/settings.py | 28 + config/webapp_themes_config.py | 29 +- docs/remnawave-minishop.webp | Bin 122284 -> 65602 bytes tests/test_settings.py | 19 + tests/test_webapp_assets.py | 34 +- tests/test_webapp_route_contract.py | 22 + tests/test_webapp_themes_config.py | 29 + 28 files changed, 1474 insertions(+), 306 deletions(-) create mode 100644 bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte delete mode 100644 bot/app/web/frontend/src/admin/sections/ThemesSection.svelte diff --git a/.env.example b/.env.example index 7f71da8..16c6036 100644 --- a/.env.example +++ b/.env.example @@ -38,11 +38,8 @@ WEBAPP_ENABLED=True # WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_TITLE="/minishop" # Mini App title -WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: /theme.json and optional CSS/assets WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light) -WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used -WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h) diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index 4f1e93a..de1ed9f 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -56,4 +56,5 @@ def setup_admin_routes(app: web.Application) -> None: router.add_put("/api/admin/tariffs", admin_tariffs_save_route) router.add_get("/api/admin/themes", admin_themes_get_route) router.add_put("/api/admin/themes", admin_themes_save_route) + router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/bot/app/web/admin_api_impl/settings.py index 787778f..3746394 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/bot/app/web/admin_api_impl/settings.py @@ -70,5 +70,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: if isinstance(cache, dict): cache["ts"] = 0.0 cache["data"] = {} + if ( + "WEBAPP_LOGO_URL" in updates + or "WEBAPP_LOGO_URL" in deletes + or "WEBAPP_LOGO_USE_EMOJI" in updates + or "WEBAPP_LOGO_USE_EMOJI" in deletes + ): + request.app["webapp_logo_cache"] = None return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py index 983618d..f1b4d79 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/bot/app/web/admin_api_impl/themes.py @@ -1,6 +1,14 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +import asyncio +import hashlib +import ipaddress +import re +import socket + +from aiohttp import ClientSession, ClientTimeout + from config.webapp_themes_config import ( WebappThemesConfig, ensure_webapp_core_themes, @@ -9,6 +17,167 @@ from config.webapp_themes_config import ( ) +WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + + +def _detect_logo_extension( + body: bytes, content_type: str = "", filename: str = "" +) -> Optional[str]: + content_type = (content_type or "").split(";", 1)[0].strip().lower() + suffix = Path(filename or "").suffix.lower() + if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"): + return ".png" + if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"): + return ".jpg" + if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")): + return ".gif" + if content_type == "image/webp" or ( + len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP" + ): + return ".webp" + if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg": + head = body[:512].lstrip().lower() + if head.startswith(b" str: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be a non-empty image up to 2 MiB") + ext = _detect_logo_extension(body, content_type, filename) + if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES: + raise ValueError("unsupported image type") + digest = hashlib.sha256(body).hexdigest()[:16] + safe_name = f"logo-{digest}{ext}" + WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True) + (WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body) + return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" + + +async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: + reader = await request.multipart() + async for part in reader: + if part.name != "file": + continue + body = bytearray() + while True: + chunk = await part.read_chunk(size=64 * 1024) + if not chunk: + break + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), part.headers.get("Content-Type", ""), part.filename or "" + raise ValueError("file field is required") + + +async def _hostname_resolves_to_public_address(hostname: str) -> bool: + if not hostname: + return False + try: + ip_obj = ipaddress.ip_address(hostname) + return not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ) + except ValueError: + pass + + loop = asyncio.get_running_loop() + try: + resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except Exception: + return False + + found_public_ip = False + for entry in resolved: + sockaddr = entry[4] + candidate = sockaddr[0] if sockaddr else "" + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ): + return False + found_public_ip = True + return found_public_ip + + +async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("only https image URLs are supported") + if not await _hostname_resolves_to_public_address(parsed.hostname): + raise ValueError("logo URL must resolve to a public address") + + timeout = ClientTimeout(total=5) + async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session: + async with session.get( + url, + allow_redirects=False, + headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, + ) as response: + if response.status != 200: + raise ValueError(f"logo URL returned HTTP {response.status}") + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + if content_type and not content_type.startswith("image/"): + raise ValueError("logo URL returned non-image content") + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("logo must be up to 2 MiB") + return bytes(body), content_type, Path(parsed.path).name + + +async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + logo_url = _write_uploaded_logo(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_logo", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp logo") + return _error(500, "write_failed", str(exc)) + + return _ok({"logo_url": logo_url}) + + async def admin_themes_get_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 1b4b578..031fbc5 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -79,6 +79,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ SettingField( "WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a" ), + SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"), SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), SettingField( diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index ec51a5c..2467319 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -87,6 +87,7 @@ ...(MOCK ? MOCK.config : {}), ...(injectedConfig || {}), }; + const themePreviewKey = String(CFG.themePreviewKey || query.get("theme_preview") || "").trim(); const I18N = injectedI18n || {}; let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; @@ -246,7 +247,7 @@ $: brandEmojiFont = CFG.logoEmojiFont || "system"; $: brand = normalizeBrand({ title: brandTitle, - logoUrl: CFG.logoUrl, + logoUrl: CFG.logoUseEmoji ? "" : CFG.logoUrl, emoji: brandEmoji, emojiFont: brandEmojiFont, }); @@ -308,7 +309,11 @@ $: user = data?.user || {}; $: themesCatalog = data?.themes_catalog || CFG.themesCatalog || { default_theme: "dark", themes: [] }; - $: resolvedThemeKey = resolveEffectiveThemeKey(themesCatalog); + $: previewThemeAllowed = Boolean(themePreviewKey && (!data?.user || user?.is_admin)); + $: previewThemeEntry = previewThemeAllowed + ? findThemeEntry(themesCatalog, themePreviewKey) + : null; + $: resolvedThemeKey = previewThemeEntry?.key || resolveEffectiveThemeKey(themesCatalog); $: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey); $: darkThemeEntry = findThemeEntry(themesCatalog, "dark"); $: effectiveThemeEntry = @@ -870,13 +875,32 @@ ); } - async function handleAdminPersistedSaved() { + function adminPayloadHasLogoChange(options = {}) { + const keys = new Set([ + ...Object.keys(options.updates || {}), + ...(Array.isArray(options.deletes) ? options.deletes : []), + ]); + return [ + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + ].some((key) => keys.has(key)); + } + + async function handleAdminPersistedSaved(options = {}) { invalidateWebappTariffOptionCaches(billingStore); try { await loadData(); } catch { // Admin save already succeeded; a later full refresh will pick up new settings or catalog. } + const shouldReloadFrontend = + options?.reloadFrontend === true || + (!options?.deferFrontendReload && adminPayloadHasLogoChange(options)); + if (shouldReloadFrontend && typeof window !== "undefined") { + window.location.reload(); + } } function selectTariff(tariff) { diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 91b7b46..6bc0291 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -35,7 +35,7 @@ import StatsSection from "./sections/StatsSection.svelte"; import TariffEditorModal from "./sections/TariffEditorModal.svelte"; import TariffsSection from "./sections/TariffsSection.svelte"; - import ThemesSection from "./sections/ThemesSection.svelte"; + import AppearanceSection from "./sections/AppearanceSection.svelte"; import UserDetailModal from "./sections/UserDetailModal.svelte"; import UsersSection from "./sections/UsersSection.svelte"; import { createAdsStore } from "../lib/admin/stores/adsStore.js"; @@ -115,7 +115,7 @@ label: at("nav_system", {}, "Система"), items: [ { id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins }, - { id: "themes", label: at("nav_themes", {}, "Темы"), icon: Paintbrush }, + { id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush }, { id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders }, ], }, @@ -158,9 +158,9 @@ title: at("section_tariffs_title", {}, "Тарифы"), subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"), }, - themes: { - title: at("section_themes_title", {}, "Темы Web App"), - subtitle: at("section_themes_subtitle", {}, "Цвета, шрифты и темы оформления Mini App"), + appearance: { + title: at("section_appearance_title", {}, "Внешний вид"), + subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"), }, settings: { title: at("section_settings_title", {}, "Настройки приложения"), @@ -646,8 +646,8 @@ {/if} - {#if active === "themes"} - + {#if active === "appearance"} + {/if} {#if active === "settings"} diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte new file mode 100644 index 0000000..758592e --- /dev/null +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -0,0 +1,743 @@ + + +{#if themesLoading || settingsLoading} + {at("loading", {}, "Загрузка…")} +{:else} +
+
+
+
+

{at("appearance_brand_title", {}, "Логотип")}

+ {at( + "appearance_brand_sub", + {}, + "Файл, ссылка или подтвержденный emoji-логотип" + )} +
+
+ {#if dirtyCount} + + {at("settings_dirty_count", { count: dirtyCount }, `Изменений: ${dirtyCount}`)} + + {/if} + + + {settingsSaving || themesSaving + ? at("btn_saving", {}, "Сохранение...") + : at("btn_save", {}, "Сохранить")} + +
+
+
+
+ {#if !useEmojiLogo && previewLogoUrl && !logoPreviewFailed} + { + logoPreviewFailed = true; + }} + /> + {:else if useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + logoFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_logo_upload_file", {}, "Загрузить файл")} + +
+ + + {at("appearance_logo_upload_url", {}, "По ссылке")} + +
+
+ +
+ +
+ + setAppearanceValue("WEBAPP_LOGO_EMOJI", event.currentTarget.value)} + /> + setAppearanceValue("WEBAPP_LOGO_EMOJI_FONT", value)} + /> +
+
+
+
+
+ +
+
+
+

{at("appearance_themes_title", {}, "Темы")}

+ {at( + "appearance_themes_sub", + {}, + "Глобальная тема, accent color и предпросмотр" + )} +
+
+ + + {at("btn_refresh", {}, "Обновить")} + + + + {at("btn_save", {}, "Сохранить")} + +
+
+
+ {#if !themesCatalog.themes.length} + + {at( + "themes_catalog_empty", + {}, + "Каталог пуст. Добавьте папку темы в data/themes и обновите список." + )} + + {:else} +
+ {#each themesCatalog.themes as theme (theme.key)} + {@const isCurrent = theme.key === activeKey} +
selectTheme(theme, event)} + onkeydown={(event) => handleThemeKeydown(event, theme)} + > + + + {themeTitle(theme)} + {#if isCurrent} + {at("status_current", {}, "Текущая")} + {/if} + + {theme.key} + + + + {themeDescription(theme)} + + + +
+ previewTheme(event, theme)} + > + + {at("appearance_preview_theme", {}, "Предпросмотр")} + +
+ +
+ {/each} +
+ {/if} +
+
+
+{/if} + + diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte index 893157b..a26d1a5 100644 --- a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte @@ -17,28 +17,32 @@ const settingsStore = getContext("settingsStore"); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); + $: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance"); let settingsOpenSections = []; let settingsOpenSubsections = {}; let revealedSecrets = new Set(); $: settingsAllOpen = - settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length; + visibleSettingsSections.length > 0 && + settingsOpenSections.length === visibleSettingsSections.length; onMount(() => { settingsStore.loadSettings().then(() => { if ($settingsStore.settingsSections.length) { - const ids = $settingsStore.settingsSections.map((s) => s.id); + const ids = $settingsStore.settingsSections + .filter((s) => s.id !== "appearance") + .map((s) => s.id); settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice(); } }); }); function toggleAllSections() { - if (settingsOpenSections.length === settingsSections.length) { + if (settingsOpenSections.length === visibleSettingsSections.length) { settingsOpenSections = []; } else { - settingsOpenSections = settingsSections.map((s) => s.id); + settingsOpenSections = visibleSettingsSections.map((s) => s.id); } } @@ -229,7 +233,7 @@
{/snippet} -{#if settingsLoading || !settingsSections.length} +{#if settingsLoading || !visibleSettingsSections.length} {settingsLoading ? at("loading", {}, "Загрузка…") @@ -265,7 +269,7 @@
- {#each settingsSections as section} + {#each visibleSettingsSections as section} {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} diff --git a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte b/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte deleted file mode 100644 index e10a2a8..0000000 --- a/bot/app/web/frontend/src/admin/sections/ThemesSection.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - -{#if themesLoading} - {at("loading", {}, "Загрузка…")} -{:else} -
-
-
-

{at("themes_catalog_title", {}, "Темы Web App")}

- {at( - "themes_catalog_sub", - {}, - "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы" - )} -
-
- - - {at("btn_refresh", {}, "Обновить")} - - - - {at("btn_save", {}, "Сохранить")} - -
-
-
- {#if !themesCatalog.themes.length} - - {at( - "themes_catalog_empty", - {}, - "Каталог пуст. Добавьте папку темы в data/themes и обновите список." - )} - - {:else} -
- {#each themesCatalog.themes as theme (theme.key)} - {@const isCurrent = theme.key === activeKey} -
selectTheme(theme, event)} - onkeydown={(event) => handleCardKeydown(event, theme)} - > - - - {themeTitle(theme)} - {#if isCurrent} - {at("status_current", {}, "Текущая")} - {/if} - - {theme.key} - - - - {themeDescription(theme)} - - - - -
- {/each} -
- {/if} -
-
-{/if} - - diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js index 448d558..e74c3e1 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js @@ -38,13 +38,30 @@ export function createSettingsStore({ api, onToast, at }) { }); } + function setFieldValue(key, value) { + state.update((s) => { + const nextDirty = { ...s.settingsDirty }; + delete nextDirty[key]; + return { + ...s, + settingsDirty: nextDirty, + settingsSections: (s.settingsSections || []).map((section) => ({ + ...section, + fields: (section.fields || []).map((field) => + field.key === key ? { ...field, value, overridden: true } : field + ), + })), + }; + }); + } + async function saveSettings(onSettingsSaved) { let dirty = {}; state.update((s) => { dirty = s.settingsDirty; return s; }); - if (!Object.keys(dirty).length) return; + if (!Object.keys(dirty).length) return true; state.update((s) => ({ ...s, settingsSaving: true })); try { @@ -63,6 +80,7 @@ export function createSettingsStore({ api, onToast, at }) { state.update((s) => ({ ...s, settingsDirty: {} })); if (onSettingsSaved) await onSettingsSaved({ updates, deletes }); await loadSettings(); + return true; } else if (res?.errors) { const summary = Object.entries(res.errors) .map(([k, v]) => `${k}: ${v}`) @@ -71,6 +89,7 @@ export function createSettingsStore({ api, onToast, at }) { } else { onToast(res?.error || "Ошибка"); } + return false; } finally { state.update((s) => ({ ...s, settingsSaving: false })); } @@ -91,6 +110,7 @@ export function createSettingsStore({ api, onToast, at }) { loadSettings, markDirty, clearDirty, + setFieldValue, resetField, saveSettings, }; diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js index f0f63d8..f1db26e 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -58,6 +58,67 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { } } + async function uploadLogoFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/logo", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Логотип загружен. Сохраните изменения, чтобы применить его." + ) + ); + return data.logo_url || ""; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadLogoUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/logo", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_logo_uploaded_pending", + {}, + "Логотип загружен. Сохраните изменения, чтобы применить его." + ) + ); + return data.logo_url || ""; + } + flash( + data?.message || + data?.error || + at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + async function setCurrentTheme(key) { let changed = false; state.update((s) => ({ @@ -102,12 +163,35 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { })); } + function setThemeAccent(key, accent) { + state.update((s) => ({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + accent: String(accent || "").trim() || null, + }, + } + : theme + ), + }, + })); + } + return { subscribe: state.subscribe, loadThemes, saveThemes, setCurrentTheme, + setThemeAccent, togglePrimaryAccent, toggleAdminUse, + uploadLogoFile, + uploadLogoUrl, }; } diff --git a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte index 1cdec74..cabedee 100644 --- a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte +++ b/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte @@ -33,6 +33,7 @@ export let emojiFont = ""; export let size = "sm"; export let animate = false; + export let fallbackEmoji = true; let className = ""; export { className as class }; @@ -206,7 +207,7 @@ clearLogoLoadTimeout(); }} /> - {:else if useAnimatedEmoji} + {:else if fallbackEmoji && useAnimatedEmoji} - {:else} + {:else if fallbackEmoji} ({}), } = {}) { + const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData; + async function api(path, options = {}) { if (mockApi) return mockApi(path, options, getMockContext()); @@ -18,7 +20,9 @@ export function createApiClient({ if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { headers["X-CSRF-Token"] = csrf; } - if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) { + headers["Content-Type"] = "application/json"; + } const response = await fetch(`${apiBase}${path}`, { ...options, diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index 5e10534..8c94f0b 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -2,6 +2,7 @@ import asyncio import base64 import hashlib +import html import hmac import io import ipaddress @@ -17,7 +18,7 @@ from collections import deque from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit from aiogram import Bot, Dispatcher from aiogram.types import LabeledPrice @@ -65,6 +66,8 @@ TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscriptio ASSET_DIR = TEMPLATE_PATH.parent WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" +WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index b27caa9..198ba08 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -5,6 +5,7 @@ from config.webapp_themes_config import ( default_webapp_theme_asset_file, default_webapp_theme_css_files, ensure_default_webapp_theme_descriptor_files, + public_theme_payload, public_themes_catalog_payload, ) @@ -123,6 +124,9 @@ async def theme_asset_route(request: web.Request) -> web.Response: def _resolve_webapp_logo_url(settings: Settings) -> str: + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return "" + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: return "" @@ -173,6 +177,8 @@ def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: async def webapp_logo_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + raise web.HTTPNotFound(text="webapp_logo_disabled") raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") @@ -206,6 +212,41 @@ async def webapp_logo_route(request: web.Request) -> web.Response: return response +async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + raise web.HTTPNotFound(text="webapp_logo_not_found") + + root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() + path = (root / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_logo_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + async def webapp_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() @@ -235,6 +276,8 @@ async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def _warm_webapp_logo_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return 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 @@ -258,6 +301,8 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None: async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: settings: Settings = app["settings"] + if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": return @@ -547,7 +592,7 @@ async def _security_headers_middleware(request: web.Request, handler): "frame-ancestors https://web.telegram.org https://t.me; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " + "img-src 'self' data: blob: https:; " "connect-src 'self' https://oauth.telegram.org; " "object-src 'none'; " "base-uri 'self'; " @@ -741,16 +786,24 @@ async def index_route(request: web.Request) -> web.Response: html = TEMPLATE_PATH.read_text(encoding="utf-8") cached = _get_cached_webapp_settings(request) themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + initial_theme = _initial_theme_for_request(request, themes_catalog) + preview_key = str(request.query.get("theme_preview") or "").strip() + preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None + if preview_theme is None or not preview_theme.enabled: + preview_key = "" config = { "title": settings.WEBAPP_TITLE, "primaryColor": settings.WEBAPP_PRIMARY_COLOR, "themesCatalog": public_themes_catalog_payload( themes_catalog, - settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + primary_color, enabled_only=True, ), "themesDir": settings.WEBAPP_THEMES_DIR, + "themePreviewKey": preview_key, "logoUrl": cached["logo_url"], + "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, "apiBase": "/api", @@ -769,6 +822,9 @@ async def index_route(request: web.Request) -> web.Response: "appRepositoryUrl": APP_REPOSITORY_URL, } html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) + initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) + if initial_theme_markup: + html = html.replace("", f"{initial_theme_markup}\n", 1) i18n_instance: Optional[object] = request.app.get("i18n") i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} nonce = request.get("csp_nonce", "") @@ -793,7 +849,11 @@ async def index_route(request: web.Request) -> web.Response: f'', ) brand_asset_url = cached["logo_url"] - if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": + if ( + not brand_asset_url + and settings.WEBAPP_LOGO_USE_EMOJI + 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( @@ -843,6 +903,95 @@ def _resolve_webapp_js_asset_name() -> str: return "subscription_webapp.js" +_INITIAL_THEME_TOKEN_CSS_MAP = { + "accent": "--accent", + "bg": "--bg", + "panel": "--panel", + "panel_2": "--panel-2", + "panel_3": "--panel-3", + "border": "--border", + "border_strong": "--border-strong", + "text": "--text", + "muted": "--muted", + "dim": "--dim", + "danger": "--danger", + "blue": "--blue", + "radius": "--radius", + "font_sans": "--font-sans", + "font_logo": "--font-logo", + "font_mono": "--font-mono", + "admin_bg": "--admin-bg", + "admin_surface": "--admin-surface", + "admin_surface_2": "--admin-surface-2", + "admin_elev": "--admin-elev", + "admin_border": "--admin-border", + "admin_border_strong": "--admin-border-strong", + "admin_text": "--admin-text", + "admin_muted": "--admin-muted", + "admin_dim": "--admin-dim", +} + + +def _theme_css_href_for_html(theme: Any) -> str: + css_file = str(getattr(theme, "css_file", "") or "").strip() + key = str(getattr(theme, "key", "") or "").strip() + if not css_file or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key): + return "" + parts = [part for part in css_file.replace("\\", "/").split("/") if part] + if any(part in {".", ".."} for part in parts): + return "" + themed_path = "/".join([key, *parts]) + encoded = "/".join(quote(part, safe="") for part in themed_path.split("/")) + return f"/webapp-theme-css/{encoded}" if encoded else "" + + +def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: + preview_key = str(request.query.get("theme_preview") or "").strip() + if preview_key: + preview_theme = catalog.theme_by_key(preview_key) + if preview_theme is not None and preview_theme.enabled: + return preview_theme + + theme = catalog.theme_by_key(catalog.default_theme) + if theme is not None: + return theme + return catalog.enabled_themes()[0] if catalog.enabled_themes() else None + + +def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str: + if theme is None: + return "" + + payload = public_theme_payload(theme, primary_color) + tokens = payload.get("tokens") if isinstance(payload, dict) else {} + tokens = tokens if isinstance(tokens, dict) else {} + declarations = [] + for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): + value = str(tokens.get(token_key) or "").strip() + if value: + declarations.append(f"{css_name}:{value}") + + scheme = "light" if tokens.get("color_scheme") == "light" else "dark" + bg = str(tokens.get("bg") or "").strip() + css_rules = [f"html{{color-scheme:{scheme};}}"] + if bg: + css_rules.append(f"body{{background-color:{bg};}}") + if declarations: + css_rules.append(f".app-shell{{{';'.join(declarations)}}}") + + nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) + style_tag = ( + f'" + ) + href = _theme_css_href_for_html(theme) + if not href: + return style_tag + return ( + f'\n' + style_tag + ) + + def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index d6a5862..478a3b5 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -9,12 +9,22 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) app.router.add_get("/admin", index_route) - app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get( + ( + "/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|" + "appearance|settings}" + ), + index_route, + ) app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) 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( + rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_uploaded_logo_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index bca9a36..a17ac85 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -1,7 +1,7 @@ """Branded HTML email templates that mirror the subscription Mini App look. The web app uses a dark theme with a configurable accent colour -(`WEBAPP_PRIMARY_COLOR`) and an optional logo (`WEBAPP_LOGO_URL`). The same +admin-configured accent colour and logo. The same accent + logo are reused here so emails feel like part of the product. All copy goes through the shared `JsonI18n` instance so translations live in ``locales/.json`` next to the rest of the bot strings. @@ -45,8 +45,10 @@ def _safe_color(value: Optional[str]) -> str: def _public_logo_url(settings: Settings) -> Optional[str]: - """Email recipients can't reach the in-app /webapp-logo proxy, so the - raw https URL from the env is used directly. Anything else is dropped.""" + """Email recipients can't reach the in-app /webapp-logo proxy, so only a + stored public https URL can be used directly. Anything else is dropped.""" + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + return None raw = (settings.WEBAPP_LOGO_URL or "").strip() if not raw: return None diff --git a/config/settings.py b/config/settings.py index be1078a..13f6b24 100644 --- a/config/settings.py +++ b/config/settings.py @@ -92,6 +92,7 @@ class WebAppSettings(BaseModel): title: str primary_color: str logo_url: Optional[str] + logo_use_emoji: bool logo_emoji: str logo_emoji_font: str session_ttl_seconds: int @@ -369,6 +370,7 @@ class Settings(BaseSettings): ), ) WEBAPP_LOGO_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False) WEBAPP_LOGO_EMOJI: str = Field(default="🫥") WEBAPP_LOGO_EMOJI_FONT: str = Field( default="system", @@ -545,6 +547,7 @@ class Settings(BaseSettings): title=self.WEBAPP_TITLE, primary_color=self.WEBAPP_PRIMARY_COLOR, logo_url=self.WEBAPP_LOGO_URL, + logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, @@ -836,6 +839,31 @@ class Settings(BaseSettings): theme_dir=self.WEBAPP_THEMES_DIR, ) + @field_validator("WEBAPP_PRIMARY_COLOR", mode="before") + @classmethod + def ignore_deprecated_webapp_primary_color_env(cls, _value): + return "#00fe7a" + + @field_validator("WEBAPP_LOGO_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value): + return False + + @field_validator("WEBAPP_LOGO_EMOJI", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_env(cls, _value): + return "🫥" + + @field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): + return "system" + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py index e301498..4f713bc 100644 --- a/config/webapp_themes_config.py +++ b/config/webapp_themes_config.py @@ -8,7 +8,7 @@ import re from pathlib import Path from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator logger = logging.getLogger(__name__) @@ -48,6 +48,22 @@ class ThemeTokens(BaseModel): admin_muted: Optional[str] = None admin_dim: Optional[str] = None + @field_validator("accent") + @classmethod + def _normalize_accent_hex(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", raw) + if not match: + raise ValueError("accent must be a hex color (#RGB or #RRGGBB)") + hex_value = match.group(1).lower() + if len(hex_value) == 3: + hex_value = "".join(char * 2 for char in hex_value) + return f"#{hex_value}" + class WebappTheme(BaseModel): """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" @@ -256,10 +272,13 @@ def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]: def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]: - return [theme for _, theme in sorted( - ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), - key=lambda pair: pair[0], - )] + return [ + theme + for _, theme in sorted( + ((_theme_sort_key(t, i), t) for i, t in enumerate(themes)), + key=lambda pair: pair[0], + ) + ] def _themes_config_from_list( diff --git a/docs/remnawave-minishop.webp b/docs/remnawave-minishop.webp index f864834875fea9fc84e1d9820c1996d591d3d603..0c107996a85049043229d3cb30b39a43c9d42377 100644 GIT binary patch literal 65602 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
h*xgP++Eph$)TjHLqz(>drA=xsZ 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 a$yGk71ULbRKreH#_y7-9fIUp=0000g$-OZE literal 122284 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%0DF;gKnwuzSx!&` z0K~xQ1ONi@CjbCCN4_`!^pppy(YXf6W1jCx6^}r?gie;pQLIB>kNmu%xa-Rf_0?-n z{5iiid4~clArz1U)gLn zKW;~Z!qQRRM|GKevF(loAU6kz^V~WENHUNS!iB=DuKzc0=3f_(k=+UPil1wup}`;`Sk1m$uc$1j^Z#=pd7T zL{g?UY-<*k^DEKdB?p@U^V^
s39e<~Y+%n>_8igFnxA8}nTo0pR7%Z=>)Maz@T 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
ndgAH`z+b+kgK7{tWFQ_KhG+@y)yvd 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 Zm&W5@U=52G;i8oSbYXndLhvn#n*hXo$T|Q3 diff --git a/tests/test_settings.py b/tests/test_settings.py index 9dbf44b..795e7ae 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -28,6 +28,25 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) + def test_deprecated_webapp_appearance_env_values_are_ignored(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + WEBAPP_PRIMARY_COLOR="#ff0000", + WEBAPP_LOGO_URL="https://cdn.example.com/logo.png", + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_EMOJI="🔥", + WEBAPP_LOGO_EMOJI_FONT="twemoji", + ) + + self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a") + self.assertIsNone(settings.WEBAPP_LOGO_URL) + self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI) + self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥") + self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system") + def test_tariffs_config_missing_uses_legacy_fallback(self): settings = Settings( _env_file=None, diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 5e32b62..2eb0ed7 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -11,6 +11,7 @@ from unittest.mock import patch from bot.app.web import subscription_webapp from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings +from config.webapp_themes_config import builtin_webapp_themes_config class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): @@ -91,6 +92,36 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): r"^/webapp-logo\?v=[0-9a-f]{12}$", ) + def test_uploaded_webapp_logo_url_is_served_directly(self): + settings = SimpleNamespace( + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png" + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_logo_url(settings), + "/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self): + settings = SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-abcdef1234567890.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "") + + def test_initial_theme_head_markup_includes_css_and_tokens(self): + cfg = builtin_webapp_themes_config("#123456") + theme = cfg.theme_by_key("light") + request = SimpleNamespace(get=lambda key, default="": "nonce-value") + + markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456") + + self.assertIn("/webapp-theme-css/light/style.css", markup) + self.assertIn('nonce="nonce-value"', markup) + self.assertIn("--accent:#123456", markup) + self.assertIn("color-scheme:light", markup) + def test_animated_emoji_asset_path_uses_same_origin_route(self): self.assertEqual( subscription_webapp._webapp_animated_emoji_asset_path("🤩"), @@ -364,7 +395,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): logo_url = "https://cdn.example.com/logo.png" logo = (b"cached-logo", "image/png") app = { - "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url), + "settings": SimpleNamespace(WEBAPP_LOGO_URL=logo_url, WEBAPP_LOGO_USE_EMOJI=False), "webapp_logo_cache": None, "webapp_logo_cache_lock": asyncio.Lock(), } @@ -403,6 +434,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): with tempfile.TemporaryDirectory() as tmpdir: app = { "settings": SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=True, WEBAPP_LOGO_EMOJI="🤩", WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", ), diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 0e3d597..8d3f85d 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -1,8 +1,10 @@ +import asyncio import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from aiohttp import web +from aiohttp.test_utils import make_mocked_request from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import auth as admin_auth_routes @@ -57,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route", ("GET", "/health"): "health_route", ("GET", "/webapp-logo"): "webapp_logo_route", + ("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/subscription_webapp.css"): "css_asset_route", ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", @@ -139,12 +142,31 @@ class WebAppRouteContractTests(unittest.TestCase): ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", ("GET", "/api/admin/themes"): "admin_themes_get_route", ("PUT", "/api/admin/themes"): "admin_themes_save_route", + ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } for key, handler_name in expected.items(): self.assertEqual(routes.get(key), handler_name, key) + def test_admin_themes_page_route_is_not_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/themes", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.http_exception.status, 404) + + def test_admin_appearance_page_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request("GET", "/admin/appearance", app=app) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "index_route") + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self): diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index 65b83e1..f8a9d43 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -279,6 +279,35 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertTrue(win95["use_in_admin"]) self.assertNotIn("accent", win95["tokens"]) + def test_theme_accent_is_normalized_to_hex(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "0F8"}, + } + ], + ) + + self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88") + + def test_theme_accent_rejects_non_hex_values(self): + with self.assertRaises(ValueError): + WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "accent": "lime"}, + } + ], + ) + def test_public_payload_keeps_admin_usage_flag(self): cfg = WebappThemesConfig( default_theme="custom", From a1725a68729c5cdc73f282b56a8be2106372b1e3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 13:54:47 +0300 Subject: [PATCH 03/45] feat: custom themes logo size tuning --- README.md | 2 + .../admin/sections/AppearanceSection.svelte | 66 +++++++++++++++++++ .../src/lib/admin/stores/themesStore.js | 26 ++++++++ .../web/frontend/src/lib/webapp/themeStyle.js | 7 ++ bot/app/web/frontend/src/styles/webapp.css | 38 +++++++---- .../web/templates/subscription_webapp.html | 2 +- bot/app/web/themes/windows95/style.css | 11 +++- bot/app/web/webapp/assets.py | 6 +- config/webapp_themes_config.py | 11 ++++ tests/test_webapp_themes_config.py | 26 +++++++- 10 files changed, 175 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d619f58..a23d9c4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Remnawave Minishop +![Remnawave Minishop](docs/remnawave-minishop.webp) + Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду. Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md). diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte index 758592e..b5b7dab 100644 --- a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -134,6 +134,12 @@ return `#${hex}`; } + function homeLogoScale(theme) { + const scale = Number(theme.tokens?.home_logo_scale || 100); + if (!Number.isFinite(scale)) return 100; + return Math.min(300, Math.max(50, Math.round(scale))); + } + function openThemeAccentPicker(theme) { themesStore.setThemeAccent(theme.key, pickerHex(theme.tokens?.accent || "#00fe7a")); } @@ -216,6 +222,10 @@ themesStore.setThemeAccent(theme.key, value); } + function setThemeHomeLogoScale(theme, value) { + themesStore.setThemeHomeLogoScale(theme.key, value); + } + function selectTheme(theme, event = null) { if (event?.target?.closest?.("button,input,label")) return; if (!themesSaving) themesStore.setCurrentTheme(theme.key); @@ -472,6 +482,37 @@ /> {at("themes_use_in_admin", {}, "Использовать в админке")} +
({ + ...s, + themesCatalog: { + ...s.themesCatalog, + themes: (s.themesCatalog.themes || []).map((theme) => + theme.key === key + ? { + ...theme, + tokens: { + ...(theme.tokens || {}), + home_logo_scale: nextScale === 100 ? null : nextScale, + }, + } + : theme + ), + }, + })); + } + return { subscribe: state.subscribe, loadThemes, saveThemes, setCurrentTheme, setThemeAccent, + setThemeHomeLogoScale, togglePrimaryAccent, toggleAdminUse, uploadLogoFile, diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/bot/app/web/frontend/src/lib/webapp/themeStyle.js index e312c37..c415cd0 100644 --- a/bot/app/web/frontend/src/lib/webapp/themeStyle.js +++ b/bot/app/web/frontend/src/lib/webapp/themeStyle.js @@ -17,6 +17,7 @@ const TOKEN_TO_CSS_VAR = { font_sans: "--font-sans", font_logo: "--font-logo", font_mono: "--font-mono", + home_logo_scale: "--home-logo-scale", admin_bg: "--admin-bg", admin_surface: "--admin-surface", admin_surface_2: "--admin-surface-2", @@ -38,6 +39,12 @@ export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", op if (key === "accent") continue; const value = t[key]; if (value === undefined || value === null || value === "") continue; + if (key === "home_logo_scale") { + const scale = Number(value); + if (!Number.isFinite(scale) || scale <= 0) continue; + parts.push(`${cssVar}:${scale / 100}`); + continue; + } parts.push(`${cssVar}:${String(value)}`); } return parts.join(";"); diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 18c1416..9b7d6dd 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -30,12 +30,18 @@ a { display: grid; min-height: 100dvh; place-items: center; - align-content: center; + align-content: safe center; gap: 14px; color: var(--muted); font-weight: 800; } +.loader .brand-mark.brand-mark-lg { + width: calc(4.125rem * var(--home-logo-scale, 1)); + height: calc(4.125rem * var(--home-logo-scale, 1)); + font-size: calc(2.875rem * var(--home-logo-scale, 1)); +} + .app-header, .preview-header, .screen-head { @@ -1530,17 +1536,29 @@ a { .home-layout { display: grid; min-height: calc(100dvh - 34px); - grid-template-rows: minmax(0, 1fr) auto; + grid-template-rows: minmax(min-content, 1fr) auto; gap: 18px; padding-bottom: 86px; animation: section-enter 0.24s ease-out both; } .home-brand { - align-self: center; + align-self: safe center; padding-top: 0; } +.home-brand .brand-mark.brand-mark-xl { + width: calc(6rem * var(--home-logo-scale, 1)); + height: calc(6rem * var(--home-logo-scale, 1)); + font-size: calc(4.375rem * var(--home-logo-scale, 1)); +} + +.login-brand-auth .brand-mark.brand-mark-xl { + width: calc(116px * var(--home-logo-scale, 1)); + height: calc(116px * var(--home-logo-scale, 1)); + font-size: calc(84px * var(--home-logo-scale, 1)); +} + .home-bottom { display: grid; gap: 10px; @@ -1549,7 +1567,7 @@ a { .auth-screen { display: grid; - align-content: center; + align-content: safe center; } .auth-card-wrap { @@ -1568,12 +1586,6 @@ a { gap: 5px; } -.login-brand-auth .brand-mark.brand-mark-xl { - width: 116px; - height: 116px; - font-size: 84px; -} - .login-brand h1 { margin: 0; overflow-wrap: anywhere; @@ -2129,9 +2141,9 @@ a { .home-brand .brand-mark, .home-brand .brand-mark-lg, .home-brand .brand-mark-xl { - width: clamp(180px, 18vw, 260px); - height: clamp(180px, 18vw, 260px); - font-size: clamp(124px, 13vw, 184px); + width: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + height: calc(clamp(180px, 18vw, 260px) * var(--home-logo-scale, 1)); + font-size: calc(clamp(124px, 13vw, 184px) * var(--home-logo-scale, 1)); } .home-bottom { diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index cf213f6..492c5c8 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -9,7 +9,7 @@ /minishop - + diff --git a/bot/app/web/themes/windows95/style.css b/bot/app/web/themes/windows95/style.css index 68a5709..bdbdccc 100644 --- a/bot/app/web/themes/windows95/style.css +++ b/bot/app/web/themes/windows95/style.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap&subset=cyrillic"); + .theme-key-windows95 { color-scheme: light; image-rendering: pixelated; @@ -15,7 +17,7 @@ --blue: #000080; --radius: 0px; --font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; - --font-logo: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + --font-logo: "Press Start 2P", "Pixel Cyr", "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; --font-mono: Consolas, "Courier New", monospace; --accent-contrast: #ffffff; --surface-sheen: transparent; @@ -89,6 +91,8 @@ .theme-key-windows95 svg.lucide-bitcoin, .theme-key-windows95 svg.lucide-check, .theme-key-windows95 svg.lucide-check-circle-2, +.theme-key-windows95 svg.lucide-circle-check, +.theme-key-windows95 svg.lucide-circle-check-big, .theme-key-windows95 svg.lucide-chevrons-up-down, .theme-key-windows95 svg.lucide-circle-x, .theme-key-windows95 svg.lucide-coins, @@ -142,6 +146,7 @@ background-repeat: no-repeat; background-size: contain; image-rendering: pixelated; + filter: none !important; } .theme-key-windows95 svg.lucide-arrow-left { @@ -547,6 +552,10 @@ color: var(--accent); } +.theme-key-windows95 .traffic-top strong { + color: var(--muted); +} + .theme-key-windows95 .btn-primary, .theme-key-windows95 .bottom-nav button.active { color: #ffffff; diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index 198ba08..c0916fd 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -857,12 +857,12 @@ async def index_route(request: web.Request) -> web.Response: brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( - '', # noqa: E501 - f'', # noqa: E501 + '', + f'', ) else: html = html.replace( - '', # noqa: E501 + '', "", ) return web.Response(text=html, content_type="text/html", charset="utf-8") diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py index 4f713bc..495cdeb 100644 --- a/config/webapp_themes_config.py +++ b/config/webapp_themes_config.py @@ -38,6 +38,7 @@ class ThemeTokens(BaseModel): font_sans: Optional[str] = None font_logo: Optional[str] = None font_mono: Optional[str] = None + home_logo_scale: Optional[int] = None admin_bg: Optional[str] = None admin_surface: Optional[str] = None admin_surface_2: Optional[str] = None @@ -64,6 +65,16 @@ class ThemeTokens(BaseModel): hex_value = "".join(char * 2 for char in hex_value) return f"#{hex_value}" + @field_validator("home_logo_scale") + @classmethod + def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]: + if value is None: + return None + scale = int(value) + if scale < 50 or scale > 300: + raise ValueError("home_logo_scale must be between 50 and 300 percent") + return scale + class WebappTheme(BaseModel): """Single theme descriptor loaded from WEBAPP_THEMES_DIR//theme.json.""" diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index f8a9d43..1cb81fe 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -33,7 +33,7 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertEqual(win95.tokens.style_preset, "win95") self.assertFalse(win95.use_primary_accent) self.assertTrue(win95.use_in_admin) - self.assertEqual(win95.assets_version, 1) + self.assertEqual(win95.assets_version, 3) ascii_theme = cfg.theme_by_key("ascii") self.assertIsNotNone(ascii_theme) self.assertEqual(ascii_theme.css_file, "style.css") @@ -294,6 +294,25 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88") + def test_theme_home_logo_scale_is_public_token(self): + cfg = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "custom", + "enabled": True, + "default": True, + "tokens": {"color_scheme": "dark", "home_logo_scale": 135}, + } + ], + ) + + payload = public_themes_catalog_payload(cfg, "#abc123") + custom = payload["themes"][0] + + self.assertEqual(cfg.theme_by_key("custom").tokens.home_logo_scale, 135) + self.assertEqual(custom["tokens"]["home_logo_scale"], 135) + def test_theme_accent_rejects_non_hex_values(self): with self.assertRaises(ValueError): WebappThemesConfig( @@ -361,11 +380,14 @@ class WebappThemesConfigTests(unittest.TestCase): descriptor["assets_version"], cfg.theme_by_key("windows95").assets_version, ) - self.assertEqual(descriptor["assets_version"], 1) + self.assertEqual(descriptor["assets_version"], 3) self.assertIn("lucide-house", css) self.assertIn("lucide-earth", css) self.assertIn("lucide-circle-check", css) self.assertIn("lucide-circle-check-big", css) + self.assertIn("filter: none !important", css) + self.assertIn("Press Start 2P", css) + self.assertIn(".theme-key-windows95 .traffic-top strong", css) self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists()) def test_resolved_refreshes_stale_builtin_light_assets(self): From 86ddceb64690ea6549589c6cde12b46960557d89 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 14:38:39 +0300 Subject: [PATCH 04/45] feat: custom themes polishing --- .../src/lib/admin/stores/themesStore.js | 8 +- bot/app/web/themes/windows95/icons/README.md | 9 -- .../web/themes/windows95/icons/arrow-left.png | Bin 610 -> 1708 bytes .../themes/windows95/icons/arrow-right.png | Bin 423 -> 1920 bytes .../web/themes/windows95/icons/bitcoin.png | Bin 395 -> 1698 bytes .../web/themes/windows95/icons/dashboard.png | Bin 371 -> 1686 bytes bot/app/web/themes/windows95/style.css | 149 ++++++++++++------ bot/app/web/themes/windows95/theme.json | 2 +- tests/test_webapp_themes_config.py | 4 +- 9 files changed, 104 insertions(+), 68 deletions(-) delete mode 100644 bot/app/web/themes/windows95/icons/README.md diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js index 41d4a6d..41c9f6a 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -119,8 +119,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { } } - async function setCurrentTheme(key) { - let changed = false; + function setCurrentTheme(key) { state.update((s) => ({ ...s, themesCatalog: { @@ -132,11 +131,6 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { })), }, })); - state.update((s) => { - changed = s.themesCatalog.default_theme === key; - return s; - }); - if (changed) await saveThemes({ silent: true }); } function togglePrimaryAccent(key, enabled) { diff --git a/bot/app/web/themes/windows95/icons/README.md b/bot/app/web/themes/windows95/icons/README.md deleted file mode 100644 index 83e5f70..0000000 --- a/bot/app/web/themes/windows95/icons/README.md +++ /dev/null @@ -1,9 +0,0 @@ -Windows 95 theme button icons -============================= - -These PNG icons were sourced from the Windows 98 Icon Viewer: - -https://win98icons.alexmeub.com/ - -Original filenames were normalized for theme CSS usage. They are loaded by -`bot/app/web/themes/windows95/style.css` through `/webapp-theme-assets/...`. diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/bot/app/web/themes/windows95/icons/arrow-left.png index d08f5e96bcebec55887b39f029fd915e9cefc652..38a3df67e0ec250a780d4bcabbe9978c27601333 100644 GIT binary patch literal 1708 zcmbVNOKjXk7&eGd64G$!0aW#1xgc7=_SjxKyVEXJ5^uU&)oGe8Nu->0Uhc5fWoJj8L_##+^!Vb|eoeap@4_xSe1LZPbqiSG z1ZjHJ<%k?uo~q;Jd0cXYUH9`l+mI3XBtd-JZv`=ImxT^5Wb5p)EbtvjGG7+PvVi=Y zHO)`Zi0}%KfQSJAysAsPB}WCMU>NuDDo}MI8SNOj^VzK@u~pl4HXrO=#Stm!ZyI zUlf3q*Rt{$>uT}+JVcyS7Fim-f~<><>q{Y#XGlyJB1|faOy<@sn-ES!1SK@8Q`+jA zHQkrYtD}<6Z=3Z3hoI8X`qi z#J1v!8Yy701h6KKYMN`fsxfMe=JEGM9y?vAb=^GKnaf%NR=t>+wbVNi2gswnRmufzy1Ojk%vRzEy~o}<6}Ywxy0mGNFw!g7-G#-}O;Qw*u?DNwRoh9vocI_mURnCJ znYXU=|F_@uNbi*uH&M_aY+TAhmW-SYm98oBrG9nZT`++9a5mMlWj<2bO+J)Kz^EfO za*urY`Fm_!zf?2F>f4?_Ix{%(1Q?F*K3{pG^F{gWF`zBPF?{$b#h_4Dr| zgGX+9q5bgc7h679d2G*%U%vh6L$`1Fclb$r^)#yfcJ`6R-d9$YJO3H@b;p}O-3H(J zYb8B&*WdcNvpbF*<8GvG{m1vesg6JQ%+r-O?)k2M=)?nav!9(=dG_V%yK9?|AKvr* k$k+JwDf9d|`SzDjHl$yz?I%MAN3#EuT4l;SHGXjQKQs{~{r~^~ literal 610 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vJfvmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fgp&sB9;tEpG&~V_ufrbVk z3&dgg4+0DfKm_Cic_607#P3W%4r57>UoeBivm0q3PLj8~3%gii$7>*my~NYkmHiQ$ z6oZcZ_+ptQE9i(`n!`O=Gf{aPFZ4isjrjCthFGj*~A@0N1@1qGbb;?7CDXI*o2 zhG|T?#**jTfvOAkkx!0BwrQ+lykzpp*oS9JliZfiYkW3dxnQ-r zEqfQsWfs@mTJfJ6od@T#1?FAd=dobYly{#6S%j2sZwuv|ewSZ%Mayj)n+C6nWPVny z1)J=fdmaQt+St5nHIC!{`tXLXN%FzD`VEVs-;49i?w<9?`|^s=TottzhRdHC{`%;* zDNVU+ULE|{|Kt5X3>GUo7ARZIeF_Xo)e_f;l9a@fRIB8oR3OD*WMF8aYhb2pWENsz vU}a)rWo)QxU}9xpAh=6G3PnS1eoAIqC2kD~&u1(HYGCkm^>bP0l+XkK{50WP diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/bot/app/web/themes/windows95/icons/arrow-right.png index 422537f439b8901377a8b80064ef83dec04be08c..5c1cb205f1eeb2735269a5af10df64fe499b0ea6 100644 GIT binary patch literal 1920 zcmb_dUuYaf9Nwa&8dH>rAWCH2sx;{A&i=jKZLVp0m-HfD8q&i=1aWqD_HN1D&bqtF z-35KA6tSh$3JQw8DPoa6h`zLn526o3P%I+8NTC`^kyi9UD6}|xf7UdlX(=A;?df&;9AOjeGaK z2RUxw9lx@WER>(IF>Q#*r4A8WjgX-^Zfv|2BD_cv-XT>#$P1Uxd?oO{n-}KwGAxG$ zQu9kI5jnUrQ^6~X*m8yOJ^Wb9W&{nAAimY82eIAC3msmYjnmhXz;__YVqTa?1M&;y zS-wCc!pl&E07D3QMHlyrt^$c>7*FvERCGyJBxnK!+B&oqneY7s7BO-?d#*Uui^rbw zLM=%`Taud1rr1(&HLgN~}0d@BI zx&W-a<#HBdQ!R}~79vh2msuLUf^3S8EA5a-b0nrq5hjz%Oy?a`=#Si*h-Rf!Y^Re{gSXcuUmMl3K>r*>47%l32-Cn&&V zx|kPO&Z6(THpy9jHn$c%F5czDaq568(RF%sZp=YUxm;aW% z7+4UiAg8LH;VDMW$f>EDI|i{!e#~0e?yZl5Zhe;dF z*ZHec3jksn5`+0fYv<7E ztNNw(M@Q$LK62!-(zm~U`T;(0{<}+s2ew~XU43}wzTeN1)enXAjGUgFDW2N*;-P;5 D;hS2MYfG{~t&IMIrQ!X%R_4QO1%WzhDN3XE)M7oFs2|7ml`jQ>%a+_7YEDSN2D2 zQVcrslUq$RfkFkIE{-7_*OLNGi-_8v-#!^9D07`YKZlV!w8dHS>f5L3UI&Au zZ})pQNIYt~t-a*nw`tsMT~09padCVMHY=1Gmjs_W1+-hW#5JNMC9x#cD!C{XNHG{0 z7+UBWnCTjsg%}uEnV47^8|oUESQ!`y?oyCK(U6;;l9^VCTSLP08Owkg7(8A5T-G@y GGywp$b%qN7 diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/bot/app/web/themes/windows95/icons/bitcoin.png index a9b721218f67a85d127c464eab5bd2ff11e98489..55de9d3a212fca6884f05e74a33cf5a7963296e4 100644 GIT binary patch literal 1698 zcmbVNe@q)y96xXw8xFzY;voJw4>G}dKig6`=r(8(x~xc`P-gsN*SpvDLhr}!uC!nr z1{?-;rpv@^%a#!~jl{*wY_7&BBU6m~L(M|6WC{Ts)5!ci{}E>NU1^(bnJ(^ zpZEFx{@!47Q$t>EaV~-&dG1D63!L-Kv1%oJS6>|33a8a-<8~cE?kzM&7IN^=dIVYV zo9OK@Iy~E0UXch+Q2c<1Nh(AmNX_P$%JH4RK>Z*n$`0(8GiNbW6dah(=Ak^Q6NJRZ z9u2hiGVrulsvd| zU?Ia$S(1!KqeRp~C|Z!DtE#F$0nSh~ zucCz0!D>#cj52n@hL-t6Sx};S`3)C)Zvm0tQ4Tp`uTxPlu z++_L~kYV%EV3(R-I6sCUSw6R`)?2n$+V@pcHeK*p?xaSz9vf~_Y`PF`p$znHkXV|drl@Rq3;4=+x2MXM`L#l!(@;rQspY3YZy z{1GnqLZJHGS!M0zHOJPmFHDUfks_nD?^#zkXi?-gI9 cwqYsf=h36H$6nfV+Wd>S>ziEf*6rx~2L(D#lK=n! literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFO`~`P`^#J8V&xy1i|n|jK>ZF$JyT%Xt# zdDZrJ8F&{LwpTU)ty3*=jVMV;EJ?LWE=mPb3`Pcq7PgTe~DWM4fdtQEo diff --git a/bot/app/web/themes/windows95/icons/dashboard.png b/bot/app/web/themes/windows95/icons/dashboard.png index 7bd6f7920406f45bc39267c917f62d44d3137015..18f939b6575df0963054fb457fb3059e244ed35c 100644 GIT binary patch literal 1686 zcmbVNeQXnD9KHnvI~ge8bRglpO(weDS9|SxTUo|#tPN|oY>k`5gkA66c9-=&xVzDA zD9C2Sv4of?CK5$KqXCme=jIj!beb%hkT^};W@coI#{R>UMJG5G;(J}&06~dda(D0D zJ@51TJ-@H(sSnoVtF;$0#p?8jy z7l8olR}7O^1mNeq7}ShQlE{YLe3*1ZO6?3qI-v$9u!r5HVSA|~>;xW{i+3}g%zjQ4 z+O5oH_KW{>f1NJD?&4#2&13bPRT8XEGGMYg(jCzV_M}%a2~AZ2%L`U@dojUED-f~t zYw4~dutXbg1(5VV(!B&T)QB19bx;|F(Yj+f5zu+U>cb@+#H|uj-)`vH3>*#uYnh!w z@F6?LfC7h?4yV+IUHd2m$=T`WDnq`W+1F;}FVXv7T*Eb8USH59o#7UKe)&@Dl}#;& zYWeo=r%s-koE)8hV2XOTV&UYv$wM7iON048?Mobeb=Z0K%Hqt4Q`PuzZI@%`o)&iX zqicJA-7>_#U$oLSS~Rl$iN=q|`v85}HhlW@R}Wt4+3ksr?8>_~lkA_KyD;(m%5yb! zC5f$DB6H}_!KTBbFMNcCJEuPA9;sZBhbZTgWrO(_F8)z$em`;Ix&Gqe5xMNFJay^y zpgxWJ7F(Vfdn8va)QY)f1(WFRdm`WbxXV2-fBu)j%}1`<9xLjtzdm!o)_3^3$PW!i z#kvE71LI)m&1rG@d*UZK$AiYHl9xgYt3wxO1Gc}*2I;qocN9OAOnyGryZ@cVf#Vyo z{_{20-yU0YY(v+#tA5*-9DR2Gz!wE|O{zIL S{h2dw{W<*AL9TCmOV>Z!2t$$p literal 371 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPF span { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.theme-key-windows95 .admin-language-trigger strong, +.theme-key-windows95 .admin-language-trigger small { + min-width: 0; + margin: 0; + white-space: nowrap; +} + +.theme-key-windows95 .admin-language-trigger small { + display: inline-flex; + align-items: center; + gap: 5px; + overflow: hidden; + text-overflow: ellipsis; +} + .theme-key-windows95 .admin-nav-item:hover, .theme-key-windows95 .admin-select-item[data-highlighted], .theme-key-windows95 .admin-select-item:hover, @@ -738,9 +788,10 @@ } .theme-key-windows95 .admin-nav-item.active, -.theme-key-windows95 .admin-btn-primary, .theme-key-windows95 .admin-tabs-trigger[data-state="active"], -.theme-key-windows95 .admin-revenue-period-btn.is-active { +.theme-key-windows95 .admin-revenue-period-btn.is-active, +.theme-key-windows95 .admin-btn:active:not(:disabled), +.theme-key-windows95 .admin-btn[aria-pressed="true"] { border-color: #404040 #ffffff #ffffff #404040; box-shadow: inset 1px 1px 0 #000000, diff --git a/bot/app/web/themes/windows95/theme.json b/bot/app/web/themes/windows95/theme.json index f390472..4631f6a 100644 --- a/bot/app/web/themes/windows95/theme.json +++ b/bot/app/web/themes/windows95/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": false, "use_in_admin": true, "css_file": "style.css", - "assets_version": 1, + "assets_version": 5, "tokens": { "color_scheme": "light", "style_preset": "win95" diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index 1cb81fe..a643603 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -33,7 +33,7 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertEqual(win95.tokens.style_preset, "win95") self.assertFalse(win95.use_primary_accent) self.assertTrue(win95.use_in_admin) - self.assertEqual(win95.assets_version, 3) + self.assertEqual(win95.assets_version, 5) ascii_theme = cfg.theme_by_key("ascii") self.assertIsNotNone(ascii_theme) self.assertEqual(ascii_theme.css_file, "style.css") @@ -380,7 +380,7 @@ class WebappThemesConfigTests(unittest.TestCase): descriptor["assets_version"], cfg.theme_by_key("windows95").assets_version, ) - self.assertEqual(descriptor["assets_version"], 3) + self.assertEqual(descriptor["assets_version"], 5) self.assertIn("lucide-house", css) self.assertIn("lucide-earth", css) self.assertIn("lucide-circle-check", css) From e9ad25995797eefde3409479a4e2a76850a40883 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 15:46:08 +0300 Subject: [PATCH 05/45] docs: update web app theme specific docs --- README.md | 5 +- docs/admin.md | 9 ++ docs/configuration.md | 12 +- docs/deployment.md | 6 +- docs/webapp-themes.md | 366 ++++++++++++++++++++++++++++++++++++++++++ docs/webapp.md | 10 +- 6 files changed, 391 insertions(+), 17 deletions(-) create mode 100644 docs/webapp-themes.md diff --git a/README.md b/README.md index a23d9c4..afe9b61 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика. - [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога. - [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки. +- [Темы Web App](docs/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`). - [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека. @@ -82,10 +83,10 @@ docker compose logs -f remnawave-minishop Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md). -Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji: +Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` diff --git a/docs/admin.md b/docs/admin.md index 6e24843..9ccee4e 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -9,6 +9,7 @@ - блокировка пользователей, рассылки, промокоды и просмотр логов; - ручная синхронизация с Remnawave; - редактор разрешенных настроек приложения из manifest-файла; +- раздел **Внешний вид** для логотипа, emoji-логотипа, выбора темы, accent-цвета, масштаба логотипа и предпросмотра тем; - редактор JSON-каталога тарифов; - загрузка Internal Squads из Remnawave для выбора в тарифах. @@ -46,6 +47,14 @@ Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде. +## Внешний вид + +Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки. + +В блоке тем админка читает каталог из `WEBAPP_THEMES_DIR`, показывает встроенные и кастомные темы, позволяет выбрать текущую тему, изменить accent, включить или выключить тему для админки и настроить масштаб логотипа на главной и экране входа. Кнопка предпросмотра открывает `/home?theme_preview=` и не меняет глобальную тему до сохранения. + +Подробный формат `theme.json`, CSS/asset-роуты и пошаговый пайплайн создания новой темы описаны в [webapp-themes.md](webapp-themes.md). + ## Тарифы Раздел **Система -> Тарифы** работает с файлом `TARIFFS_CONFIG_PATH` (по умолчанию `data/tariffs.json`). При сохранении backend валидирует payload через `TariffsConfig`, пишет JSON в UTF-8 и сбрасывает кеш публичных данных Web App. diff --git a/docs/configuration.md b/docs/configuration.md index f4b2f41..9864358 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,10 +96,10 @@ nano .env Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. -В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: +В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data ``` @@ -122,10 +122,8 @@ docker compose up -d --build --force-recreate | `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Хост и порт Web App. По умолчанию порт `8081`. | | `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. | | `WEBAPP_TITLE` | Заголовок Web App. | -| `WEBAPP_PRIMARY_COLOR` | Основной цвет интерфейса. | -| `WEBAPP_LOGO_URL` | URL логотипа Web App; если пусто — показывается emoji из `WEBAPP_LOGO_EMOJI`. | -| `WEBAPP_LOGO_EMOJI` | Emoji-заглушка вместо картинки логотипа. | -| `WEBAPP_LOGO_EMOJI_FONT` | Набор/шрифт для отрисовки emoji (например `system`, `twemoji`, `noto-color-animated`). | +| `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `/theme.json` и опциональные CSS/ассеты. | +| `WEBAPP_DEFAULT_THEME` | Опциональный override темы по ключу, например `light` или `neon`. Если пусто, используется `default` из дескрипторов тем. | | `WEBAPP_SESSION_SECRET` | HMAC-секрет сессий Web App. | | `WEBHOOK_SECRET_TOKEN` | Секретный токен, с которым Telegram шлёт обновления на вебхук. | | `WEBAPP_SESSION_TTL_SECONDS` | Время жизни сессии Web App. | @@ -146,7 +144,7 @@ docker compose up -d --build --force-recreate | `BRUTE_FORCE_LOCK_SECONDS` | Длительность временной блокировки. | | `MY_DEVICES_SECTION_ENABLED` | Показывает раздел "Мои устройства" и включает API устройств. | -Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md). +Логотип, emoji-логотип, основной accent-цвет и тема редактируются в разделе **Админка -> Внешний вид** и сохраняются как overrides в базе. Переменные `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` в `.env` считаются устаревшими для первичной настройки и игнорируются при загрузке env. Настройка домена, BotFather и callback URL описана в [webapp.md](webapp.md), а создание кастомных тем - в [webapp-themes.md](webapp-themes.md). ### SMTP и вход по email diff --git a/docs/deployment.md b/docs/deployment.md index a4292cb..e15dc52 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -26,7 +26,7 @@ IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d Перед запуском или после добавления mount выполните на сервере из каталога проекта: ```bash -mkdir -p data/webapp-logo data/webapp-emoji +mkdir -p data/themes data/webapp-logo data/webapp-emoji chown -R 10001:10001 data chmod -R u+rwX data docker compose up -d --force-recreate remnawave-minishop @@ -35,10 +35,10 @@ docker compose up -d --force-recreate remnawave-minishop Проверка прав: ```bash -docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/webapp-emoji; touch /app/data/webapp-emoji/test && rm /app/data/webapp-emoji/test' +docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/themes /app/data/webapp-emoji; touch /app/data/themes/test /app/data/webapp-emoji/test && rm /app/data/themes/test /app/data/webapp-emoji/test' ``` -Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, кеш `WEBAPP_LOGO_URL` в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`. +Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, темы в `/app/data/themes`, кеш логотипов в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`. ## Обновление версии diff --git a/docs/webapp-themes.md b/docs/webapp-themes.md new file mode 100644 index 0000000..2df8e37 --- /dev/null +++ b/docs/webapp-themes.md @@ -0,0 +1,366 @@ +# Темы и внешний вид Web App + +Web App поддерживает файловые темы, предпросмотр и базовую настройку внешнего вида из админ-панели. Тема может быть простой цветовой схемой на JSON-токенах или полноценным скином с собственным CSS, шрифтами, иконками и графикой. + +## Что можно поменять + +Через раздел **Админка -> Внешний вид** можно: + +- выбрать глобальную тему Web App; +- изменить accent-цвет конкретной темы; +- включить или выключить применение темы в админ-панели; +- настроить масштаб логотипа на главной и экране входа; +- загрузить логотип файлом или по HTTPS-ссылке; +- включить emoji-логотип и выбрать способ его отрисовки; +- открыть предпросмотр темы через `/home?theme_preview=`. + +Через файлы темы можно менять намного больше: + +- базовые цвета Mini App и админки; +- радиусы, семейства шрифтов и размер главного логотипа; +- любые компоненты через CSS: карточки, навигацию, таблицы, модалки, кнопки, скелетоны, прогресс-бары, состояния hover/active и мобильную/desktop-верстку; +- иконки и изображения, если CSS ссылается на ассеты темы; +- стили только пользовательской части, только админки или обеих частей сразу. + +Готовые темы лежат в `bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. + +## Где живут темы + +Каждая тема - отдельная папка: + +```text +data/themes/ + neon/ + theme.json + style.css + icons/ + save.png +``` + +Путь настраивается переменной: + +```env +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= +``` + +`WEBAPP_DEFAULT_THEME` опционален. Если он задан и совпадает с ключом темы, он переопределяет `default: true` в `theme.json`. Если переменная пустая, дефолт выбирается из дескрипторов тем. + +Важно: `WEBAPP_PRIMARY_COLOR`, `WEBAPP_LOGO_URL`, `WEBAPP_LOGO_USE_EMOJI`, `WEBAPP_LOGO_EMOJI` и `WEBAPP_LOGO_EMOJI_FONT` больше не являются рабочим способом первичной настройки через `.env`. Эти значения редактируются в админке и сохраняются как overrides в базе. Тема при этом может использовать сохраненный primary color как fallback accent. + +## Контракт `theme.json` + +Минимальная тема: + +```json +{ + "key": "neon", + "names": { + "ru": "Неон", + "en": "Neon" + }, + "enabled": true, + "default": true, + "use_primary_accent": true, + "use_in_admin": true, + "tokens": { + "color_scheme": "dark", + "bg": "#05040a", + "panel": "#11101c", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "accent": "#a855f7", + "radius": "14px" + } +} +``` + +Тема с CSS: + +```json +{ + "key": "neon", + "names": { + "ru": "Неон", + "en": "Neon" + }, + "enabled": true, + "default": false, + "use_primary_accent": false, + "use_in_admin": true, + "css_file": "style.css", + "assets_version": 1, + "tokens": { + "color_scheme": "dark", + "style_preset": "none", + "accent": "#a855f7", + "bg": "#05040a", + "panel": "#11101c", + "panel_2": "#090815", + "panel_3": "#1a1830", + "border": "rgba(168, 85, 247, 0.28)", + "border_strong": "rgba(168, 85, 247, 0.48)", + "text": "#f8f7ff", + "muted": "#b8b2d8", + "dim": "#756f9b", + "danger": "#ff6b8a", + "blue": "#38bdf8", + "radius": "14px", + "font_sans": "Inter, system-ui, sans-serif", + "font_logo": "Inter, system-ui, sans-serif", + "font_mono": "\"JetBrains Mono\", \"Fira Code\", monospace", + "home_logo_scale": 120, + "admin_bg": "#05040a", + "admin_surface": "#11101c", + "admin_surface_2": "#090815", + "admin_elev": "#1a1830", + "admin_border": "rgba(168, 85, 247, 0.28)", + "admin_border_strong": "rgba(168, 85, 247, 0.48)", + "admin_text": "#f8f7ff", + "admin_muted": "#b8b2d8", + "admin_dim": "#756f9b" + } +} +``` + +Поля верхнего уровня: + +| Поле | Назначение | +| --- | --- | +| `key` | Уникальный ключ темы, 1-64 символа: латиница, цифры, `_` и `-`. Если ключ не указан, берется имя папки. | +| `names` | Локализованные названия, например `ru` и `en`. | +| `enabled` | Показывать тему пользователям. Отключенная тема не попадает в публичный каталог. | +| `default` | Делает тему выбранной по умолчанию, если `WEBAPP_DEFAULT_THEME` не задан. | +| `use_primary_accent` | Если `true`, тема может получить accent из настройки внешнего вида, когда в `tokens.accent` ничего нет. | +| `use_in_admin` | Если `false`, пользовательская часть использует тему, но админка откатывается на `dark`. | +| `css_file` | CSS-файл внутри папки темы. Может быть `style.css` или вложенный путь вроде `css/theme.css`. | +| `assets_version` | Версия ассетов. Для встроенных тем используется для обновления старых файлов в `data/themes`. | +| `tokens` | Дизайн-токены, которые превращаются в CSS-переменные на `.app-shell`. | + +## Токены + +Поддерживаемые токены: + +| Токен | CSS-переменная | Что меняет | +| --- | --- | --- | +| `color_scheme` | `color-scheme` | Нативная светлая/темная схема браузера: `dark` или `light`. | +| `style_preset` | CSS-класс пресета | Сейчас `win95`/`windows95` добавляет `theme-preset-win95`; остальные значения не дают специального класса. | +| `accent` | `--accent` | Главный акцент: активные элементы, кнопки, прогресс, фокус. Только hex `#RGB` или `#RRGGBB`. | +| `bg` | `--bg` | Основной фон приложения. | +| `panel` | `--panel` | Основные карточки и поверхности. | +| `panel_2` | `--panel-2` | Вторичные поверхности. | +| `panel_3` | `--panel-3` | Поверхности повышенной вложенности, dropdown/popover. | +| `border` | `--border` | Обычные границы. | +| `border_strong` | `--border-strong` | Усиленные границы и hover-состояния. | +| `text` | `--text` | Основной текст. | +| `muted` | `--muted` | Вторичный текст. | +| `dim` | `--dim` | Еще более тихий текст и служебные подписи. | +| `danger` | `--danger` | Ошибки и опасные действия. | +| `blue` | `--blue` | Синий вспомогательный цвет. | +| `radius` | `--radius` | Базовый радиус карточек, кнопок и контролов. | +| `font_sans` | `--font-sans` | Основной шрифт интерфейса. | +| `font_logo` | `--font-logo` | Шрифт бренда и заголовка. | +| `font_mono` | `--font-mono` | Моноширинный шрифт. | +| `home_logo_scale` | `--home-logo-scale` | Масштаб логотипа на главной и входе, от `50` до `300` процентов. | +| `admin_bg` | `--admin-bg` | Фон админ-панели. | +| `admin_surface` | `--admin-surface` | Основные карточки админки. | +| `admin_surface_2` | `--admin-surface-2` | Вторичные поверхности админки. | +| `admin_elev` | `--admin-elev` | Elevated-поверхности админки. | +| `admin_border` | `--admin-border` | Границы админки. | +| `admin_border_strong` | `--admin-border-strong` | Усиленные границы админки. | +| `admin_text` | `--admin-text` | Основной текст админки. | +| `admin_muted` | `--admin-muted` | Вторичный текст админки. | +| `admin_dim` | `--admin-dim` | Тихие подписи админки. | + +Если `css_file` не задан, интерфейс полностью строится на токенах и общих стилях. Если `css_file` задан, токены все равно применяются первыми, а CSS темы может уточнить или полностью переопределить внешний вид. + +## CSS-слой темы + +CSS темы подключается как: + +```text +/webapp-theme-css// +``` + +Например `data/themes/neon/style.css` будет доступен как `/webapp-theme-css/neon/style.css`. + +Корневой контейнер получает классы: + +```text +app-shell theme-dark theme-key-neon theme-css-style +``` + +Для светлой схемы будет `theme-light`. Класс `theme-key-` - основной якорь для CSS темы. Всегда начинайте селекторы с него, чтобы тема не задевала другие режимы: + +```css +.theme-key-neon.app-shell { + --surface-sheen: rgba(168, 85, 247, 0.12); + --shadow-soft: 0 18px 48px rgba(12, 5, 30, 0.44); +} + +.theme-key-neon .card { + border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); + background: + linear-gradient(135deg, rgba(168, 85, 247, 0.14), rgba(56, 189, 248, 0.05)), + var(--panel); +} + +.theme-key-neon .bottom-nav button.active, +.theme-key-neon .admin-nav-item.active { + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent); +} +``` + +CSS можно писать для пользовательской части и админки одновременно: + +```css +.theme-key-neon .period-card, +.theme-key-neon .method-card, +.theme-key-neon .option-row { + border-radius: 16px; +} + +.theme-key-neon .admin-card, +.theme-key-neon .admin-stat-card, +.theme-key-neon .admin-revenue-panel { + border-radius: 16px; +} +``` + +Ограничения: + +- CSS-файл должен быть внутри папки темы; +- размер CSS - до 512 KiB; +- путь не может содержать `..`; +- удаленные CSS, `data:` и protocol-relative URL в `css_file` не подключаются. + +## Ассеты темы + +Картинки темы кладутся рядом с `theme.json` и отдаются через: + +```text +/webapp-theme-assets// +``` + +Пример: + +```css +.theme-key-neon .btn-primary::before { + content: ""; + width: 16px; + height: 16px; + background: url("/webapp-theme-assets/neon/icons/spark.png") center / contain no-repeat; +} +``` + +Разрешены `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`. Один asset - до 1 MiB. Для шрифтов лучше использовать внешние источники, уже разрешенные CSP (`fonts.googleapis.com`, `fonts.gstatic.com`, `cdn.jsdelivr.net`) или системные fallback-цепочки в `font_*` токенах. + +## Пайплайн создания новой темы + +1. Выберите ключ темы. + + Ключ должен быть стабильным: по нему сохраняется выбранная тема и строятся URL ассетов. Используйте короткий slug: `neon`, `brand_dark`, `terminal-blue`. Не переименовывайте ключ после публикации без миграции файлов и сохраненных настроек. + +2. Создайте папку в `WEBAPP_THEMES_DIR`. + + В Docker по умолчанию это `data/themes`. Если включен bind mount `./data:/app/data`, убедитесь, что контейнер может писать в `data`. + + ```bash + mkdir -p data/themes/neon + ``` + +3. Скопируйте ближайшую базовую тему. + + Для обычной брендовой темы чаще всего удобнее начать с `dark` или `light`. Для глубокого CSS-скина можно взять `ascii` или `windows95` как пример того, насколько далеко можно уйти от стандартного вида. + + ```bash + cp bot/app/web/themes/dark/theme.json data/themes/neon/theme.json + ``` + +4. Отредактируйте `theme.json`. + + Сначала поменяйте `key`, `names`, `default`, `use_primary_accent` и базовые токены. На этом этапе можно вообще не создавать CSS: приложение уже увидит тему как новый набор токенов. + +5. Запустите приложение и откройте админку. + + Раздел **Внешний вид** загружает `/api/admin/themes`, backend читает `WEBAPP_THEMES_DIR`, добавляет обязательные базовые темы и возвращает каталог. Нажмите **Обновить**, если папка была создана во время работы приложения. + +6. Проверьте тему через предпросмотр. + + В карточке темы нажмите **Предпросмотр** или откройте: + + ```text + https://app.domain.com/home?theme_preview=neon + ``` + + Предпросмотр не меняет глобальную тему и удобен для проверки CSS до публикации. + +7. Подберите accent и масштаб логотипа. + + В админке можно менять accent и `home_logo_scale` без ручного редактирования JSON. При сохранении backend перепишет `theme.json` в `WEBAPP_THEMES_DIR`, выставит ровно один `default` и сбросит кеш публичных настроек. + +8. Добавьте `style.css`, если токенов мало. + + Создайте файл, укажите его в `theme.json`: + + ```json + { + "css_file": "style.css" + } + ``` + + Начинайте с переопределения CSS-переменных на `.theme-key-neon.app-shell`, затем переходите к конкретным компонентам. Проверяйте минимум: главная, оплата, настройки, модалки, админский дашборд, таблица пользователей, редактор тарифов. + +9. Добавьте ассеты при необходимости. + + Положите картинки в подпапку темы и ссылайтесь на них через `/webapp-theme-assets//...`. Не используйте относительные пути вроде `url("icons/x.png")`, если CSS может быть подключен с другого URL-уровня; явный `/webapp-theme-assets/neon/icons/x.png` надежнее. + +10. Настройте поведение админки. + + Если тема сильно декоративная и мешает рабочей админке, выставьте `use_in_admin: false`. Пользователи увидят тему, а администраторы в разделе админки получат `dark` как fallback. + +11. Сделайте тему дефолтной. + + Есть два способа: + + - в админке выбрать тему и сохранить; + - указать `WEBAPP_DEFAULT_THEME=neon` в `.env`, если нужен жесткий override на уровне окружения. + +12. Зафиксируйте тему. + + Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. + +## Насколько глубоко можно менять вид + +Уровни кастомизации: + +1. **Быстрый бренд** - токены `accent`, `bg`, `panel`, `text`, `radius`, логотип в админке. Код не нужен. +2. **Полная палитра** - все пользовательские и admin-токены, отдельные шрифты, масштаб логотипа. +3. **CSS-скин** - переопределение карточек, навигации, таблиц, модалок, progress/skeleton/toast, desktop/mobile раскладок. +4. **Почти новый UI** - тема вроде `windows95` или `ascii`: можно менять форму контролов, иконки, эффекты, таблицы и визуальный язык целиком, пока сохраняется DOM и интерактивные состояния. + +Не стоит менять через CSS смысловые состояния: скрывать ошибки, отключать фокус, перекрывать кнопки невидимыми слоями или делать `display: none` для обязательных действий оплаты и авторизации. Тема должна менять внешний вид, а не бизнес-логику. + +## Диагностика + +Если тема не появилась: + +- проверьте, что `theme.json` лежит ровно в `WEBAPP_THEMES_DIR//theme.json`; +- ключ состоит только из латиницы, цифр, `_` и `-`; +- JSON валиден; +- тема не отключена через `enabled: false`; +- в логах нет предупреждения `Ignoring theme descriptor`. + +Если CSS не применился: + +- проверьте `css_file` и URL `/webapp-theme-css//`; +- убедитесь, что файл меньше 512 KiB; +- начинайте селекторы с `.theme-key-`; +- откройте `/home?theme_preview=` в новом окне, чтобы исключить сохраненный старый выбор. + +Если ассеты не грузятся: + +- используйте путь `/webapp-theme-assets//`; +- проверьте расширение: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `ico`; +- размер каждого файла должен быть до 1 MiB; +- путь не должен содержать пробелы, кириллицу или `..`. diff --git a/docs/webapp.md b/docs/webapp.md index b018d64..d0878f3 100644 --- a/docs/webapp.md +++ b/docs/webapp.md @@ -24,10 +24,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 WEBAPP_SERVER_PORT=8081 SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ WEBAPP_TITLE="Моя подписка" -WEBAPP_PRIMARY_COLOR="#00fe7a" -WEBAPP_LOGO_URL= -WEBAPP_LOGO_EMOJI="🫥" -WEBAPP_LOGO_EMOJI_FONT=system +WEBAPP_THEMES_DIR=data/themes +WEBAPP_DEFAULT_THEME= WEBAPP_SESSION_SECRET= WEBHOOK_SECRET_TOKEN= WEBAPP_SESSION_TTL_SECONDS=86400 @@ -49,7 +47,9 @@ SMTP_FROM_EMAIL=no-reply@domain.com SMTP_FROM_NAME=Remnawave Minishop ``` -Если `WEBAPP_LOGO_URL` пустой, в шапке и на экране входа показывается запасной **emoji-логотип** (`WEBAPP_LOGO_EMOJI`) и при необходимости стиль отрисовки (`WEBAPP_LOGO_EMOJI_FONT`: например `system`, `noto-color`, `noto-color-animated`, `twemoji`). Если SMTP-настройки не заполнены, вход по email скрывается. +Внешний вид настраивается в админке: раздел **Внешний вид** управляет логотипом, emoji-логотипом, accent-цветом, выбранной темой и масштабом логотипа. Кастомные темы читаются из `WEBAPP_THEMES_DIR`, а `WEBAPP_DEFAULT_THEME` может принудительно выбрать тему по ключу. Подробный контракт `theme.json`, CSS/asset-роуты и пайплайн создания темы описаны в [webapp-themes.md](webapp-themes.md). + +Если SMTP-настройки не заполнены, вход по email скрывается. ## Telegram-авторизация From 2a1ff8e45e70e8fb0cf4ac7fd47f4f6fa2674f6c Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 19:27:33 +0300 Subject: [PATCH 06/45] feat: separate webapp favicon configuration in appearance admin panel section --- bot/app/web/admin_api_impl/routes.py | 1 + bot/app/web/admin_api_impl/settings.py | 6 + bot/app/web/admin_api_impl/themes.py | 92 ++++++- bot/app/web/admin_settings_manifest.py | 3 + bot/app/web/frontend/src/App.svelte | 11 +- .../web/frontend/src/admin/AdminPanel.svelte | 11 +- .../admin/sections/AppearanceSection.svelte | 232 ++++++++++++++++-- .../src/lib/admin/stores/themesStore.js | 67 ++++- .../web/frontend/src/lib/webapp/mockApi.js | 46 +++- .../frontend/src/lib/webapp/previewMock.js | 2 + bot/app/web/webapp/_runtime.py | 2 + bot/app/web/webapp/assets.py | 107 +++++++- bot/app/web/webapp/routes.py | 4 + config/settings.py | 24 ++ requirements.txt | 1 + tests/test_settings.py | 6 + tests/test_webapp_assets.py | 59 +++++ tests/test_webapp_route_contract.py | 14 ++ 18 files changed, 667 insertions(+), 21 deletions(-) diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py index de1ed9f..3a46e7b 100644 --- a/bot/app/web/admin_api_impl/routes.py +++ b/bot/app/web/admin_api_impl/routes.py @@ -57,4 +57,5 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/themes", admin_themes_get_route) router.add_put("/api/admin/themes", admin_themes_save_route) router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route) + router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route) router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/bot/app/web/admin_api_impl/settings.py index 3746394..e201123 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/bot/app/web/admin_api_impl/settings.py @@ -75,6 +75,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: or "WEBAPP_LOGO_URL" in deletes or "WEBAPP_LOGO_USE_EMOJI" in updates or "WEBAPP_LOGO_USE_EMOJI" in deletes + or "WEBAPP_FAVICON_URL" in updates + or "WEBAPP_FAVICON_URL" in deletes + or "WEBAPP_FAVICON_USE_CUSTOM" in updates + or "WEBAPP_FAVICON_USE_CUSTOM" in deletes + or "WEBAPP_LOGO_FAVICON_URL" in updates + or "WEBAPP_LOGO_FAVICON_URL" in deletes ): request.app["webapp_logo_cache"] = None diff --git a/bot/app/web/admin_api_impl/themes.py b/bot/app/web/admin_api_impl/themes.py index f1b4d79..22b89f3 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/bot/app/web/admin_api_impl/themes.py @@ -8,6 +8,7 @@ import re import socket from aiohttp import ClientSession, ClientTimeout +from PIL import Image, ImageOps, UnidentifiedImageError from config.webapp_themes_config import ( WebappThemesConfig, @@ -20,6 +21,9 @@ from config.webapp_themes_config import ( WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { ".gif": "image/gif", ".ico": "image/x-icon", @@ -69,6 +73,66 @@ def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "" return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" +def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image: + fitted = source.copy() + fitted.thumbnail((size, size), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + left = (size - fitted.width) // 2 + top = (size - fitted.height) // 2 + canvas.alpha_composite(fitted, (left, top)) + return canvas + + +def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]: + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + raise ValueError("favicon source must be a non-empty image up to 2 MiB") + + ext = _detect_logo_extension(body, content_type, filename) + digest = hashlib.sha256(body).hexdigest()[:16] + target_dir = WEBAPP_FAVICON_DIR / digest + target_dir.mkdir(parents=True, exist_ok=True) + + if ext == ".svg": + safe_name = "favicon.svg" + (target_dir / safe_name).write_bytes(body) + return { + "favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}", + "variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"}, + } + + try: + with Image.open(io.BytesIO(body)) as image: + image.seek(0) + source = ImageOps.exif_transpose(image).convert("RGBA") + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise ValueError("favicon source must be a raster image") from exc + + if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192: + raise ValueError("favicon source dimensions are not supported") + + variants: Dict[str, str] = {} + png_icons: Dict[int, Image.Image] = {} + for size in WEBAPP_FAVICON_SIZES: + icon = _image_to_square_icon(source, size) + png_icons[size] = icon + filename = f"icon-{size}.png" + icon.save(target_dir / filename, format="PNG", optimize=True) + variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}" + + png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True) + variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png" + png_icons[32].save( + target_dir / "favicon.ico", + format="ICO", + sizes=[(16, 16), (32, 32), (48, 48)], + ) + variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico" + return { + "favicon_url": variants["180"], + "variants": variants, + } + + async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]: reader = await request.multipart() async for part in reader: @@ -169,13 +233,39 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon return _error(400, "invalid_payload", "url or file is required") body, detected_content_type, filename = await _fetch_logo_from_url(source_url) logo_url = _write_uploaded_logo(body, detected_content_type, filename) + try: + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError: + favicon_payload = {} except ValueError as exc: return _error(400, "invalid_logo", str(exc)) except OSError as exc: logger.exception("Failed to save uploaded webapp logo") return _error(500, "write_failed", str(exc)) - return _ok({"logo_url": logo_url}) + return _ok({"logo_url": logo_url, **favicon_payload}) + + +async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + content_type = (request.headers.get("Content-Type") or "").lower() + try: + if content_type.startswith("multipart/form-data"): + body, detected_content_type, filename = await _read_uploaded_logo_file(request) + else: + payload = await _read_json(request) + source_url = str(payload.get("url") or "").strip() + if not source_url: + return _error(400, "invalid_payload", "url or file is required") + body, detected_content_type, filename = await _fetch_logo_from_url(source_url) + favicon_payload = _write_favicon_set(body, detected_content_type, filename) + except ValueError as exc: + return _error(400, "invalid_favicon", str(exc)) + except OSError as exc: + logger.exception("Failed to save uploaded webapp favicon") + return _error(500, "write_failed", str(exc)) + + return _ok(favicon_payload) async def admin_themes_get_route(request: web.Request) -> web.Response: diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py index 031fbc5..457bc95 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/bot/app/web/admin_settings_manifest.py @@ -100,6 +100,9 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), + SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Использовать отдельную favicon"), + SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), + SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), # ─── Subscription periods & pricing ──────────────────────────── SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 2467319..022223c 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -251,6 +251,10 @@ emoji: brandEmoji, emojiFont: brandEmojiFont, }); + $: faviconBrand = normalizeBrand({ + ...brand, + logoUrl: String(CFG.faviconUrl || "").trim() || brand.logoUrl, + }); $: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans; $: methods = data?.payment_methods?.length ? data.payment_methods : []; $: appSettings = data?.settings || DEV_MOCK.data.settings; @@ -377,7 +381,7 @@ : telegramLoginUnavailable ? t("wa_auth_telegram_not_configured") : ""; - $: applyFavicon(brand); + $: applyFavicon(faviconBrand); $: syncBodyScrollLock( paymentModalOpen || changeModalOpen || @@ -885,6 +889,9 @@ "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_EMOJI", "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", ].some((key) => keys.has(key)); } @@ -987,6 +994,8 @@ onThemesSaved={handleAdminPersistedSaved} {brandTitle} {brand} + appFaviconUrl={CFG.faviconUrl} + appFaviconUseCustom={CFG.faviconUseCustom} appVersion={CFG.appVersion} appRepositoryUrl={CFG.appRepositoryUrl} {currentLang} diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 6bc0291..dac7d27 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -76,6 +76,8 @@ export let onThemesSaved = () => {}; export let brand = {}; export let brandTitle = "/minishop"; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; export let appVersion = "dev+local"; export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop"; export let currentLang = "ru"; @@ -647,7 +649,14 @@ {/if} {#if active === "appearance"} - + {/if} {#if active === "settings"} diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte index b5b7dab..a6766c1 100644 --- a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte @@ -16,9 +16,23 @@ export let currentLang = "ru"; export let onSettingsSaved = () => {}; export let brand = {}; + export let appFaviconUrl = ""; + export let appFaviconUseCustom = false; const settingsStore = getContext("settingsStore"); const themesStore = getContext("themesStore"); + const APPEARANCE_SETTING_KEYS = new Set([ + "WEBAPP_TITLE", + "WEBAPP_PRIMARY_COLOR", + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", + "WEBAPP_ENABLED", + ]); $: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore); $: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore); @@ -31,6 +45,25 @@ $: currentLogoUrl = !useEmojiLogo ? pendingLogoPreviewUrl || logoUrl || brand?.logoUrl || "" : ""; $: previewLogoUrl = logoPreviewNonce && currentLogoUrl ? withLogoCacheBust(currentLogoUrl) : currentLogoUrl; + $: persistedUseCustomFavicon = boolValue( + valueForKey("WEBAPP_FAVICON_USE_CUSTOM", appFaviconUseCustom) + ); + $: if ( + !Object.prototype.hasOwnProperty.call(settingsDirty, "WEBAPP_FAVICON_USE_CUSTOM") && + lastPersistedUseCustomFavicon !== persistedUseCustomFavicon + ) { + faviconUseCustomDraft = persistedUseCustomFavicon; + lastPersistedUseCustomFavicon = persistedUseCustomFavicon; + } + $: useCustomFavicon = faviconUseCustomDraft; + $: faviconUrl = valueForKey("WEBAPP_FAVICON_URL", appFaviconUrl); + $: logoFaviconUrl = valueForKey("WEBAPP_LOGO_FAVICON_URL"); + $: generatedFaviconUrl = !useEmojiLogo ? logoFaviconUrl || previewLogoUrl || "" : ""; + $: currentFaviconUrl = useCustomFavicon + ? pendingFaviconPreviewUrl || faviconUrl || "" + : generatedFaviconUrl; + $: previewFaviconUrl = + faviconPreviewNonce && currentFaviconUrl ? withCacheBust(currentFaviconUrl) : currentFaviconUrl; $: logoEmoji = valueForKey("WEBAPP_LOGO_EMOJI"); $: logoEmojiInput = useEmojiLogo ? logoEmoji : ""; $: logoEmojiPreview = logoEmoji || "🫥"; @@ -46,36 +79,51 @@ label: item.label, })); $: dirtyCount = Object.keys(settingsDirty || {}).filter((key) => - appearanceFields.some((field) => field.key === key) + isAppearanceSettingKey(key) ).length; $: appearanceDirtyKeys = Object.keys(settingsDirty || {}).filter((key) => - appearanceFields.some((field) => field.key === key) + isAppearanceSettingKey(key) ); let logoFileInput; + let faviconFileInput; let logoSourceUrl = ""; + let faviconSourceUrl = ""; let logoPreviewNonce = 0; + let faviconPreviewNonce = 0; let logoPreviewFailed = false; + let faviconPreviewFailed = false; let lastPreviewLogoUrl = ""; + let lastPreviewFaviconUrl = ""; + let lastPersistedUseCustomFavicon; + let faviconUseCustomDraft = false; let pendingLogoPreviewUrl = ""; + let pendingFaviconPreviewUrl = ""; let pendingObjectUrl = ""; + let pendingFaviconObjectUrl = ""; $: if (previewLogoUrl !== lastPreviewLogoUrl) { lastPreviewLogoUrl = previewLogoUrl; logoPreviewFailed = false; } - function valueFor(field) { - if (!field) return ""; - if (settingsDirty[field.key]?.deleted) return ""; - if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) { - return settingsDirty[field.key].value; - } - return field.value ?? ""; + $: if (previewFaviconUrl !== lastPreviewFaviconUrl) { + lastPreviewFaviconUrl = previewFaviconUrl; + faviconPreviewFailed = false; } - function valueForKey(key) { - return valueFor(fieldMap.get(key)); + function valueForKey(key, fallback = "") { + if (settingsDirty[key]?.deleted) return ""; + if (Object.prototype.hasOwnProperty.call(settingsDirty, key)) { + return settingsDirty[key].value; + } + const field = fieldMap.get(key); + if (!field) return fallback; + return field.value ?? fallback; + } + + function isAppearanceSettingKey(key) { + return APPEARANCE_SETTING_KEYS.has(key) || appearanceFields.some((field) => field.key === key); } function boolValue(value) { @@ -88,9 +136,13 @@ } function withLogoCacheBust(url) { + return withCacheBust(url, logoPreviewNonce); + } + + function withCacheBust(url, nonce) { if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url; const separator = url.includes("?") ? "&" : "?"; - return `${url}${separator}v=${logoPreviewNonce}`; + return `${url}${separator}v=${nonce}`; } function clearPendingObjectUrl() { @@ -100,6 +152,13 @@ pendingObjectUrl = ""; } + function clearPendingFaviconObjectUrl() { + if (pendingFaviconObjectUrl && typeof URL !== "undefined") { + URL.revokeObjectURL(pendingFaviconObjectUrl); + } + pendingFaviconObjectUrl = ""; + } + function setPendingLogoPreview(url, objectUrl = "") { clearPendingObjectUrl(); pendingObjectUrl = objectUrl; @@ -108,6 +167,14 @@ logoPreviewNonce = Date.now(); } + function setPendingFaviconPreview(url, objectUrl = "") { + clearPendingFaviconObjectUrl(); + pendingFaviconObjectUrl = objectUrl; + pendingFaviconPreviewUrl = url; + faviconPreviewFailed = false; + faviconPreviewNonce = Date.now(); + } + function themeTitle(theme) { return localizedThemeName(theme, currentLang) || "—"; } @@ -151,28 +218,77 @@ const objectUrl = URL.createObjectURL(file); setPendingLogoPreview(objectUrl, objectUrl); } - themesStore.uploadLogoFile(file).then((uploadedUrl) => { + themesStore.uploadLogoFile(file).then((uploaded) => { + const uploadedUrl = uploaded?.logoUrl || ""; if (!uploadedUrl) { pendingLogoPreviewUrl = ""; clearPendingObjectUrl(); return; } settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + if (uploaded?.faviconUrl) { + settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + } settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); if (logoFileInput) logoFileInput.value = ""; }); } function uploadLogoFromUrl() { - themesStore.uploadLogoUrl(logoSourceUrl).then((uploadedUrl) => { + themesStore.uploadLogoUrl(logoSourceUrl).then((uploaded) => { + const uploadedUrl = uploaded?.logoUrl || ""; if (!uploadedUrl) return; setPendingLogoPreview(uploadedUrl); logoSourceUrl = ""; settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + if (uploaded?.faviconUrl) { + settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + } settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); }); } + function handleFaviconFileChange(event) { + const file = event.currentTarget.files?.[0]; + if (!file) return; + if (typeof URL !== "undefined") { + const objectUrl = URL.createObjectURL(file); + setPendingFaviconPreview(objectUrl, objectUrl); + } + themesStore.uploadFaviconFile(file).then((uploaded) => { + const uploadedUrl = uploaded?.faviconUrl || ""; + if (!uploadedUrl) { + pendingFaviconPreviewUrl = ""; + clearPendingFaviconObjectUrl(); + return; + } + settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); + setCustomFavicon(true); + if (faviconFileInput) faviconFileInput.value = ""; + }); + } + + function uploadFaviconFromUrl() { + themesStore.uploadFaviconUrl(faviconSourceUrl).then((uploaded) => { + const uploadedUrl = uploaded?.faviconUrl || ""; + if (!uploadedUrl) return; + setPendingFaviconPreview(uploadedUrl); + faviconSourceUrl = ""; + settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); + setCustomFavicon(true); + }); + } + + function setCustomFavicon(enabled) { + const nextEnabled = Boolean(enabled); + faviconUseCustomDraft = nextEnabled; + settingsStore.markDirty("WEBAPP_FAVICON_USE_CUSTOM", nextEnabled); + if (!nextEnabled) { + pendingFaviconPreviewUrl = ""; + clearPendingFaviconObjectUrl(); + } + } + function setEmojiLogo(enabled) { settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", Boolean(enabled)); if (!enabled) { @@ -199,6 +315,9 @@ "WEBAPP_LOGO_USE_EMOJI", "WEBAPP_LOGO_EMOJI", "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_URL", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_LOGO_FAVICON_URL", ].includes(key) ); let settingsSaved = true; @@ -251,6 +370,7 @@ onDestroy(() => { clearPendingObjectUrl(); + clearPendingFaviconObjectUrl(); }); @@ -379,6 +499,74 @@
+ +
+
+ {#if previewFaviconUrl && !faviconPreviewFailed} + { + faviconPreviewFailed = true; + }} + /> + {:else if !useCustomFavicon && useEmojiLogo} + + {:else} + + {/if} +
+ +
+
+ + + faviconFileInput?.click()} + disabled={themesSaving} + > + + {at("appearance_favicon_upload_file", {}, "Загрузить favicon")} + +
+ + + {at("appearance_favicon_upload_url", {}, "По ссылке")} + +
+
+
+
@@ -548,6 +736,11 @@ align-items: stretch; } + .appearance-favicon-grid { + grid-template-columns: minmax(132px, 140px) minmax(0, 520px); + border-top: 1px solid var(--admin-border); + } + .appearance-logo-preview { display: inline-flex; align-items: center; @@ -564,6 +757,12 @@ background: color-mix(in srgb, var(--admin-surface-2) 54%, var(--admin-surface)); } + .appearance-favicon-preview { + width: 140px; + height: 140px; + max-width: 100%; + } + .appearance-logo-image { display: block; width: 100%; @@ -801,6 +1000,11 @@ width: min(164px, 100%); } + .appearance-favicon-preview { + width: min(140px, 100%); + height: auto; + } + .appearance-url-row, .appearance-emoji-grid { grid-template-columns: 1fr; diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js index 41c9f6a..9d07793 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/bot/app/web/frontend/src/lib/admin/stores/themesStore.js @@ -76,7 +76,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { "Логотип загружен. Сохраните изменения, чтобы применить его." ) ); - return data.logo_url || ""; + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; } flash( data?.message || @@ -106,7 +106,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { "Логотип загружен. Сохраните изменения, чтобы применить его." ) ); - return data.logo_url || ""; + return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; } flash( data?.message || @@ -119,6 +119,67 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { } } + async function uploadFaviconFile(file) { + if (!file) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const body = new FormData(); + body.append("file", file); + const data = await api("/admin/appearance/favicon", { + method: "POST", + body, + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена. Сохраните изменения, чтобы применить ее." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + + async function uploadFaviconUrl(url) { + const sourceUrl = String(url || "").trim(); + if (!sourceUrl) return null; + state.update((s) => ({ ...s, themesSaving: true })); + try { + const data = await api("/admin/appearance/favicon", { + method: "POST", + body: JSON.stringify({ url: sourceUrl }), + }); + if (data?.ok) { + flash( + at( + "appearance_favicon_uploaded_pending", + {}, + "Favicon загружена. Сохраните изменения, чтобы применить ее." + ) + ); + return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; + } + flash( + data?.message || + data?.error || + at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon") + ); + return null; + } finally { + state.update((s) => ({ ...s, themesSaving: false })); + } + } + function setCurrentTheme(key) { state.update((s) => ({ ...s, @@ -213,5 +274,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { toggleAdminUse, uploadLogoFile, uploadLogoUrl, + uploadFaviconFile, + uploadFaviconUrl, }; } diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/bot/app/web/frontend/src/lib/webapp/mockApi.js index 6a6f7d0..e81955d 100644 --- a/bot/app/web/frontend/src/lib/webapp/mockApi.js +++ b/bot/app/web/frontend/src/lib/webapp/mockApi.js @@ -220,7 +220,21 @@ export async function mockApi(path, options = {}, context = {}) { }; } if (path === "/admin/appearance/logo") { - return { ok: true, logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png" }; + return { + ok: true, + logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png", + favicon_url: "/webapp-favicon/0000000000000000/icon-180.png", + }; + } + if (path === "/admin/appearance/favicon") { + return { + ok: true, + favicon_url: "/webapp-favicon/1111111111111111/icon-180.png", + variants: { + "32": "/webapp-favicon/1111111111111111/icon-32.png", + apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png", + }, + }; } if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") { try { @@ -236,6 +250,15 @@ export async function mockApi(path, options = {}, context = {}) { if (updates.WEBAPP_LOGO_EMOJI_FONT) { DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT; } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) { + DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || ""; + } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) { + DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || ""; + } + if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) { + DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM); + } } catch (_e) { void _e; } @@ -282,6 +305,27 @@ export async function mockApi(path, options = {}, context = {}) { { value: "noto-color-animated", label: "Noto Color Emoji Animated" }, ], }, + { + key: "WEBAPP_FAVICON_USE_CUSTOM", + type: "bool", + section: "appearance", + label: "Custom favicon", + value: Boolean(DEV_MOCK.config.faviconUseCustom), + }, + { + key: "WEBAPP_FAVICON_URL", + type: "url", + section: "appearance", + label: "Favicon URL", + value: DEV_MOCK.config.faviconUrl || "", + }, + { + key: "WEBAPP_LOGO_FAVICON_URL", + type: "url", + section: "appearance", + label: "Logo favicon URL", + value: DEV_MOCK.config.faviconUrl || "", + }, ], }, ], diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/bot/app/web/frontend/src/lib/webapp/previewMock.js index 8eba8fb..c9609b9 100644 --- a/bot/app/web/frontend/src/lib/webapp/previewMock.js +++ b/bot/app/web/frontend/src/lib/webapp/previewMock.js @@ -30,6 +30,8 @@ export const DEV_MOCK = { logoUseEmoji: false, logoEmoji: "🫥", logoEmojiFont: "system", + faviconUrl: "", + faviconUseCustom: false, apiBase: "/api", supportUrl: "https://t.me/support", privacyPolicyUrl: "https://example.com/privacy", diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py index 8c94f0b..a148340 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/bot/app/web/webapp/_runtime.py @@ -68,6 +68,8 @@ WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" +WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" +WEBAPP_FAVICON_PATH = "/webapp-favicon" WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index c0916fd..cf8c770 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -142,6 +142,27 @@ def _resolve_webapp_logo_url(settings: Settings) -> str: return "" +def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: + raw_custom_url = (getattr(settings, "WEBAPP_FAVICON_URL", None) or "").strip() + raw_logo_favicon_url = (getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) or "").strip() + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False) and raw_custom_url: + return _resolve_webapp_asset_url(raw_custom_url) + if logo_url and raw_logo_favicon_url: + resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) + if resolved: + return resolved + return logo_url or "" + + +def _resolve_webapp_asset_url(raw_url: str) -> str: + parsed_url = urlsplit(raw_url) + if parsed_url.scheme in {"https", "http", "data"}: + return raw_url + if raw_url.startswith("/"): + return raw_url + return "" + + def _webapp_logo_cache_key(logo_url: str) -> str: return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() @@ -247,6 +268,47 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: return response +async def webapp_favicon_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + digest = str(request.match_info.get("digest") or "").strip().lower() + filename = str(request.match_info.get("filename") or "").strip() + if not re.fullmatch(r"[0-9a-f]{16}", digest): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + if not re.fullmatch( + r"(?:icon-(?:16|32|48|180|192|512)\.png|apple-touch-icon\.png|favicon\.(?:ico|svg))", + filename, + ): + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + root = WEBAPP_FAVICON_DIR.expanduser().resolve() + path = (root / digest / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_favicon_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_favicon_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_favicon_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + async def webapp_animated_emoji_route(request: web.Request) -> web.Response: codepoints = str(request.match_info.get("codepoints") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower() @@ -639,8 +701,10 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: cache = request.app["webapp_settings_cache"] now = time.monotonic() if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): + logo_url = _resolve_webapp_logo_url(settings) cache["data"] = { - "logo_url": _resolve_webapp_logo_url(settings), + "logo_url": logo_url, + "favicon_url": _resolve_webapp_favicon_url(settings, logo_url), "subscription_options": settings.subscription_options, "stars_subscription_options": settings.stars_subscription_options, "traffic_packages": settings.traffic_packages, @@ -806,6 +870,8 @@ async def index_route(request: web.Request) -> web.Response: "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), "logoEmoji": settings.WEBAPP_LOGO_EMOJI, "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "faviconUrl": cached["favicon_url"], + "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, @@ -848,6 +914,12 @@ async def index_route(request: web.Request) -> web.Response: WEBAPP_JS_PLACEHOLDER, f'', ) + favicon_markup = _favicon_head_markup(cached["favicon_url"]) + if favicon_markup: + html = html.replace( + '', + favicon_markup, + ) brand_asset_url = cached["logo_url"] if ( not brand_asset_url @@ -992,6 +1064,39 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: ) +def _favicon_head_markup(favicon_url: str) -> str: + href = str(favicon_url or "").strip() + if not href: + return "" + + escaped_href = html.escape(href, quote=True) + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/icon-(?:16|32|48|180|192|512)\.png", + href, + ) + if not match: + rel = "apple-touch-icon" if href.endswith(".png") else "icon" + return ( + f'\n' + f'' + ) + + digest = match.group(1) + base = f"{WEBAPP_FAVICON_PATH}/{digest}" + return "\n".join( + [ + ( + f'' + ), + f'', + f'', + f'', + f'', + ] + ) + + def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: start = html.find(start_marker) if start == -1: diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py index 478a3b5..d8a0b59 100644 --- a/bot/app/web/webapp/routes.py +++ b/bot/app/web/webapp/routes.py @@ -25,6 +25,10 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", webapp_uploaded_logo_route, ) + app.router.add_get( + rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}", + webapp_favicon_route, + ) app.router.add_get( r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", webapp_animated_emoji_route, diff --git a/config/settings.py b/config/settings.py index 13f6b24..99ea47a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -95,6 +95,9 @@ class WebAppSettings(BaseModel): logo_use_emoji: bool logo_emoji: str logo_emoji_font: str + favicon_use_custom: bool + favicon_url: Optional[str] + logo_favicon_url: Optional[str] session_ttl_seconds: int session_secret: str webhook_secret_token: str @@ -379,6 +382,9 @@ class Settings(BaseSettings): "noto-emoji, twemoji, openmoji, apple, segoe, noto-local" ), ) + WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) + WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) + WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) @@ -550,6 +556,9 @@ class Settings(BaseSettings): logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI, logo_emoji=self.WEBAPP_LOGO_EMOJI, logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT, + favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM, + favicon_url=self.WEBAPP_FAVICON_URL, + logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL, session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS, session_secret=self.WEBAPP_SESSION_SECRET, webhook_secret_token=self.WEBHOOK_SECRET_TOKEN, @@ -864,6 +873,21 @@ class Settings(BaseSettings): def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value): return "system" + @field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value): + return False + + @field_validator("WEBAPP_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_favicon_url_env(cls, _value): + return None + + @field_validator("WEBAPP_LOGO_FAVICON_URL", mode="before") + @classmethod + def ignore_deprecated_webapp_logo_favicon_url_env(cls, _value): + return None + @computed_field @property def referral_bonus_inviter(self) -> Dict[int, int]: diff --git a/requirements.txt b/requirements.txt index 9ce503e..55c818f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ sqlalchemy[asyncio]==2.0.49 asyncpg==0.31.0 aiocryptopay==0.4.8 PyJWT[crypto]==2.12.1 +Pillow==12.2.0 diff --git a/tests/test_settings.py b/tests/test_settings.py index 795e7ae..d3ec935 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -39,6 +39,9 @@ class SettingsTests(unittest.TestCase): WEBAPP_LOGO_USE_EMOJI=True, WEBAPP_LOGO_EMOJI="🔥", WEBAPP_LOGO_EMOJI_FONT="twemoji", + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="https://cdn.example.com/favicon.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", ) self.assertEqual(settings.WEBAPP_PRIMARY_COLOR, "#00fe7a") @@ -46,6 +49,9 @@ class SettingsTests(unittest.TestCase): self.assertFalse(settings.WEBAPP_LOGO_USE_EMOJI) self.assertEqual(settings.WEBAPP_LOGO_EMOJI, "🫥") self.assertEqual(settings.WEBAPP_LOGO_EMOJI_FONT, "system") + self.assertFalse(settings.WEBAPP_FAVICON_USE_CUSTOM) + self.assertIsNone(settings.WEBAPP_FAVICON_URL) + self.assertIsNone(settings.WEBAPP_LOGO_FAVICON_URL) def test_tariffs_config_missing_uses_legacy_fallback(self): settings = Settings( diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 2eb0ed7..bd53f4d 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -1,4 +1,5 @@ import asyncio +import io import json import os import tempfile @@ -8,7 +9,10 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +from PIL import Image + from bot.app.web import subscription_webapp +from bot.app.web.admin_api_impl import themes as admin_themes from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings from config.webapp_themes_config import builtin_webapp_themes_config @@ -110,6 +114,61 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(subscription_webapp._resolve_webapp_logo_url(settings), "") + def test_custom_webapp_favicon_takes_precedence(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=True, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/abcdef1234567890/icon-180.png", + ) + + def test_logo_generated_favicon_is_used_when_custom_disabled(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual( + subscription_webapp._resolve_webapp_favicon_url(settings, "/logo.png"), + "/webapp-favicon/1111111111111111/icon-180.png", + ) + + def test_logo_generated_favicon_is_not_used_without_logo(self): + settings = SimpleNamespace( + WEBAPP_FAVICON_USE_CUSTOM=False, + WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", + ) + + self.assertEqual(subscription_webapp._resolve_webapp_favicon_url(settings, ""), "") + + def test_favicon_head_markup_includes_touch_icon(self): + markup = subscription_webapp._favicon_head_markup( + "/webapp-favicon/abcdef1234567890/icon-180.png" + ) + + self.assertIn('rel="apple-touch-icon"', markup) + self.assertIn("/webapp-favicon/abcdef1234567890/icon-32.png", markup) + + def test_favicon_set_generation_writes_common_icon_sizes(self): + buffer = io.BytesIO() + Image.new("RGBA", (2, 2), (0, 254, 122, 255)).save(buffer, format="PNG") + png_body = buffer.getvalue() + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(admin_themes, "WEBAPP_FAVICON_DIR", Path(tmpdir)): + payload = admin_themes._write_favicon_set(png_body, "image/png", "icon.png") + + self.assertRegex(payload["favicon_url"], r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$") + digest = payload["favicon_url"].split("/")[2] + self.assertTrue((Path(tmpdir) / digest / "icon-32.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) + self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists()) + def test_initial_theme_head_markup_includes_css_and_tokens(self): cfg = builtin_webapp_themes_config("#123456") theme = cfg.theme_by_key("light") diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 8d3f85d..a511d9b 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -143,6 +143,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/api/admin/themes"): "admin_themes_get_route", ("PUT", "/api/admin/themes"): "admin_themes_save_route", ("POST", "/api/admin/appearance/logo"): "admin_appearance_logo_upload_route", + ("POST", "/api/admin/appearance/favicon"): "admin_appearance_favicon_upload_route", ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", } @@ -167,6 +168,19 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(match_info.handler.__name__, "index_route") + def test_webapp_favicon_asset_route_is_registered(self): + app = web.Application() + subscription_webapp.setup_subscription_webapp_routes(app) + + request = make_mocked_request( + "GET", + "/webapp-favicon/abcdef1234567890/icon-180.png", + app=app, + ) + match_info = asyncio.run(app.router.resolve(request)) + + self.assertEqual(match_info.handler.__name__, "webapp_favicon_route") + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self): From f80e336e1fc1af964fca50db1ce63dc68d1e6bb4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 21:46:10 +0300 Subject: [PATCH 07/45] feat: tune visual of windows 95 theme --- bot/app/web/themes/windows95/style.css | 259 ++++++++++++++++++------ bot/app/web/themes/windows95/theme.json | 2 +- bot/app/web/webapp/assets.py | 12 +- config/webapp_themes_config.py | 2 + tests/test_webapp_assets.py | 27 ++- tests/test_webapp_themes_config.py | 6 +- 6 files changed, 238 insertions(+), 70 deletions(-) diff --git a/bot/app/web/themes/windows95/style.css b/bot/app/web/themes/windows95/style.css index b9b66b7..d5aaede 100644 --- a/bot/app/web/themes/windows95/style.css +++ b/bot/app/web/themes/windows95/style.css @@ -41,51 +41,57 @@ --admin-text: #000000; --admin-muted: #202020; --admin-dim: #404040; - --win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=4"); - --win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=4"); - --win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=4"); - --win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=4"); - --win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=4"); - --win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=4"); - --win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=4"); - --win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=4"); - --win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=4"); - --win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=4"); - --win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=4"); - --win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=4"); - --win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=4"); - --win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=4"); - --win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=4"); - --win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=4"); - --win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=4"); - --win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=4"); - --win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=4"); - --win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=4"); - --win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=4"); - --win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=4"); - --win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=4"); - --win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=4"); - --win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=4"); - --win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=4"); - --win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=4"); - --win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=4"); - --win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=4"); - --win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=4"); - --win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=4"); - --win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=4"); - --win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=4"); - --win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=4"); - --win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=4"); - --win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=4"); - --win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=4"); - --win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=4"); - --win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=4"); + --win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png?v=6"); + --win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png?v=6"); + --win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png?v=6"); + --win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png?v=6"); + --win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png?v=6"); + --win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png?v=6"); + --win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png?v=6"); + --win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png?v=6"); + --win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png?v=6"); + --win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png?v=6"); + --win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png?v=6"); + --win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png?v=6"); + --win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png?v=6"); + --win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png?v=6"); + --win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png?v=6"); + --win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png?v=6"); + --win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png?v=6"); + --win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png?v=6"); + --win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png?v=6"); + --win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png?v=6"); + --win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png?v=6"); + --win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png?v=6"); + --win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png?v=6"); + --win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png?v=6"); + --win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png?v=6"); + --win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png?v=6"); + --win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png?v=6"); + --win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png?v=6"); + --win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png?v=6"); + --win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png?v=6"); + --win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png?v=6"); + --win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png?v=6"); + --win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png?v=6"); + --win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png?v=6"); + --win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png?v=6"); + --win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png?v=6"); + --win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png?v=6"); + --win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png?v=6"); + --win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png?v=6"); } .theme-key-windows95.app-shell { background: var(--bg) !important; } +.theme-key-windows95 *, +.theme-key-windows95 *::before, +.theme-key-windows95 *::after { + border-radius: 0 !important; +} + .theme-key-windows95 svg.lucide-arrow-left, .theme-key-windows95 svg.lucide-arrow-right, .theme-key-windows95 svg.lucide-bitcoin, @@ -456,6 +462,24 @@ 2px 2px 0 #000000; } +body:has(.theme-key-windows95) .language-select-content, +body:has(.theme-key-windows95) .admin-select-content { + border-width: 2px; + border-style: solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080, + 2px 2px 0 #000000; +} + +body:has(.theme-key-windows95) .language-select-item, +body:has(.theme-key-windows95) .admin-select-item { + border-radius: 0 !important; +} + .theme-key-windows95 .card::before, .theme-key-windows95 .dialog-card::before { content: ""; @@ -555,23 +579,18 @@ } .theme-key-windows95 .progress { - height: 14px; - border: 2px solid; - border-color: #404040 #ffffff #ffffff #404040; - border-radius: 0; - background: #ffffff; + height: 8px; + margin-top: 12px; + overflow: hidden; + border: 0; + border-radius: 999px !important; + background: color-mix(in srgb, var(--muted) 18%, transparent); } .theme-key-windows95 .progress span { - border-radius: 0; - background: repeating-linear-gradient( - 90deg, - var(--accent) 0, - var(--accent) 8px, - #ffffff 8px, - #ffffff 10px - ); - box-shadow: none; + border-radius: inherit !important; + background: linear-gradient(90deg, var(--accent), #1084d0); + box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 30%, transparent); } .theme-key-windows95 .language-select-item { @@ -726,17 +745,132 @@ .theme-key-windows95 .admin-textarea, .theme-key-windows95 .admin-screen-wrap textarea, .theme-key-windows95 .admin-dialog textarea, -.theme-key-windows95 .input { +.theme-key-windows95 .input, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), +.theme-key-windows95 textarea, +.theme-key-windows95 select { + min-height: 28px; + padding: 3px 6px; border: 2px solid; border-color: #404040 #ffffff #ffffff #404040; border-radius: 0; background: #ffffff; color: #000000; + font: 13px "MS Sans Serif", Tahoma, "Segoe UI", sans-serif; + line-height: 1.35; + box-shadow: + inset 1px 1px 0 #000000, + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; + transition: none; +} + +.theme-key-windows95 input[type="color"] { + min-height: 28px; + padding: 2px; +} + +.theme-key-windows95 input[type="file"] { + background: var(--panel); +} + +.theme-key-windows95 input[type="range"] { + width: 100%; + height: 22px; + padding: 0; + border: 0; + background: transparent; + accent-color: var(--accent); + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]::-webkit-slider-runnable-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #dfdfdf; } +.theme-key-windows95 input[type="range"]::-webkit-slider-thumb { + width: 14px; + height: 20px; + margin-top: -9px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; + -webkit-appearance: none; + appearance: none; +} + +.theme-key-windows95 input[type="range"]:active::-webkit-slider-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-track { + height: 6px; + border: 2px solid; + border-color: #404040 #ffffff #ffffff #404040; + border-radius: 0 !important; + background: #ffffff; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]::-moz-range-progress { + height: 6px; + border-radius: 0 !important; + background: var(--accent); +} + +.theme-key-windows95 input[type="range"]::-moz-range-thumb { + width: 14px; + height: 20px; + border: 2px solid; + border-color: #ffffff #404040 #404040 #ffffff; + border-radius: 0 !important; + background: #c0c0c0; + box-shadow: + inset 1px 1px 0 #dfdfdf, + inset -1px -1px 0 #808080; +} + +.theme-key-windows95 input[type="range"]:active::-moz-range-thumb { + border-color: #404040 #ffffff #ffffff #404040; + box-shadow: + inset 1px 1px 0 #808080, + inset -1px -1px 0 #dfdfdf; +} + +.theme-key-windows95 input[type="range"]:focus-visible { + outline: 1px dotted #000000; + outline-offset: 2px; +} + +.theme-key-windows95 input[type="range"]:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.theme-key-windows95 input::placeholder, +.theme-key-windows95 textarea::placeholder, +.theme-key-windows95 .input::placeholder { + color: #808080; +} + .theme-key-windows95 .admin-cn-badge, .theme-key-windows95 .admin-badge { border-radius: 0; @@ -763,7 +897,10 @@ .theme-key-windows95 .admin-textarea:focus, .theme-key-windows95 .admin-screen-wrap textarea:focus, .theme-key-windows95 .admin-dialog textarea:focus, -.theme-key-windows95 .input:focus { +.theme-key-windows95 .input:focus, +.theme-key-windows95 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus, +.theme-key-windows95 textarea:focus, +.theme-key-windows95 select:focus { outline: none; border-color: #404040 #ffffff #ffffff #404040; box-shadow: @@ -871,14 +1008,10 @@ background: var(--panel); } -.theme-key-windows95 .progress span { - background: repeating-linear-gradient( - 90deg, - var(--accent) 0, - var(--accent) 6px, - #1084d0 6px, - #1084d0 8px - ); +.theme-key-windows95 .ui-spinner, +.theme-key-windows95 .telegram-button-spinner { + border-radius: 999px !important; + color: var(--accent); } .theme-key-windows95 .card::before, diff --git a/bot/app/web/themes/windows95/theme.json b/bot/app/web/themes/windows95/theme.json index 4631f6a..73583f0 100644 --- a/bot/app/web/themes/windows95/theme.json +++ b/bot/app/web/themes/windows95/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": false, "use_in_admin": true, "css_file": "style.css", - "assets_version": 5, + "assets_version": 6, "tokens": { "color_scheme": "light", "style_preset": "win95" diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py index cf8c770..ca3beee 100644 --- a/bot/app/web/webapp/assets.py +++ b/bot/app/web/webapp/assets.py @@ -118,8 +118,13 @@ async def theme_asset_route(request: web.Request) -> web.Response: if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: raise web.HTTPNotFound(text="theme_asset_not_found") + query = getattr(request, "query", {}) 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" + if query.get("v") + else "public, max-age=3600" + ) return response @@ -1058,10 +1063,11 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: href = _theme_css_href_for_html(theme) if not href: return style_tag - return ( + stylesheet = ( f'\n' + style_tag + f'data-initial-theme-css="{html.escape(str(theme.key), quote=True)}">' ) + return stylesheet + "\n" + style_tag def _favicon_head_markup(favicon_url: str) -> str: diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py index 495cdeb..8346c5e 100644 --- a/config/webapp_themes_config.py +++ b/config/webapp_themes_config.py @@ -375,6 +375,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: or "lucide-earth" not in style or "lucide-circle-check" not in style or "border-radius: 0 !important" not in style + or "::-webkit-slider-thumb" not in style + or "?v=6" not in style or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) ) diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index bd53f4d..664ae12 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -163,7 +163,10 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): with patch.object(admin_themes, "WEBAPP_FAVICON_DIR", Path(tmpdir)): payload = admin_themes._write_favicon_set(png_body, "image/png", "icon.png") - self.assertRegex(payload["favicon_url"], r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$") + self.assertRegex( + payload["favicon_url"], + r"^/webapp-favicon/[0-9a-f]{16}/icon-180\.png$", + ) digest = payload["favicon_url"].split("/")[2] self.assertTrue((Path(tmpdir) / digest / "icon-32.png").exists()) self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) @@ -390,6 +393,28 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600") self.assertEqual(response.body, b"png-bytes") + async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom" / "icons").mkdir(parents=True) + (themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes") + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/icons/save.png"}, + query={"v": "6"}, + ) + + response = await subscription_webapp.theme_asset_route(request) + + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self): with tempfile.TemporaryDirectory() as tmpdir: request = SimpleNamespace( diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index a643603..9e430c7 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -33,7 +33,7 @@ class WebappThemesConfigTests(unittest.TestCase): self.assertEqual(win95.tokens.style_preset, "win95") self.assertFalse(win95.use_primary_accent) self.assertTrue(win95.use_in_admin) - self.assertEqual(win95.assets_version, 5) + self.assertEqual(win95.assets_version, 6) ascii_theme = cfg.theme_by_key("ascii") self.assertIsNotNone(ascii_theme) self.assertEqual(ascii_theme.css_file, "style.css") @@ -380,13 +380,15 @@ class WebappThemesConfigTests(unittest.TestCase): descriptor["assets_version"], cfg.theme_by_key("windows95").assets_version, ) - self.assertEqual(descriptor["assets_version"], 5) + self.assertEqual(descriptor["assets_version"], 6) self.assertIn("lucide-house", css) self.assertIn("lucide-earth", css) self.assertIn("lucide-circle-check", css) self.assertIn("lucide-circle-check-big", css) self.assertIn("filter: none !important", css) self.assertIn("Press Start 2P", css) + self.assertIn("::-webkit-slider-thumb", css) + self.assertIn("?v=6", css) self.assertIn(".theme-key-windows95 .traffic-top strong", css) self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists()) From fc8e573243b5b330e8028b17bc3e19773e028296 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 23:01:23 +0300 Subject: [PATCH 08/45] feat: tune light theme, reduce accent color usage overall --- .../patterns/admin/AdminRevenueChart.svelte | 11 ++- .../web/frontend/src/lib/webapp/themeStyle.js | 15 +++ .../frontend/src/styles/admin-controls.css | 40 ++++---- bot/app/web/frontend/src/styles/admin.css | 24 ++--- bot/app/web/frontend/src/styles/base.css | 15 +++ .../web/frontend/src/styles/components.css | 12 +-- bot/app/web/frontend/src/styles/webapp.css | 24 ++--- bot/app/web/themes/light/style.css | 93 ++++++++++++++++++- bot/app/web/themes/light/theme.json | 2 +- config/webapp_themes_config.py | 17 +++- 10 files changed, 194 insertions(+), 59 deletions(-) diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte index 8bd3798..1f50900 100644 --- a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte +++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte @@ -24,7 +24,8 @@ function readCssColor(name, fallback) { if (typeof document === "undefined") return fallback; - const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const scope = hostEl || document.documentElement; + const raw = getComputedStyle(scope).getPropertyValue(name).trim(); return raw || fallback; } @@ -65,7 +66,11 @@ const muted = readCssColor("--admin-muted", "#9aa7a2"); const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)"); const accent = readCssColor("--accent", "#00fe7a"); - const lineStroke = readCssColor("--admin-text", "#e8f0ec"); + const lineStroke = readCssColor( + "--admin-chart-stroke", + readCssColor("--admin-text", "#e8f0ec"), + ); + const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)"); return { width: w, @@ -94,7 +99,7 @@ stroke: lineStroke, width: 2, cap: "round", - fill: "rgba(120, 140, 132, 0.14)", + fill: lineFill, }, ], axes: [ diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/bot/app/web/frontend/src/lib/webapp/themeStyle.js index c415cd0..15fe42d 100644 --- a/bot/app/web/frontend/src/lib/webapp/themeStyle.js +++ b/bot/app/web/frontend/src/lib/webapp/themeStyle.js @@ -12,6 +12,21 @@ const TOKEN_TO_CSS_VAR = { muted: "--muted", dim: "--dim", danger: "--danger", + danger_text: "--danger-text", + danger_soft: "--danger-soft", + danger_border: "--danger-border", + success: "--success", + success_text: "--success-text", + success_soft: "--success-soft", + success_border: "--success-border", + warning: "--warning", + warning_text: "--warning-text", + warning_soft: "--warning-soft", + warning_border: "--warning-border", + info: "--info", + info_text: "--info-text", + info_soft: "--info-soft", + info_border: "--info-border", blue: "--blue", radius: "--radius", font_sans: "--font-sans", diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/bot/app/web/frontend/src/styles/admin-controls.css index e69029f..3f22104 100644 --- a/bot/app/web/frontend/src/styles/admin-controls.css +++ b/bot/app/web/frontend/src/styles/admin-controls.css @@ -113,7 +113,7 @@ .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } @@ -129,31 +129,31 @@ } .admin-btn.admin-btn-ghost:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); color: var(--admin-text); } .admin-btn.admin-btn-danger { - background: color-mix(in srgb, #ff5757 80%, #000); + background: color-mix(in srgb, var(--danger) 80%, #000); color: #fff; - border-color: color-mix(in srgb, #ff5757 60%, #000); + border-color: color-mix(in srgb, var(--danger) 60%, #000); font-weight: 600; } .admin-btn.admin-btn-danger:hover:not(:disabled) { - background: #ff5757; - border-color: #ff5757; + background: var(--danger); + border-color: var(--danger); } .admin-btn.admin-btn-danger-soft { - color: #ffb4b4; + color: var(--danger-text); background: var(--admin-surface-2); - border-color: color-mix(in srgb, #ff6b6b 30%, transparent); + border-color: var(--danger-border); } .admin-btn.admin-btn-danger-soft:hover:not(:disabled) { - background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); - border-color: color-mix(in srgb, #ff6b6b 50%, transparent); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 50%, transparent); } .admin-btn.admin-btn-icon { @@ -183,21 +183,21 @@ } .admin-badge.admin-badge-success { - border-color: color-mix(in srgb, var(--accent) 36%, transparent); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); + border-color: var(--success-border); + color: var(--success-text); + background: var(--success-soft); } .admin-badge.admin-badge-danger { - border-color: color-mix(in srgb, #ff6b6b 32%, transparent); - color: #ffb4b4; - background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); + border-color: var(--danger-border); + color: var(--danger-text); + background: var(--danger-soft); } .admin-badge.admin-badge-warning { - border-color: color-mix(in srgb, #ffd166 32%, transparent); - color: #ffd166; - background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); + border-color: var(--warning-border); + color: var(--warning-text); + background: var(--warning-soft); } .admin-badge.admin-badge-muted { @@ -270,7 +270,7 @@ .admin-extend-control .admin-btn.admin-btn-primary { background: var(--accent); - color: #02110a; + color: var(--accent-contrast); border-color: color-mix(in srgb, var(--accent) 70%, #000); font-weight: 600; } diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css index 821ab64..df35dd5 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/bot/app/web/frontend/src/styles/admin.css @@ -546,11 +546,11 @@ } .admin-revenue-kpi-growth.is-up { - color: color-mix(in srgb, #3ecf8e 92%, var(--admin-text)); + color: var(--success-text); } .admin-revenue-kpi-growth.is-down { - color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text)); + color: var(--danger-text); } .admin-revenue-chart { @@ -2088,11 +2088,11 @@ } .admin-traffic-card-premium { - border-color: color-mix(in srgb, var(--accent) 30%, var(--admin-border)); + border-color: var(--info-border); } .admin-traffic-card-warning { - border-color: color-mix(in srgb, #ffb454 46%, var(--admin-border)); + border-color: var(--warning-border); } .admin-traffic-head, @@ -2134,11 +2134,11 @@ } .admin-traffic-bar-premium span { - background: linear-gradient(90deg, #66e3ff, var(--accent)); + background: linear-gradient(90deg, var(--info), color-mix(in srgb, var(--info) 64%, #ffffff)); } .admin-traffic-card-warning .admin-traffic-bar span { - background: linear-gradient(90deg, #ffb454, #ff6b6b); + background: linear-gradient(90deg, var(--warning), var(--danger)); } .admin-traffic-meta { @@ -2656,8 +2656,8 @@ } .admin-switch-root[data-state="checked"] { - background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); - border-color: color-mix(in srgb, var(--accent) 50%, transparent); + background: color-mix(in srgb, var(--success) 82%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--success) 52%, transparent); } .admin-switch-root:focus-visible { @@ -3080,9 +3080,9 @@ } .admin-danger-zone { - border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); + border: 1px solid var(--danger-border); border-radius: 12px; - background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); + background: var(--danger-soft); padding: 14px 16px; display: flex; flex-direction: column; @@ -3099,13 +3099,13 @@ .admin-danger-zone-head strong { font-size: 13px; font-weight: 700; - color: #ffb4b4; + color: var(--danger-text); text-transform: uppercase; letter-spacing: 0.04em; } .admin-danger-zone-head small { - color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); + color: color-mix(in srgb, var(--danger-text) 70%, var(--admin-muted)); font-size: 12px; line-height: 1.45; } diff --git a/bot/app/web/frontend/src/styles/base.css b/bot/app/web/frontend/src/styles/base.css index 6a5b977..a0015cf 100644 --- a/bot/app/web/frontend/src/styles/base.css +++ b/bot/app/web/frontend/src/styles/base.css @@ -24,6 +24,21 @@ --muted: #a9b4b0; --dim: #68736f; --danger: #ff6b6b; + --danger-text: #ffb4b4; + --danger-soft: color-mix(in srgb, var(--danger) 12%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #3ecf8e; + --success-text: #7ff0b9; + --success-soft: color-mix(in srgb, var(--success) 12%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #ffd166; + --warning-text: #ffd166; + --warning-soft: color-mix(in srgb, var(--warning) 12%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2d9cff; + --info-text: #8ed0ff; + --info-soft: color-mix(in srgb, var(--info) 12%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 34%, var(--border)); --blue: #2d9cff; --radius: 8px; --accent-contrast: #03100a; diff --git a/bot/app/web/frontend/src/styles/components.css b/bot/app/web/frontend/src/styles/components.css index 62df485..bc75e07 100644 --- a/bot/app/web/frontend/src/styles/components.css +++ b/bot/app/web/frontend/src/styles/components.css @@ -171,15 +171,15 @@ } .admin-cn-badge-destructive { - background: color-mix(in srgb, var(--danger) 16%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--danger) 35%, var(--admin-border)); - color: color-mix(in srgb, var(--danger) 92%, var(--admin-text)); + background: var(--danger-soft); + border-color: var(--danger-border); + color: var(--danger-text); } .admin-cn-badge-success { - background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface)); - border-color: color-mix(in srgb, var(--accent) 32%, var(--admin-border)); - color: color-mix(in srgb, var(--accent) 88%, var(--admin-text)); + background: var(--success-soft); + border-color: var(--success-border); + color: var(--success-text); } .admin-cn-badge-muted { diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css index 9b7d6dd..62a9001 100644 --- a/bot/app/web/frontend/src/styles/webapp.css +++ b/bot/app/web/frontend/src/styles/webapp.css @@ -574,8 +574,8 @@ a { .device-disconnect-button, .device-danger-button { - border-color: color-mix(in srgb, var(--danger) 62%, var(--border)); - color: #ffb8b8; + border-color: var(--danger-border); + color: var(--danger-text); } .devices-empty-card { @@ -957,7 +957,7 @@ a { .tariff-warning-card { justify-content: flex-start; - color: var(--warning, #ffd166); + color: var(--warning-text); } .confirm-summary-card { @@ -1067,10 +1067,10 @@ a { .field-error-tooltip { z-index: 120; max-width: 280px; - border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); + border: 1px solid var(--danger-border); border-radius: 8px; - background: #2a1010; - color: #ffd8d8; + background: var(--danger-soft); + color: var(--danger-text); padding: 8px 10px; font-size: 12px; line-height: 1.3; @@ -1286,16 +1286,16 @@ a { .settings-row-linked { grid-template-columns: 28px minmax(0, 1fr); - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - background: color-mix(in srgb, var(--accent) 11%, var(--surface-muted)); + border-color: var(--success-border); + background: var(--success-soft); } .settings-row-linked > svg:first-child { - color: var(--accent); + color: var(--success-text); } .settings-row-linked strong { - color: var(--accent); + color: var(--success-text); } .emoji-flag { @@ -1772,7 +1772,7 @@ a { } .status-line { - color: var(--accent); + color: var(--success-text); font-size: 12px; line-height: 1.4; } @@ -1800,7 +1800,7 @@ a { bottom: 16px; z-index: 120; max-width: min(420px, calc(100vw - 32px)); - border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border)); + border: 1px solid var(--border-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--panel) 92%, transparent); box-shadow: var(--shadow-popover); diff --git a/bot/app/web/themes/light/style.css b/bot/app/web/themes/light/style.css index 1317e33..25f6148 100644 --- a/bot/app/web/themes/light/style.css +++ b/bot/app/web/themes/light/style.css @@ -11,6 +11,21 @@ --muted: #475569; --dim: #64748b; --danger: #dc2626; + --danger-text: #b91c1c; + --danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel)); + --danger-border: color-mix(in srgb, var(--danger) 34%, var(--border)); + --success: #16a34a; + --success-text: #166534; + --success-soft: color-mix(in srgb, var(--success) 10%, var(--panel)); + --success-border: color-mix(in srgb, var(--success) 34%, var(--border)); + --warning: #d97706; + --warning-text: #92400e; + --warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel)); + --warning-border: color-mix(in srgb, var(--warning) 34%, var(--border)); + --info: #2563eb; + --info-text: #1d4ed8; + --info-soft: color-mix(in srgb, var(--info) 9%, var(--panel)); + --info-border: color-mix(in srgb, var(--info) 30%, var(--border)); --blue: #2563eb; --radius: 8px; --accent-contrast: #ffffff; @@ -22,9 +37,9 @@ --overlay-scrim: rgba(15, 23, 42, 0.34); --nav-bg: rgba(255, 255, 255, 0.88); --rail-bg: rgba(255, 255, 255, 0.72); - --shadow-soft: 0 16px 38px rgba(15, 23, 42, 0.08); - --shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.16); - --shadow-popover: 0 18px 34px rgba(15, 23, 42, 0.14); + --shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06); + --shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12); + --shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12); --inset-highlight: rgba(255, 255, 255, 0.75); --admin-bg: #f7f8fb; --admin-surface: #ffffff; @@ -34,7 +49,33 @@ --admin-border-strong: rgba(15, 23, 42, 0.18); --admin-text: #0f172a; --admin-muted: #64748b; - --admin-dim: #94a3b8; + --admin-dim: #64748b; + --admin-chart-stroke: #065f46; + --admin-chart-fill: rgba(6, 95, 70, 0.22); +} + +.theme-key-light .ui-spinner, +.theme-key-light .brand-mark-spinner { + color: inherit; +} + +.theme-key-light .telegram-button-spinner { + border-color: rgba(255, 255, 255, 0.35); + border-top-color: #ffffff; +} + +.theme-key-light .btn-primary, +.theme-key-light .admin-btn.admin-btn-primary, +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary { + background: color-mix(in srgb, var(--accent) 50%, #000000); + border-color: color-mix(in srgb, var(--accent) 42%, #000000); + color: #ffffff; +} + +.theme-key-light .btn-primary:hover:not(:disabled), +.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled), +.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 52%, #000000); } .theme-key-light.app-shell { @@ -44,3 +85,47 @@ .theme-key-light .phone-screen { background: var(--bg); } + +/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */ +.theme-key-light .settings-row { + background: var(--panel); + box-shadow: none; +} + +.theme-key-light .settings-row-linked { + background: var(--success-soft); +} + +/* Avatar/profile card: bigger lift, but rows below have an opaque background and + stack above, so the shadow stays visually under them instead of bleeding through. */ +.theme-key-light .settings-profile { + box-shadow: + 0 10px 24px rgba(15, 23, 42, 0.10), + inset 0 1px 0 var(--inset-highlight); +} + +.theme-key-light .settings-links-block { + position: relative; + z-index: 1; +} + +/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */ +.theme-key-light .admin-revenue-svg-frame { + background: #ffffff; +} + +/* Bonus section: drop accent color from body strongs; only the bonus-system heading + and explicitly-accent card headings stay tinted — and they use the same darkened + accent technique as .btn-primary on light, so they remain readable on white. */ +.theme-key-light .bonus-card strong { + color: var(--text); +} + +.theme-key-light .bonus-card-head strong, +.theme-key-light .card-heading-accent { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} + +.theme-key-light .bonus-card-head > svg { + color: color-mix(in srgb, var(--accent) 50%, #000000); +} diff --git a/bot/app/web/themes/light/theme.json b/bot/app/web/themes/light/theme.json index a228433..9713516 100644 --- a/bot/app/web/themes/light/theme.json +++ b/bot/app/web/themes/light/theme.json @@ -9,7 +9,7 @@ "use_primary_accent": true, "use_in_admin": true, "css_file": "style.css", - "assets_version": 1, + "assets_version": 2, "tokens": { "color_scheme": "light" } diff --git a/config/webapp_themes_config.py b/config/webapp_themes_config.py index 8346c5e..e6cc7f7 100644 --- a/config/webapp_themes_config.py +++ b/config/webapp_themes_config.py @@ -33,6 +33,21 @@ class ThemeTokens(BaseModel): muted: Optional[str] = None dim: Optional[str] = None danger: Optional[str] = None + danger_text: Optional[str] = None + danger_soft: Optional[str] = None + danger_border: Optional[str] = None + success: Optional[str] = None + success_text: Optional[str] = None + success_soft: Optional[str] = None + success_border: Optional[str] = None + warning: Optional[str] = None + warning_text: Optional[str] = None + warning_soft: Optional[str] = None + warning_border: Optional[str] = None + info: Optional[str] = None + info_text: Optional[str] = None + info_soft: Optional[str] = None + info_border: Optional[str] = None blue: Optional[str] = None radius: Optional[str] = None font_sans: Optional[str] = None @@ -350,7 +365,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool: except OSError: return True if key == "light": - return ".theme-key-light.app-shell" not in style + return "--success-text" not in style or ".theme-key-light.app-shell" not in style if key == "ascii": return ( ".theme-key-ascii" not in style From b72a23f1e146639762527f5db3f5ee4289ca099e Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 23:18:24 +0300 Subject: [PATCH 09/45] refactor: parallelize tariff worker panel calls and unblock startup sync --- bot/handlers/admin/sync_admin.py | 31 +++++++++ bot/main_bot.py | 49 ++++++++++---- bot/services/tariff_worker.py | 108 +++++++++++++++++++------------ 3 files changed, 135 insertions(+), 53 deletions(-) diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 19eacfd..5de5b62 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -1,3 +1,4 @@ +import asyncio import logging from datetime import datetime, timezone from typing import Optional, Union @@ -16,6 +17,11 @@ from db.models import Subscription router = Router(name="admin_sync_router") +# Single-flight guard: panel sync runs concurrently with the bot, but only one +# sync at a time. Overlapping callers (startup, /sync, admin API) return early +# instead of queueing behind the running sync. +_sync_lock = asyncio.Lock() + def _normalize_panel_email(value: Optional[str]) -> Optional[str]: email = (value or "").strip().lower() @@ -122,6 +128,31 @@ async def perform_sync( session: AsyncSession, settings: Settings, i18n_instance: JsonI18n, +) -> dict: + """Single-flight entry point — skips when another sync is already running.""" + if _sync_lock.locked(): + logging.info("perform_sync: skipped because another sync is already in progress") + return { + "status": "skipped", + "details": "Another sync run is already in progress.", + "errors": [], + "users_processed": 0, + "subs_synced": 0, + } + async with _sync_lock: + return await _perform_sync_impl( + panel_service=panel_service, + session=session, + settings=settings, + i18n_instance=i18n_instance, + ) + + +async def _perform_sync_impl( + panel_service: PanelApiService, + session: AsyncSession, + settings: Settings, + i18n_instance: JsonI18n, ) -> dict: """ Perform panel synchronization and return results diff --git a/bot/main_bot.py b/bot/main_bot.py index 9c802ca..b8b9647 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -130,31 +130,54 @@ async def on_startup_configured(dispatcher: Dispatcher): except Exception: logging.exception("STARTUP: Failed to initialize message queue manager.") - # Automatic sync on startup + # Automatic sync on startup — runs in background so the dispatcher can + # start serving Telegram webhooks immediately even if the panel is slow. + # perform_sync is single-flight, so concurrent admin-triggered runs will + # be skipped while this one is in progress. try: - logging.info("STARTUP: Running automatic panel sync...") + logging.info("STARTUP: Scheduling automatic panel sync in background...") + asyncio.create_task( + _background_startup_sync( + panel_service=panel_service, + session_factory=async_session_factory, + settings=settings, + i18n_instance=i18n_instance, + ), + name="StartupPanelSync", + ) + except Exception: + logging.exception("STARTUP: Failed to schedule automatic sync.") - async with async_session_factory() as session: + logging.info("STARTUP: Bot on_startup_configured completed.") + + +async def _background_startup_sync( + *, + panel_service: PanelApiService, + session_factory: sessionmaker, + settings: Settings, + i18n_instance: JsonI18n, +) -> None: + try: + async with session_factory() as session: sync_result = await perform_sync( panel_service=panel_service, session=session, settings=settings, i18n_instance=i18n_instance, ) - - if sync_result.get("status") == "completed": - logging.info( - f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" # noqa: E501 - ) + status = sync_result.get("status") + details = sync_result.get("details", "N/A") + if status == "completed": + logging.info(f"STARTUP: Background sync completed successfully. Details: {details}") + elif status == "skipped": + logging.info(f"STARTUP: Background sync skipped: {details}") else: logging.warning( - f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" # noqa: E501 + f"STARTUP: Background sync finished with status '{status}'. Details: {details}" ) - except Exception: - logging.exception("STARTUP: Failed to run automatic sync.") - - logging.info("STARTUP: Bot on_startup_configured completed.") + logging.exception("STARTUP: Background sync failed.") async def on_shutdown_configured(dispatcher: Dispatcher): diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 7132252..c0e2351 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -23,6 +23,11 @@ PREMIUM_WARNING_LEVEL_OFFSET = 1000 # Single warning per premium billing period when usage reached or exceeded the quota. PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100 +# Process active subscriptions in chunks and prefetch panel data concurrently +# to avoid an N+1 serial chain to the Remnawave panel each tick. +TARIFF_WORKER_BATCH_SIZE = 50 +TARIFF_WORKER_PANEL_CONCURRENCY = 10 + class TariffTrafficWorker: def __init__( @@ -117,48 +122,71 @@ class TariffTrafficWorker: Subscription.tariff_key.is_not(None), ) ) - for sub in result.scalars().all(): - try: - tariff = self.settings.tariffs_config.require(sub.tariff_key) - except Exception: - continue - panel_data = ( - await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) - or {} - ) - used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details( - panel_data - ) - panel_status = str(panel_data.get("status") or "").upper() - panel_username = panel_data.get("username") if isinstance(panel_data, dict) else None - if used is not None and used != sub.traffic_used_bytes: - sub.traffic_used_bytes = used - if limit is not None and limit != sub.traffic_limit_bytes: - sub.traffic_limit_bytes = limit - if panel_status and panel_status != (sub.status_from_panel or "").upper(): - sub.status_from_panel = panel_status + subs = list(result.scalars().all()) + if not subs: + return - if tariff.billing_model == "period": - await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) - await self._maybe_warn_or_throttle( - session, - sub, - tariff, - used, - limit, - warning_period_start=warning_period_start - if tariff.billing_model == "period" - else None, - ) + semaphore = asyncio.Semaphore(TARIFF_WORKER_PANEL_CONCURRENCY) - await self._sync_premium_squad_limit( - session, - sub, - tariff, - now, - panel_username=panel_username, - panel_user_dict=panel_data, - ) + async def _fetch_panel(sub: Subscription) -> dict: + async with semaphore: + try: + data = await self.panel_service.get_user_by_uuid( + sub.panel_user_uuid, log_response=False + ) + except Exception: + logging.exception( + "TariffTrafficWorker: failed to fetch panel user %s", + sub.panel_user_uuid, + ) + return {} + return data or {} + + for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE): + chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE] + panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk)) + for sub, panel_data in zip(chunk, panel_payloads): + try: + tariff = self.settings.tariffs_config.require(sub.tariff_key) + except Exception: + continue + ( + used, + limit, + panel_strategy, + ) = self.subscription_service._extract_panel_traffic_details(panel_data) + panel_status = str(panel_data.get("status") or "").upper() + panel_username = ( + panel_data.get("username") if isinstance(panel_data, dict) else None + ) + if used is not None and used != sub.traffic_used_bytes: + sub.traffic_used_bytes = used + if limit is not None and limit != sub.traffic_limit_bytes: + sub.traffic_limit_bytes = limit + if panel_status and panel_status != (sub.status_from_panel or "").upper(): + sub.status_from_panel = panel_status + + if tariff.billing_model == "period": + await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) + await self._maybe_warn_or_throttle( + session, + sub, + tariff, + used, + limit, + warning_period_start=warning_period_start + if tariff.billing_model == "period" + else None, + ) + + await self._sync_premium_squad_limit( + session, + sub, + tariff, + now, + panel_username=panel_username, + panel_user_dict=panel_data, + ) async def _ensure_period_reset_strategy( self, From aeb51b9ccc534cc1aba9b758dac7fbe3b3c83cb5 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 23:57:39 +0300 Subject: [PATCH 10/45] refactor: add indexes on payments(status, created_at) and message_logs(timestamp) --- db/migrator.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/db/migrator.py b/db/migrator.py index f721d60..cc7c926 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -729,6 +729,21 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> ) +def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> None: + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_payments_status_created_at " + "ON payments (status, created_at)" + ) + ) + connection.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_message_logs_timestamp " + "ON message_logs (timestamp DESC)" + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -846,6 +861,11 @@ MIGRATIONS: List[Migration] = [ description="Admin toggle for effectively unlimited main traffic limit", upgrade=_migration_0021_add_regular_unlimited_override, ), + Migration( + id="0022_add_indexes_for_admin_reports", + description="Indexes to speed up financial stats and admin log queries", + upgrade=_migration_0022_add_indexes_for_admin_reports, + ), ] From a05c54fc666ed92a172f500c79c53d5138e365c8 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 23:59:05 +0300 Subject: [PATCH 11/45] refactor: tighten panel API timeouts and retry safe requests once on transient errors --- bot/services/panel_api_service.py | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 21ba81e..ee7cbb2 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -15,6 +15,12 @@ from db.models import PanelSyncStatus class PanelApiService: + # Status codes returned by _request_once for failures we consider transient + # (connect error, request timeout) and therefore worth retrying on safe methods. + _TRANSIENT_STATUS_CODES = (-1, -3) + _SAFE_METHODS = frozenset({"GET", "HEAD"}) + _RETRY_BACKOFF_SECONDS = 0.5 + def __init__(self, settings: Settings): self.settings = settings self.base_url = settings.PANEL_API_URL @@ -32,7 +38,14 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - timeout = aiohttp.ClientTimeout(total=30) + # Separate connect/read timeouts so a stuck panel does not hold a + # bot worker for the full window; total caps worst-case latency. + timeout = aiohttp.ClientTimeout( + total=15, + connect=3, + sock_connect=3, + sock_read=10, + ) self._session = aiohttp.ClientSession(timeout=timeout) return self._session @@ -58,8 +71,31 @@ class PanelApiService: headers["Authorization"] = f"Bearer {self.api_key}" return headers + def _is_transient_error(self, result: Optional[Dict[str, Any]]) -> bool: + if not isinstance(result, dict) or not result.get("error"): + return False + code = result.get("status_code") + if code in self._TRANSIENT_STATUS_CODES: + return True + return isinstance(code, int) and 500 <= code < 600 + async def _request( self, method: str, endpoint: str, log_full_response: bool = False, **kwargs + ) -> Optional[Dict[str, Any]]: + # Retry safe (idempotent) methods once on transient failures to absorb + # network blips and short panel restarts without surfacing errors. + max_attempts = 2 if method.upper() in self._SAFE_METHODS else 1 + result: Optional[Dict[str, Any]] = None + for attempt in range(max_attempts): + result = await self._request_once(method, endpoint, log_full_response, **kwargs) + if attempt + 1 < max_attempts and self._is_transient_error(result): + await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) + continue + return result + return result + + async def _request_once( + self, method: str, endpoint: str, log_full_response: bool = False, **kwargs ) -> Optional[Dict[str, Any]]: if not self.base_url: logging.error("Panel API URL (PANEL_API_URL) not configured in settings.") From 1e97dd9fe5a6f524fde6237450a443ce7da17d77 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Fri, 15 May 2026 23:59:49 +0300 Subject: [PATCH 12/45] refactor: add per-chat throttle in message queue to avoid 429 on rapid same-chat sends --- bot/utils/message_queue.py | 53 ++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py index d0088c7..28fea39 100644 --- a/bot/utils/message_queue.py +++ b/bot/utils/message_queue.py @@ -27,6 +27,11 @@ class QueuedMessage: class MessageQueue: """Message queue with rate limiting for Telegram API""" + # Telegram allows ~1 message/sec to the same chat before returning 429. + PER_CHAT_MIN_INTERVAL_SECONDS = 1.0 + # Drop per-chat timestamps older than this to keep the dict bounded. + PER_CHAT_TTL_SECONDS = 5.0 + def __init__(self, messages_per_second: float, burst_size: int = 5): self.messages_per_second = messages_per_second self.burst_size = burst_size @@ -36,6 +41,7 @@ class MessageQueue: self.delay_between_messages = 1.0 / messages_per_second self.total_sent = 0 self.total_failed = 0 + self._chat_last_sent: Dict[int, datetime] = {} async def add_message(self, message: QueuedMessage) -> None: """Add message to queue""" @@ -52,14 +58,14 @@ class MessageQueue: try: while self.queue: - # Check if we need to wait - await self._wait_if_needed() + # Peek at the head to honor per-chat throttling before popping. + message = self.queue[0] + await self._wait_if_needed(message.chat_id) + self.queue.popleft() - # Get and process next message - message = self.queue.popleft() try: await self._send_message(message) - self._record_send_time() + self._record_send_time(message.chat_id) except TelegramBadRequest as exc: fallback_message = self._build_profile_link_fallback(message, exc) @@ -72,7 +78,7 @@ class MessageQueue: ) try: await self._send_message(fallback_message) - self._record_send_time() + self._record_send_time(message.chat_id) continue except Exception as retry_exc: self.total_failed += 1 @@ -91,19 +97,27 @@ class MessageQueue: finally: self.is_processing = False - async def _wait_if_needed(self) -> None: - """Wait if we need to respect rate limits""" - if not self.last_send_times: - return + async def _wait_if_needed(self, chat_id: Optional[int] = None) -> None: + """Wait if we need to respect global and per-chat rate limits.""" + now = datetime.now() + waits: list[float] = [] - # Calculate time since last message - time_since_last = (datetime.now() - self.last_send_times[-1]).total_seconds() + if self.last_send_times: + time_since_last = (now - self.last_send_times[-1]).total_seconds() + if time_since_last < self.delay_between_messages: + waits.append(self.delay_between_messages - time_since_last) - if time_since_last < self.delay_between_messages: - wait_time = self.delay_between_messages - time_since_last - await asyncio.sleep(wait_time) + if chat_id is not None: + last_chat = self._chat_last_sent.get(chat_id) + if last_chat is not None: + time_since_chat = (now - last_chat).total_seconds() + if time_since_chat < self.PER_CHAT_MIN_INTERVAL_SECONDS: + waits.append(self.PER_CHAT_MIN_INTERVAL_SECONDS - time_since_chat) - def _record_send_time(self) -> None: + if waits: + await asyncio.sleep(max(waits)) + + def _record_send_time(self, chat_id: Optional[int] = None) -> None: """Track sent message timestamps and purge old entries for rate limiting.""" now = datetime.now() self.last_send_times.append(now) @@ -113,6 +127,13 @@ class MessageQueue: while self.last_send_times and self.last_send_times[0] < cutoff_time: self.last_send_times.popleft() + if chat_id is not None: + self._chat_last_sent[chat_id] = now + chat_cutoff = now - timedelta(seconds=self.PER_CHAT_TTL_SECONDS) + stale = [cid for cid, ts in self._chat_last_sent.items() if ts < chat_cutoff] + for cid in stale: + self._chat_last_sent.pop(cid, None) + def _build_profile_link_fallback( self, message: QueuedMessage, exc: Exception ) -> Optional[QueuedMessage]: From 87f9e23bae37db23e0089256b052df4a3c79df2d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 00:01:40 +0300 Subject: [PATCH 13/45] refactor: cache panel squad and host lookups in-memory with TTL --- bot/services/panel_api_service.py | 31 +++++++++++++++++ bot/utils/ttl_cache.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 bot/utils/ttl_cache.py diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index ee7cbb2..d450c89 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -9,6 +9,7 @@ from urllib.parse import urlencode import aiohttp from sqlalchemy.ext.asyncio import AsyncSession +from bot.utils.ttl_cache import AsyncTTLCache from config.settings import Settings from db.dal import panel_sync_dal from db.models import PanelSyncStatus @@ -27,6 +28,10 @@ class PanelApiService: self.api_key = settings.PANEL_API_KEY self._session: Optional[aiohttp.ClientSession] = None self.default_client_ip = "127.0.0.1" + # Cache slow-changing reference data fetched from the panel. Errors and + # None responses are not cached, so transient failures self-heal. + self._squads_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) + self._hosts_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) async def __aenter__(self): """Context manager entry""" @@ -604,7 +609,13 @@ class PanelApiService: ) return None + def _invalidate_squad_caches(self) -> None: + self._squads_cache.invalidate() + async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: + return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + + async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: response = response_data.get("response") @@ -619,6 +630,12 @@ class PanelApiService: return None async def get_internal_squad(self, squad_uuid: str) -> Optional[Dict[str, Any]]: + return await self._squads_cache.get_or_load( + f"detail:{squad_uuid}", + lambda: self._get_internal_squad_uncached(squad_uuid), + ) + + async def _get_internal_squad_uncached(self, squad_uuid: str) -> Optional[Dict[str, Any]]: response_data = await self._request( "GET", f"/internal-squads/{squad_uuid}", log_full_response=False ) @@ -639,6 +656,15 @@ class PanelApiService: async def get_internal_squad_accessible_nodes( self, squad_uuid: str, + ) -> Optional[List[Dict[str, Any]]]: + return await self._squads_cache.get_or_load( + f"nodes:{squad_uuid}", + lambda: self._get_internal_squad_accessible_nodes_uncached(squad_uuid), + ) + + async def _get_internal_squad_accessible_nodes_uncached( + self, + squad_uuid: str, ) -> Optional[List[Dict[str, Any]]]: endpoints = ( f"/internal-squads/{squad_uuid}/accessible-nodes", @@ -665,6 +691,9 @@ class PanelApiService: return None async def get_hosts(self) -> Optional[List[Dict[str, Any]]]: + return await self._hosts_cache.get_or_load("list", self._get_hosts_uncached) + + async def _get_hosts_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/hosts", log_full_response=False) if response_data and not response_data.get("error") and "response" in response_data: response = response_data.get("response") @@ -695,6 +724,7 @@ class PanelApiService: log_full_response=False, ) if response_data and not response_data.get("error"): + self._invalidate_squad_caches() return True logging.error("Failed to add users to squad %s. Response: %s", squad_uuid, response_data) return False @@ -710,6 +740,7 @@ class PanelApiService: log_full_response=False, ) if response_data and not response_data.get("error"): + self._invalidate_squad_caches() return True logging.error( "Failed to remove users from squad %s. Response: %s", squad_uuid, response_data diff --git a/bot/utils/ttl_cache.py b/bot/utils/ttl_cache.py new file mode 100644 index 0000000..bf5bd1f --- /dev/null +++ b/bot/utils/ttl_cache.py @@ -0,0 +1,55 @@ +import asyncio +import time +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + + +class AsyncTTLCache: + """In-memory async-safe TTL cache with single-flight loader. + + Concurrent get_or_load() calls for the same key share one loader execution. + """ + + def __init__(self, ttl_seconds: float): + self.ttl_seconds = ttl_seconds + self._data: Dict[str, Tuple[float, Any]] = {} + self._locks: Dict[str, asyncio.Lock] = {} + + def _is_fresh(self, expires_at: float) -> bool: + return time.monotonic() < expires_at + + def get_fresh(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + expires_at, value = entry + if not self._is_fresh(expires_at): + return None + return value + + @staticmethod + def _is_cacheable(value: Any) -> bool: + if value is None: + return False + if isinstance(value, dict) and value.get("error"): + return False + return True + + async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any: + cached = self.get_fresh(key) + if cached is not None: + return cached + lock = self._locks.setdefault(key, asyncio.Lock()) + async with lock: + cached = self.get_fresh(key) + if cached is not None: + return cached + value = await loader() + if self._is_cacheable(value): + self._data[key] = (time.monotonic() + self.ttl_seconds, value) + return value + + def invalidate(self, key: Optional[str] = None) -> None: + if key is None: + self._data.clear() + return + self._data.pop(key, None) From 55dce99d3424c3180f02014884a4190f1f2f80d7 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 00:02:22 +0300 Subject: [PATCH 14/45] refactor: ack panel webhooks immediately and run event handling in bounded background task --- bot/services/panel_webhook_service.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 30c7be3..63be592 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import hmac import json @@ -29,6 +30,10 @@ EVENT_MAP = { class PanelWebhookService: + # Cap parallel background event handlers so an expiry burst from the panel + # cannot exhaust the DB pool or the YooKassa client. + _MAX_CONCURRENT_EVENTS = 50 + def __init__( self, bot: Bot, @@ -42,6 +47,7 @@ class PanelWebhookService: self.i18n = i18n self.async_session_factory = async_session_factory self.panel_service = panel_service + self._event_semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_EVENTS) if not self.settings.PANEL_WEBHOOK_SECRET: logging.error( "PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected." @@ -243,9 +249,24 @@ class PanelWebhookService: telegram_id if telegram_id is not None else "N/A", ) - await self.handle_event(event_name, user_data) + # Acknowledge immediately so the panel does not retry while we run + # auto-renew charges, DB lookups and SMTP. Heavy work is bounded by a + # semaphore to survive expiry bursts. + asyncio.create_task( + self._run_event_in_background(event_name, user_data), + name=f"panel_event_{event_name}", + ) return web.Response(status=200, text="ok") + async def _run_event_in_background(self, event_name: str, user_payload: dict) -> None: + async with self._event_semaphore: + try: + await self.handle_event(event_name, user_payload) + except Exception: + logging.exception( + "Panel webhook background handler failed for event %s", event_name + ) + async def panel_webhook_route(request: web.Request): service: PanelWebhookService = request.app["panel_webhook_service"] From 3298dc3e778eb754d64162343af219d2b2bf5397 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 00:04:42 +0300 Subject: [PATCH 15/45] test: align CSP expectation with current img-src that allows blob --- tests/test_security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_security.py b/tests/test_security.py index 7242b94..7459ad9 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -373,7 +373,7 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): csp = response.headers["Content-Security-Policy"] self.assertNotIn("'unsafe-eval'", csp) - self.assertIn("img-src 'self' data: https:;", csp) + self.assertIn("img-src 'self' data: blob: https:;", csp) self.assertNotIn("img-src 'self' data: https: http:;", csp) From 387bdb6abcaf95034527dea88125753acca319db Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 00:09:37 +0300 Subject: [PATCH 16/45] fix: stop logging full panel response bodies by default to avoid secret leakage --- bot/services/panel_api_service.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index d450c89..eb1d666 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -235,7 +235,7 @@ class PanelApiService: return all_users async def get_user_by_uuid( - self, user_uuid: str, log_response: bool = True + self, user_uuid: str, log_response: bool = False ) -> Optional[Dict[str, Any]]: endpoint = f"/users/{user_uuid}" full_response = await self._request("GET", endpoint, log_full_response=log_response) @@ -251,7 +251,7 @@ class PanelApiService: telegram_id: Optional[int] = None, username: Optional[str] = None, email: Optional[str] = None, - log_response: bool = True, + log_response: bool = False, ) -> Optional[Dict[str, Any]]: if uuid: return await self.get_user_by_uuid(uuid, log_response=log_response) @@ -271,7 +271,7 @@ class PanelApiService: telegram_id: Optional[int] = None, username: Optional[str] = None, email: Optional[str] = None, - log_response: bool = True, + log_response: bool = False, ) -> Optional[List[Dict[str, Any]]]: response_data = None @@ -348,7 +348,7 @@ class PanelApiService: description: Optional[str] = None, tag: Optional[str] = None, status: str = "ACTIVE", - log_response: bool = True, + log_response: bool = False, ) -> Optional[Dict[str, Any]]: username_is_valid = ( @@ -434,7 +434,7 @@ class PanelApiService: return None async def update_user_status_on_panel( - self, user_uuid: str, enable: bool, log_response: bool = True + self, user_uuid: str, enable: bool, log_response: bool = False ) -> bool: action = "enable" if enable else "disable" endpoint = f"/users/{user_uuid}/actions/{action}" @@ -459,7 +459,7 @@ class PanelApiService: ) return False - async def delete_user_from_panel(self, user_uuid: str, log_response: bool = True) -> bool: + async def delete_user_from_panel(self, user_uuid: str, log_response: bool = False) -> bool: """Delete a user from the panel. Treat not-found as already deleted.""" endpoint = f"/users/{user_uuid}" response_data = await self._request("DELETE", endpoint, log_full_response=log_response) From e0b521803790e18dee61d8bbcebeb17c14ffacee Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 00:13:15 +0300 Subject: [PATCH 17/45] chore: print remnawave-minishop ascii banner on startup --- main.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/main.py b/main.py index e102253..1791056 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,30 @@ from bot.main_bot import run_bot from config.settings import get_settings from db.database_setup import init_db, init_db_connection +_STARTUP_BANNER = r""" + ~ ~ ~ r e m n a w a v e ~ ~ ~ + + ███╗ ███╗██╗███╗ ██╗██╗███████╗██╗ ██╗ ██████╗ ██████╗ + ████╗ ████║██║████╗ ██║██║██╔════╝██║ ██║██╔═══██╗██╔══██╗ + ██╔████╔██║██║██╔██╗ ██║██║███████╗███████║██║ ██║██████╔╝ + ██║╚██╔╝██║██║██║╚██╗██║██║╚════██║██╔══██║██║ ██║██╔═══╝ + ██║ ╚═╝ ██║██║██║ ╚████║██║███████║██║ ██║╚██████╔╝██║ + ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ + + https://github.com/3252a8/remnawave-minishop +""" + + +def _print_startup_banner() -> None: + # Bypass the logging formatter so the ASCII art renders without per-line + # timestamp/level prefixes mangling the box characters. + try: + sys.stdout.write(_STARTUP_BANNER + "\n") + sys.stdout.flush() + except Exception: + # Banner is purely cosmetic; never fail startup over it. + pass + def _resolve_log_level(value: str) -> int: return getattr(logging, value.upper(), logging.INFO) @@ -28,6 +52,7 @@ async def main(): if __name__ == "__main__": + _print_startup_banner() load_dotenv() logging.basicConfig( level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), From 30fb774d933e8c65a41697f4abc92a61828b1963 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sat, 16 May 2026 21:48:15 +0300 Subject: [PATCH 18/45] refactor: project architecture refactor, container splitting --- .dockerignore | 12 +- .env.example | 17 +- .gitignore | 5 +- Caddyfile | 10 - Dockerfile | 96 ---- README.md | 20 +- backend/app_logging.py | 12 + .../bot}/app/controllers/__init__.py | 0 .../app/controllers/dispatcher_controller.py | 11 +- .../bot}/app/factories/__init__.py | 0 .../bot}/app/factories/build_services.py | 0 {bot => backend/bot}/app/web/__init__.py | 0 {bot => backend/bot}/app/web/admin_api.py | 0 .../bot}/app/web/admin_api_impl/__init__.py | 0 .../bot}/app/web/admin_api_impl/_runtime.py | 1 + .../bot}/app/web/admin_api_impl/ads.py | 0 .../bot}/app/web/admin_api_impl/auth.py | 0 .../bot}/app/web/admin_api_impl/broadcast.py | 0 .../bot}/app/web/admin_api_impl/common.py | 0 .../bot}/app/web/admin_api_impl/logs.py | 0 .../bot}/app/web/admin_api_impl/panel.py | 0 .../bot}/app/web/admin_api_impl/payments.py | 0 .../bot}/app/web/admin_api_impl/promos.py | 0 .../bot}/app/web/admin_api_impl/routes.py | 0 .../bot}/app/web/admin_api_impl/settings.py | 3 + .../bot}/app/web/admin_api_impl/stats.py | 0 backend/bot/app/web/admin_api_impl/sync.py | 16 + .../bot}/app/web/admin_api_impl/tariffs.py | 0 .../bot}/app/web/admin_api_impl/themes.py | 142 +++++- .../bot}/app/web/admin_api_impl/users.py | 0 .../bot}/app/web/admin_settings_manifest.py | 7 +- {bot => backend/bot}/app/web/session.py | 0 .../bot}/app/web/subscription_webapp.py | 0 .../web/templates/subscription_webapp.html | 1 - .../bot}/app/web/themes/ascii/style.css | 0 .../bot}/app/web/themes/ascii/theme.json | 0 .../bot}/app/web/themes/dark/theme.json | 0 .../bot}/app/web/themes/light/style.css | 0 .../bot}/app/web/themes/light/theme.json | 0 .../web/themes/windows95/icons/arrow-left.png | Bin .../themes/windows95/icons/arrow-right.png | Bin .../web/themes/windows95/icons/bitcoin.png | Bin .../app/web/themes/windows95/icons/check.png | Bin .../web/themes/windows95/icons/chevrons.png | Bin .../app/web/themes/windows95/icons/coins.png | Bin .../web/themes/windows95/icons/dashboard.png | Bin .../web/themes/windows95/icons/database.png | Bin .../web/themes/windows95/icons/download.png | Bin .../app/web/themes/windows95/icons/error.png | Bin .../web/themes/windows95/icons/file-text.png | Bin .../app/web/themes/windows95/icons/folder.png | Bin .../app/web/themes/windows95/icons/gift.png | Bin .../app/web/themes/windows95/icons/globe.png | Bin .../app/web/themes/windows95/icons/help.png | Bin .../app/web/themes/windows95/icons/home.png | Bin .../app/web/themes/windows95/icons/info.png | Bin .../app/web/themes/windows95/icons/key.png | Bin .../app/web/themes/windows95/icons/lock.png | Bin .../web/themes/windows95/icons/megaphone.png | Bin .../web/themes/windows95/icons/paintbrush.png | Bin .../themes/windows95/icons/payment-card.png | Bin .../app/web/themes/windows95/icons/print.png | Bin .../web/themes/windows95/icons/refresh.png | Bin .../app/web/themes/windows95/icons/save.png | Bin .../app/web/themes/windows95/icons/search.png | Bin .../app/web/themes/windows95/icons/send.png | Bin .../web/themes/windows95/icons/settings.png | Bin .../app/web/themes/windows95/icons/shield.png | Bin .../web/themes/windows95/icons/sliders.png | Bin .../web/themes/windows95/icons/smartphone.png | Bin .../web/themes/windows95/icons/sparkles.png | Bin .../app/web/themes/windows95/icons/tag.png | Bin .../app/web/themes/windows95/icons/ticket.png | Bin .../app/web/themes/windows95/icons/trash.png | Bin .../app/web/themes/windows95/icons/user.png | Bin .../app/web/themes/windows95/icons/users.png | Bin .../web/themes/windows95/icons/warning.png | Bin .../bot}/app/web/themes/windows95/icons/x.png | Bin .../bot}/app/web/themes/windows95/style.css | 0 .../bot}/app/web/themes/windows95/theme.json | 0 {bot => backend/bot}/app/web/web_server.py | 17 +- .../bot}/app/web/webapp/__init__.py | 0 .../bot}/app/web/webapp/_runtime.py | 6 +- .../bot}/app/web/webapp/account.py | 6 + .../bot}/app/web/webapp/application.py | 0 {bot => backend/bot}/app/web/webapp/assets.py | 201 +++++--- {bot => backend/bot}/app/web/webapp/auth.py | 0 .../bot}/app/web/webapp/billing.py | 0 {bot => backend/bot}/app/web/webapp/common.py | 0 .../bot}/app/web/webapp/devices.py | 0 .../bot}/app/web/webapp/payloads.py | 0 {bot => backend/bot}/app/web/webapp/routes.py | 1 + .../bot}/app/web/webapp/serializers.py | 0 {bot => backend/bot}/app/web/webapp_auth.py | 0 {bot => backend/bot}/filters/admin_filter.py | 0 .../bot}/handlers/admin/__init__.py | 0 {bot => backend/bot}/handlers/admin/ads.py | 0 .../bot}/handlers/admin/broadcast.py | 0 {bot => backend/bot}/handlers/admin/common.py | 0 .../bot}/handlers/admin/logs_admin.py | 0 .../bot}/handlers/admin/payments.py | 0 .../bot}/handlers/admin/promo/__init__.py | 0 .../bot}/handlers/admin/promo/bulk.py | 0 .../bot}/handlers/admin/promo/create.py | 0 .../bot}/handlers/admin/promo/manage.py | 0 .../bot}/handlers/admin/statistics.py | 0 .../bot}/handlers/admin/sync_admin.py | 0 .../bot}/handlers/admin/user_management.py | 47 +- {bot => backend/bot}/handlers/inline_mode.py | 0 .../bot}/handlers/user/__init__.py | 0 {bot => backend/bot}/handlers/user/payment.py | 17 + .../bot}/handlers/user/promo_user.py | 0 .../bot}/handlers/user/referral.py | 0 {bot => backend/bot}/handlers/user/start.py | 0 .../handlers/user/subscription/__init__.py | 0 .../bot}/handlers/user/subscription/core.py | 0 .../user/subscription/payment_methods.py | 0 .../handlers/user/subscription/payments.py | 0 .../user/subscription/payments_crypto.py | 0 .../user/subscription/payments_freekassa.py | 0 .../user/subscription/payments_platega.py | 0 .../user/subscription/payments_severpay.py | 0 .../user/subscription/payments_stars.py | 0 .../subscription/payments_subscription.py | 0 .../user/subscription/payments_yookassa.py | 0 .../bot}/handlers/user/trial_handler.py | 0 backend/bot/infra/redis.py | 113 +++++ backend/bot/infra/webhook_queue.py | 63 +++ .../bot}/keyboards/inline/admin_keyboards.py | 0 .../bot}/keyboards/inline/user_keyboards.py | 0 {bot => backend/bot}/main_bot.py | 55 +-- .../middlewares/action_logger_middleware.py | 0 .../bot}/middlewares/ban_check_middleware.py | 0 .../bot}/middlewares/channel_subscription.py | 0 .../bot}/middlewares/db_session.py | 0 {bot => backend/bot}/middlewares/i18n.py | 0 .../bot}/middlewares/profile_sync.py | 0 {bot => backend/bot}/routers.py | 0 .../bot}/services/crypto_pay_service.py | 0 .../bot}/services/email_auth_service.py | 0 .../bot}/services/email_templates.py | 0 .../bot}/services/freekassa_service.py | 0 {bot => backend/bot}/services/lknpd_client.py | 0 .../bot}/services/lknpd_service.py | 0 .../bot}/services/notification_service.py | 0 .../bot}/services/panel_api_service.py | 21 +- .../bot}/services/panel_webhook_service.py | 20 +- .../bot}/services/platega_service.py | 0 .../bot}/services/promo_code_service.py | 0 .../bot}/services/referral_service.py | 0 .../services/settings_override_service.py | 105 ++++ .../bot}/services/severpay_service.py | 0 .../bot}/services/stars_service.py | 0 .../bot}/services/subscription_service.py | 0 .../subscription_service_impl/__init__.py | 0 .../subscription_service_impl/_runtime.py | 0 .../subscription_service_impl/core.py | 0 .../subscription_service_impl/devices.py | 0 .../subscription_service_impl/lifecycle.py | 0 .../panel_identity.py | 0 .../subscription_service_impl/payments.py | 0 .../subscription_service_impl/renewal.py | 0 .../subscription_service_impl/tariffs.py | 0 .../subscription_service_impl/traffic.py | 0 .../subscription_service_impl/trial.py | 1 - .../bot}/services/tariff_worker.py | 49 +- .../bot}/services/yookassa_service.py | 0 {bot => backend/bot}/states/admin_states.py | 0 {bot => backend/bot}/states/user_states.py | 0 {bot => backend/bot}/utils/__init__.py | 0 {bot => backend/bot}/utils/callback_answer.py | 0 {bot => backend/bot}/utils/config_link.py | 0 {bot => backend/bot}/utils/date_utils.py | 0 {bot => backend/bot}/utils/message_queue.py | 0 {bot => backend/bot}/utils/mini_app_url.py | 0 .../bot}/utils/request_security.py | 0 {bot => backend/bot}/utils/telegram_markup.py | 0 {bot => backend/bot}/utils/text_sanitizer.py | 0 {bot => backend/bot}/utils/ttl_cache.py | 24 +- {config => backend/config}/settings.py | 15 + {config => backend/config}/tariffs_config.py | 0 .../config}/webapp_themes_config.py | 0 {db => backend/db}/dal/__init__.py | 0 {db => backend/db}/dal/ad_dal.py | 0 {db => backend/db}/dal/app_settings_dal.py | 0 {db => backend/db}/dal/message_log_dal.py | 0 {db => backend/db}/dal/panel_sync_dal.py | 0 {db => backend/db}/dal/payment_dal.py | 0 {db => backend/db}/dal/promo_code_dal.py | 0 {db => backend/db}/dal/security_dal.py | 0 {db => backend/db}/dal/subscription_dal.py | 16 +- {db => backend/db}/dal/tariff_dal.py | 0 {db => backend/db}/dal/user_billing_dal.py | 0 {db => backend/db}/dal/user_dal.py | 0 {db => backend/db}/database_setup.py | 6 +- {db => backend/db}/migrator.py | 0 {db => backend/db}/models.py | 0 backend/main.py | 36 ++ backend/main_backend.py | 28 ++ backend/main_migrate.py | 27 ++ backend/main_worker.py | 189 ++++++++ requirements.txt => backend/requirements.txt | 1 + backend/startup_banner.py | 109 +++++ bot/app/web/admin_api_impl/sync.py | 23 - bot/app/web/frontend/src/main.js | 10 - deploy/compose/docker-compose-caddy.yml | 157 ++++++ .../compose/docker-compose-remote-server.yml | 136 ++++++ deploy/docker/Dockerfile | 82 ++++ deploy/docker/caddy/Caddyfile | 11 + deploy/docker/frontend/00-startup-banner.sh | 23 + deploy/docker/frontend/nginx.conf | 57 +++ docker-compose-caddy.yml | 62 --- docker-compose-remote-server.yml | 45 -- docker-compose.yml | 159 +++++-- docs/admin.md | 2 +- docs/architecture.md | 53 +++ docs/configuration.md | 4 +- docs/deployment.md | 448 ++++++++---------- docs/migration-to-minishop.md | 19 +- docs/webapp-themes.md | 6 +- docs/webapp.md | 46 +- .prettierignore => frontend/.prettierignore | 0 .prettierrc.json => frontend/.prettierrc.json | 0 components.json => frontend/components.json | 2 +- .../eslint.config.mjs | 8 +- .../web/frontend => frontend}/jsconfig.json | 0 .../package-lock.json | 0 frontend/package.json | 40 ++ .../scripts}/build_subscription_webapp_js.mjs | 12 +- .../web/frontend => frontend}/src/App.svelte | 0 .../src/PreviewBoard.svelte | 0 .../src/admin/AdminPanel.svelte | 0 .../src/admin/sections/AdsSection.svelte | 0 .../admin/sections/AppearanceSection.svelte | 22 +- .../admin/sections/BroadcastSection.svelte | 0 .../src/admin/sections/LogsSection.svelte | 0 .../src/admin/sections/PaymentsSection.svelte | 0 .../src/admin/sections/PromosSection.svelte | 0 .../src/admin/sections/SettingsSection.svelte | 0 .../src/admin/sections/StatsSection.svelte | 0 .../admin/sections/TariffEditorModal.svelte | 0 .../src/admin/sections/TariffsSection.svelte | 0 .../src/admin/sections/UserDetailModal.svelte | 0 .../src/admin/sections/UsersSection.svelte | 0 .../src/lib/admin/format.js | 0 .../src/lib/admin/revenueSeriesAgg.js | 0 .../src/lib/admin/stores/adsStore.js | 0 .../src/lib/admin/stores/broadcastStore.js | 0 .../src/lib/admin/stores/logsStore.js | 0 .../src/lib/admin/stores/paymentsStore.js | 0 .../src/lib/admin/stores/promosStore.js | 0 .../src/lib/admin/stores/settingsStore.js | 0 .../src/lib/admin/stores/statsStore.js | 0 .../src/lib/admin/stores/tariffsStore.js | 0 .../src/lib/admin/stores/themesStore.js | 8 +- .../src/lib/admin/stores/usersStore.js | 0 .../src/lib/admin/tariffDraft.js | 0 .../src/lib/admin/users.js | 0 .../patterns/admin/AdminBadge.svelte | 0 .../patterns/admin/AdminButton.svelte | 0 .../patterns/admin/AdminDashboardGrid.svelte | 0 .../patterns/admin/AdminDashboardStack.svelte | 0 .../patterns/admin/AdminEmptyState.svelte | 0 .../patterns/admin/AdminField.svelte | 0 .../patterns/admin/AdminPagination.svelte | 0 .../patterns/admin/AdminRevenueChart.svelte | 0 .../AdminRevenueCustomRangePopover.svelte | 0 .../patterns/admin/AdminSectionHeader.svelte | 0 .../patterns/admin/AdminSelect.svelte | 0 .../patterns/admin/AdminTable.svelte | 0 .../patterns/admin/AdminTableSkeleton.svelte | 0 .../patterns/admin/AdminTrafficCard.svelte | 0 .../lib/components/patterns/admin/index.js | 0 .../webapp/DialogOptionsSkeleton.svelte | 0 .../patterns/webapp/EmptyCard.svelte | 0 .../patterns/webapp/LanguageSelect.svelte | 0 .../patterns/webapp/LinearProgress.svelte | 0 .../patterns/webapp/PaymentMethodGrid.svelte | 0 .../patterns/webapp/StatusMessage.svelte | 0 .../lib/components/patterns/webapp/index.js | 0 .../src/lib/components/ui/badge.svelte | 0 .../src/lib/components/ui/button.svelte | 0 .../src/lib/components/ui/card.svelte | 0 .../lib/components/ui/card/card-action.svelte | 0 .../components/ui/card/card-content.svelte | 0 .../ui/card/card-description.svelte | 0 .../lib/components/ui/card/card-footer.svelte | 0 .../lib/components/ui/card/card-header.svelte | 0 .../lib/components/ui/card/card-title.svelte | 0 .../src/lib/components/ui/card/card.svelte | 0 .../src/lib/components/ui/card/index.js | 0 .../src/lib/components/ui/dialog.svelte | 0 .../src/lib/components/ui/icons.js | 0 .../src/lib/components/ui/index.js | 0 .../src/lib/components/ui/input.svelte | 0 .../src/lib/components/ui/primitives.js | 0 .../src/lib/components/ui/skeleton.svelte | 0 .../src/lib/components/ui/spinner.svelte | 0 .../frontend => frontend}/src/lib/utils.js | 0 .../src/lib/webapp/BrandMark.svelte | 0 .../src/lib/webapp/authHelpers.js | 0 .../src/lib/webapp/billingActions.js | 0 .../src/lib/webapp/billingOptionCache.js | 0 .../src/lib/webapp/browser.js | 0 .../src/lib/webapp/constants.js | 0 .../src/lib/webapp/devicesLabels.js | 0 .../src/lib/webapp/formatters.js | 0 .../src/lib/webapp/gravatar.js | 0 .../src/lib/webapp/i18n.js | 0 .../src/lib/webapp/mockApi.js | 0 .../src/lib/webapp/plurals.js | 0 .../src/lib/webapp/previewMock.js | 0 .../src/lib/webapp/publicApi.js | 0 .../src/lib/webapp/routes.js | 0 .../src/lib/webapp/session.js | 0 .../src/lib/webapp/stores/accountStore.js | 0 .../src/lib/webapp/stores/authStore.js | 0 .../src/lib/webapp/stores/billingStore.js | 0 .../src/lib/webapp/stores/devicesStore.js | 0 .../src/lib/webapp/tariffs.js | 0 .../src/lib/webapp/telegramSdk.js | 0 .../src/lib/webapp/themeStyle.js | 0 .../src/lib/webapp/traffic.js | 0 .../src/lib/webapp/webappBoot.js | 0 frontend/src/main.js | 36 ++ .../src/preview/BackTitle.svelte | 0 .../src/preview/PhoneFrame.svelte | 0 .../src/preview/PreviewMethods.svelte | 0 .../src/preview/PreviewNav.svelte | 0 .../web/frontend => frontend}/src/styles.css | 0 .../src/styles/admin-controls.css | 0 .../src/styles/admin-dialogs.css | 0 .../src/styles/admin.css | 0 .../frontend => frontend}/src/styles/base.css | 0 .../src/styles/components.css | 0 .../src/styles/controls.css | 0 .../src/styles/dialogs.css | 0 .../src/styles/tailwind.css | 0 .../src/styles/webapp.css | 0 .../src/webapp/BottomNav.svelte | 0 .../src/webapp/PaymentDialogs.svelte | 0 .../src/webapp/TariffDialogs.svelte | 0 .../src/webapp/WebAppShell.svelte | 0 .../src/webapp/auth/AuthScreen.svelte | 0 .../src/webapp/screens/DevicesScreen.svelte | 0 .../src/webapp/screens/HomeScreen.svelte | 0 .../src/webapp/screens/InviteScreen.svelte | 0 .../src/webapp/screens/SettingsScreen.svelte | 0 .../web/frontend => frontend}/vite.config.mjs | 2 +- locales/en.json | 2 + locales/ru.json | 2 + main.py | 68 --- package.json | 48 +- pyproject.toml | 4 +- pytest.ini | 2 +- requirements-dev.txt | 2 +- scripts/check-all.ps1 | 8 +- scripts/check-all.sh | 6 +- scripts/docker-build-images.ps1 | 18 + scripts/docker-build-images.sh | 19 + scripts/docker-push-images.ps1 | 21 + scripts/docker-push-images.sh | 18 + scripts/send_premium_traffic_warning_test.py | 3 + tests/test_settings.py | 41 +- tests/test_startup_banner.py | 36 ++ tests/test_subscription_service_behavior.py | 73 ++- tests/test_webapp_assets.py | 103 +++- 367 files changed, 2609 insertions(+), 864 deletions(-) delete mode 100644 Caddyfile delete mode 100644 Dockerfile create mode 100644 backend/app_logging.py rename {bot => backend/bot}/app/controllers/__init__.py (100%) rename {bot => backend/bot}/app/controllers/dispatcher_controller.py (83%) rename {bot => backend/bot}/app/factories/__init__.py (100%) rename {bot => backend/bot}/app/factories/build_services.py (100%) rename {bot => backend/bot}/app/web/__init__.py (100%) rename {bot => backend/bot}/app/web/admin_api.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/__init__.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/_runtime.py (97%) rename {bot => backend/bot}/app/web/admin_api_impl/ads.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/auth.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/broadcast.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/common.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/logs.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/panel.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/payments.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/promos.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/routes.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/settings.py (96%) rename {bot => backend/bot}/app/web/admin_api_impl/stats.py (100%) create mode 100644 backend/bot/app/web/admin_api_impl/sync.py rename {bot => backend/bot}/app/web/admin_api_impl/tariffs.py (100%) rename {bot => backend/bot}/app/web/admin_api_impl/themes.py (72%) rename {bot => backend/bot}/app/web/admin_api_impl/users.py (100%) rename {bot => backend/bot}/app/web/admin_settings_manifest.py (99%) rename {bot => backend/bot}/app/web/session.py (100%) rename {bot => backend/bot}/app/web/subscription_webapp.py (100%) rename {bot => backend/bot}/app/web/templates/subscription_webapp.html (89%) rename {bot => backend/bot}/app/web/themes/ascii/style.css (100%) rename {bot => backend/bot}/app/web/themes/ascii/theme.json (100%) rename {bot => backend/bot}/app/web/themes/dark/theme.json (100%) rename {bot => backend/bot}/app/web/themes/light/style.css (100%) rename {bot => backend/bot}/app/web/themes/light/theme.json (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/arrow-left.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/arrow-right.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/bitcoin.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/check.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/chevrons.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/coins.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/dashboard.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/database.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/download.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/error.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/file-text.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/folder.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/gift.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/globe.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/help.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/home.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/info.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/key.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/lock.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/megaphone.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/paintbrush.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/payment-card.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/print.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/refresh.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/save.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/search.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/send.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/settings.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/shield.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/sliders.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/smartphone.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/sparkles.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/tag.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/ticket.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/trash.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/user.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/users.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/warning.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/icons/x.png (100%) rename {bot => backend/bot}/app/web/themes/windows95/style.css (100%) rename {bot => backend/bot}/app/web/themes/windows95/theme.json (100%) rename {bot => backend/bot}/app/web/web_server.py (89%) rename {bot => backend/bot}/app/web/webapp/__init__.py (100%) rename {bot => backend/bot}/app/web/webapp/_runtime.py (94%) rename {bot => backend/bot}/app/web/webapp/account.py (98%) rename {bot => backend/bot}/app/web/webapp/application.py (100%) rename {bot => backend/bot}/app/web/webapp/assets.py (90%) rename {bot => backend/bot}/app/web/webapp/auth.py (100%) rename {bot => backend/bot}/app/web/webapp/billing.py (100%) rename {bot => backend/bot}/app/web/webapp/common.py (100%) rename {bot => backend/bot}/app/web/webapp/devices.py (100%) rename {bot => backend/bot}/app/web/webapp/payloads.py (100%) rename {bot => backend/bot}/app/web/webapp/routes.py (98%) rename {bot => backend/bot}/app/web/webapp/serializers.py (100%) rename {bot => backend/bot}/app/web/webapp_auth.py (100%) rename {bot => backend/bot}/filters/admin_filter.py (100%) rename {bot => backend/bot}/handlers/admin/__init__.py (100%) rename {bot => backend/bot}/handlers/admin/ads.py (100%) rename {bot => backend/bot}/handlers/admin/broadcast.py (100%) rename {bot => backend/bot}/handlers/admin/common.py (100%) rename {bot => backend/bot}/handlers/admin/logs_admin.py (100%) rename {bot => backend/bot}/handlers/admin/payments.py (100%) rename {bot => backend/bot}/handlers/admin/promo/__init__.py (100%) rename {bot => backend/bot}/handlers/admin/promo/bulk.py (100%) rename {bot => backend/bot}/handlers/admin/promo/create.py (100%) rename {bot => backend/bot}/handlers/admin/promo/manage.py (100%) rename {bot => backend/bot}/handlers/admin/statistics.py (100%) rename {bot => backend/bot}/handlers/admin/sync_admin.py (100%) rename {bot => backend/bot}/handlers/admin/user_management.py (98%) rename {bot => backend/bot}/handlers/inline_mode.py (100%) rename {bot => backend/bot}/handlers/user/__init__.py (100%) rename {bot => backend/bot}/handlers/user/payment.py (98%) rename {bot => backend/bot}/handlers/user/promo_user.py (100%) rename {bot => backend/bot}/handlers/user/referral.py (100%) rename {bot => backend/bot}/handlers/user/start.py (100%) rename {bot => backend/bot}/handlers/user/subscription/__init__.py (100%) rename {bot => backend/bot}/handlers/user/subscription/core.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payment_methods.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_crypto.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_freekassa.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_platega.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_severpay.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_stars.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_subscription.py (100%) rename {bot => backend/bot}/handlers/user/subscription/payments_yookassa.py (100%) rename {bot => backend/bot}/handlers/user/trial_handler.py (100%) create mode 100644 backend/bot/infra/redis.py create mode 100644 backend/bot/infra/webhook_queue.py rename {bot => backend/bot}/keyboards/inline/admin_keyboards.py (100%) rename {bot => backend/bot}/keyboards/inline/user_keyboards.py (100%) rename {bot => backend/bot}/main_bot.py (91%) rename {bot => backend/bot}/middlewares/action_logger_middleware.py (100%) rename {bot => backend/bot}/middlewares/ban_check_middleware.py (100%) rename {bot => backend/bot}/middlewares/channel_subscription.py (100%) rename {bot => backend/bot}/middlewares/db_session.py (100%) rename {bot => backend/bot}/middlewares/i18n.py (100%) rename {bot => backend/bot}/middlewares/profile_sync.py (100%) rename {bot => backend/bot}/routers.py (100%) rename {bot => backend/bot}/services/crypto_pay_service.py (100%) rename {bot => backend/bot}/services/email_auth_service.py (100%) rename {bot => backend/bot}/services/email_templates.py (100%) rename {bot => backend/bot}/services/freekassa_service.py (100%) rename {bot => backend/bot}/services/lknpd_client.py (100%) rename {bot => backend/bot}/services/lknpd_service.py (100%) rename {bot => backend/bot}/services/notification_service.py (100%) rename {bot => backend/bot}/services/panel_api_service.py (98%) rename {bot => backend/bot}/services/panel_webhook_service.py (95%) rename {bot => backend/bot}/services/platega_service.py (100%) rename {bot => backend/bot}/services/promo_code_service.py (100%) rename {bot => backend/bot}/services/referral_service.py (100%) rename {bot => backend/bot}/services/settings_override_service.py (56%) rename {bot => backend/bot}/services/severpay_service.py (100%) rename {bot => backend/bot}/services/stars_service.py (100%) rename {bot => backend/bot}/services/subscription_service.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/__init__.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/_runtime.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/core.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/devices.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/lifecycle.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/panel_identity.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/payments.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/renewal.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/tariffs.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/traffic.py (100%) rename {bot => backend/bot}/services/subscription_service_impl/trial.py (98%) rename {bot => backend/bot}/services/tariff_worker.py (93%) rename {bot => backend/bot}/services/yookassa_service.py (100%) rename {bot => backend/bot}/states/admin_states.py (100%) rename {bot => backend/bot}/states/user_states.py (100%) rename {bot => backend/bot}/utils/__init__.py (100%) rename {bot => backend/bot}/utils/callback_answer.py (100%) rename {bot => backend/bot}/utils/config_link.py (100%) rename {bot => backend/bot}/utils/date_utils.py (100%) rename {bot => backend/bot}/utils/message_queue.py (100%) rename {bot => backend/bot}/utils/mini_app_url.py (100%) rename {bot => backend/bot}/utils/request_security.py (100%) rename {bot => backend/bot}/utils/telegram_markup.py (100%) rename {bot => backend/bot}/utils/text_sanitizer.py (100%) rename {bot => backend/bot}/utils/ttl_cache.py (64%) rename {config => backend/config}/settings.py (98%) rename {config => backend/config}/tariffs_config.py (100%) rename {config => backend/config}/webapp_themes_config.py (100%) rename {db => backend/db}/dal/__init__.py (100%) rename {db => backend/db}/dal/ad_dal.py (100%) rename {db => backend/db}/dal/app_settings_dal.py (100%) rename {db => backend/db}/dal/message_log_dal.py (100%) rename {db => backend/db}/dal/panel_sync_dal.py (100%) rename {db => backend/db}/dal/payment_dal.py (100%) rename {db => backend/db}/dal/promo_code_dal.py (100%) rename {db => backend/db}/dal/security_dal.py (100%) rename {db => backend/db}/dal/subscription_dal.py (93%) rename {db => backend/db}/dal/tariff_dal.py (100%) rename {db => backend/db}/dal/user_billing_dal.py (100%) rename {db => backend/db}/dal/user_dal.py (100%) rename {db => backend/db}/database_setup.py (96%) rename {db => backend/db}/migrator.py (100%) rename {db => backend/db}/models.py (100%) create mode 100644 backend/main.py create mode 100644 backend/main_backend.py create mode 100644 backend/main_migrate.py create mode 100644 backend/main_worker.py rename requirements.txt => backend/requirements.txt (95%) create mode 100644 backend/startup_banner.py delete mode 100644 bot/app/web/admin_api_impl/sync.py delete mode 100644 bot/app/web/frontend/src/main.js create mode 100644 deploy/compose/docker-compose-caddy.yml create mode 100644 deploy/compose/docker-compose-remote-server.yml create mode 100644 deploy/docker/Dockerfile create mode 100644 deploy/docker/caddy/Caddyfile create mode 100644 deploy/docker/frontend/00-startup-banner.sh create mode 100644 deploy/docker/frontend/nginx.conf delete mode 100644 docker-compose-caddy.yml delete mode 100644 docker-compose-remote-server.yml create mode 100644 docs/architecture.md rename .prettierignore => frontend/.prettierignore (100%) rename .prettierrc.json => frontend/.prettierrc.json (100%) rename components.json => frontend/components.json (85%) rename eslint.config.mjs => frontend/eslint.config.mjs (79%) rename {bot/app/web/frontend => frontend}/jsconfig.json (100%) rename package-lock.json => frontend/package-lock.json (100%) create mode 100644 frontend/package.json rename {scripts => frontend/scripts}/build_subscription_webapp_js.mjs (93%) rename {bot/app/web/frontend => frontend}/src/App.svelte (100%) rename {bot/app/web/frontend => frontend}/src/PreviewBoard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/AdminPanel.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/AdsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/AppearanceSection.svelte (97%) rename {bot/app/web/frontend => frontend}/src/admin/sections/BroadcastSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/LogsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/PaymentsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/PromosSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/SettingsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/StatsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/TariffEditorModal.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/TariffsSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/UserDetailModal.svelte (100%) rename {bot/app/web/frontend => frontend}/src/admin/sections/UsersSection.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/format.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/revenueSeriesAgg.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/adsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/broadcastStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/logsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/paymentsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/promosStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/settingsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/statsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/tariffsStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/themesStore.js (94%) rename {bot/app/web/frontend => frontend}/src/lib/admin/stores/usersStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/tariffDraft.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/admin/users.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminBadge.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminButton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminDashboardGrid.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminDashboardStack.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminEmptyState.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminField.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminPagination.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminRevenueChart.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminSectionHeader.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminSelect.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTable.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTableSkeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/AdminTrafficCard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/admin/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/EmptyCard.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/LanguageSelect.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/LinearProgress.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/StatusMessage.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/patterns/webapp/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/badge.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/button.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-action.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-content.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-description.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-footer.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-header.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card-title.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/card.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/card/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/dialog.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/icons.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/index.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/input.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/primitives.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/skeleton.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/components/ui/spinner.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/utils.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/BrandMark.svelte (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/authHelpers.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/billingActions.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/billingOptionCache.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/browser.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/constants.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/devicesLabels.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/formatters.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/gravatar.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/i18n.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/mockApi.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/plurals.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/previewMock.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/publicApi.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/routes.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/session.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/accountStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/authStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/billingStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/stores/devicesStore.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/tariffs.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/telegramSdk.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/themeStyle.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/traffic.js (100%) rename {bot/app/web/frontend => frontend}/src/lib/webapp/webappBoot.js (100%) create mode 100644 frontend/src/main.js rename {bot/app/web/frontend => frontend}/src/preview/BackTitle.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PhoneFrame.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PreviewMethods.svelte (100%) rename {bot/app/web/frontend => frontend}/src/preview/PreviewNav.svelte (100%) rename {bot/app/web/frontend => frontend}/src/styles.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin-controls.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin-dialogs.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/admin.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/base.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/components.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/controls.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/dialogs.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/tailwind.css (100%) rename {bot/app/web/frontend => frontend}/src/styles/webapp.css (100%) rename {bot/app/web/frontend => frontend}/src/webapp/BottomNav.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/PaymentDialogs.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/TariffDialogs.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/WebAppShell.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/auth/AuthScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/DevicesScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/HomeScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/InviteScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/src/webapp/screens/SettingsScreen.svelte (100%) rename {bot/app/web/frontend => frontend}/vite.config.mjs (93%) delete mode 100644 main.py create mode 100644 scripts/docker-build-images.ps1 create mode 100644 scripts/docker-build-images.sh create mode 100644 scripts/docker-push-images.ps1 create mode 100644 scripts/docker-push-images.sh create mode 100644 tests/test_startup_banner.py diff --git a/.dockerignore b/.dockerignore index 224cdb1..9708826 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,8 @@ scratch/ .claude/ *.local.* node_modules/ -docker-compose-dev.yml +frontend/node_modules/ +deploy/compose/docker-compose-dev.yml data/* !data/tariffs.example.json @@ -24,15 +25,14 @@ data/* # Docker docker-compose.yml -Dockerfile -.docker +deploy/compose/*.yml .dockerignore tmp/ # WebApp build artifacts (regenerated inside Docker) -bot/app/web/templates/subscription_webapp.css -bot/app/web/templates/subscription_webapp.js -bot/app/web/templates/subscription_webapp.min.*.js +backend/bot/app/web/templates/subscription_webapp.css +backend/bot/app/web/templates/subscription_webapp.js +backend/bot/app/web/templates/subscription_webapp.min.*.js # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/.env.example b/.env.example index 16c6036..07c836b 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,21 @@ POSTGRES_PASSWORD= # R POSTGRES_HOST=remnawave-minishop-db # Database container name POSTGRES_PORT=5432 # Port POSTGRES_DB=postgres # Database name +DB_POOL_SIZE=20 # SQLAlchemy async pool size per backend/worker process +DB_MAX_OVERFLOW=10 # Extra transient DB connections above pool size +DB_POOL_TIMEOUT_SECONDS=30 # Seconds to wait for a DB pool connection +DB_POOL_RECYCLE_SECONDS=1800 # Recycle DB connections to avoid stale sockets + +REDIS_URL=redis://redis:6379/0 # Shared Redis for FSM, rate limits, cache, locks and queues +REDIS_KEY_PREFIX=remnawave-tg-shop # Prefix for Redis keys +WEBAPP_ME_CACHE_TTL_SECONDS=15 # Short TTL for /api/me payload cache +WEBAPP_RATE_LIMIT_TTL_SECONDS=60 # Redis rate-limit window +WEBAPP_RATE_LIMIT_MAX_REQUESTS=30 # Requests per window/action/user/IP +WEBHOOK_QUEUE_NAME=webhook-events # Redis queue for heavy webhook processing +WEBHOOK_QUEUE_CONCURRENCY=4 # Worker webhook consumers +WORKER_PANEL_SYNC_INTERVAL_SECONDS=900 # Worker panel sync interval +TARIFF_WORKER_LOCK_TTL_SECONDS=240 # Redis lock TTL for tariff tick +TARIFF_WORKER_TICK_SECONDS=300 # Tariff worker tick interval # Localization and Display DEFAULT_LANGUAGE="ru" # or "en" @@ -146,8 +161,6 @@ STARS_PRICE_12_MONTHS=0 TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used. -NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id -NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used # Subscription Notifications diff --git a/.gitignore b/.gitignore index d57223c..a4316fe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ bot_database.sqlite3 .env .env.* !.env.example +docker-compose-dev.yml scratch_*.py *.local.* node_modules/ @@ -14,6 +15,9 @@ node_modules/ bot/app/web/templates/subscription_webapp.css bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.min.*.js +backend/bot/app/web/templates/subscription_webapp.css +backend/bot/app/web/templates/subscription_webapp.js +backend/bot/app/web/templates/subscription_webapp.min.*.js tmp .claude @@ -32,4 +36,3 @@ locales/en_backup.json db/models_old.py data/* !data/tariffs.example.json -docker-compose-dev.yml diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index 0b1d726..0000000 --- a/Caddyfile +++ /dev/null @@ -1,10 +0,0 @@ -# Replace the example domains below with your real webhook and Mini App hostnames. -webhook.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080} -} - -app.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081} -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 524a2fd..0000000 --- a/Dockerfile +++ /dev/null @@ -1,96 +0,0 @@ -FROM python:3.12-slim AS python-builder - -WORKDIR /app - -COPY requirements.txt . - -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --no-cache-dir -r requirements.txt - - -FROM node:22-slim AS webapp-builder - -WORKDIR /webapp - -COPY package.json package-lock.json* ./ -RUN --mount=type=cache,target=/root/.npm \ - if [ -f package-lock.json ]; then npm ci; else npm install; fi - -COPY bot/app/web/frontend ./bot/app/web/frontend -COPY bot/app/web/templates ./bot/app/web/templates -COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs - -RUN npm run build:webapp - - -FROM python:3.12-slim - -WORKDIR /app - -ARG APP_VERSION="" -ARG APP_REVISION="" - -LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ - org.opencontainers.image.version="${APP_VERSION}" \ - org.opencontainers.image.revision="${APP_REVISION}" - -RUN useradd -u 10001 -m appuser - -COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages - -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update && \ - apt-get install -y --no-install-recommends git - -COPY . . - -# Replace template assets with freshly built ones -RUN rm -f bot/app/web/templates/subscription_webapp.css \ - bot/app/web/templates/subscription_webapp.js \ - bot/app/web/templates/subscription_webapp.min.*.js -COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \ - bot/app/web/templates/subscription_webapp.css -COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \ - bot/app/web/templates/subscription_webapp.js -COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \ - bot/app/web/templates/ - -RUN set -eux; \ - if [ -n "$APP_VERSION" ]; then \ - printf '%s\n' "$APP_VERSION" > .build-version; \ - elif [ -d .git ]; then \ - tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \ - sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \ - dirty=""; \ - if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \ - if [ -n "$tag" ] && [ -n "$sha" ]; then \ - count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \ - if [ -n "$count" ] && [ "$count" != "0" ]; then \ - printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \ - else \ - printf '%s%s\n' "$tag" "$dirty" > .build-version; \ - fi; \ - elif [ -n "$sha" ]; then \ - printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \ - else \ - printf 'dev+container\n' > .build-version; \ - fi; \ - else \ - printf 'dev+container\n' > .build-version; \ - fi; \ - if [ -n "$APP_REVISION" ]; then \ - printf '%s\n' "$APP_REVISION" > .build-revision; \ - elif [ -d .git ]; then \ - git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \ - else \ - printf 'unknown\n' > .build-revision; \ - fi; \ - apt-get purge -y --auto-remove git; \ - rm -rf .git /root/.cache - -RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data - -USER appuser - -CMD ["python", "main.py"] diff --git a/README.md b/README.md index afe9b61..21e354d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи ## Стек -Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**. +Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**. | Слой | Технологии | | --- | --- | | Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** | -| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) | -| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` | +| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) | +| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` | Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose. @@ -68,7 +68,7 @@ cd remnawave-minishop cp .env.example .env nano .env docker compose up -d --build -docker compose logs -f remnawave-minishop +docker compose logs -f backend worker frontend ``` Минимально заполните в `.env`: @@ -98,15 +98,21 @@ chmod -R u+rwX data docker compose up -d --build # Логи приложения -docker compose logs -f remnawave-minishop +docker compose logs -f backend worker frontend # Запуск с Caddy -docker compose -f docker-compose-caddy.yml up -d --build +docker compose -f deploy/compose/docker-compose-caddy.yml up -d # Запуск из готового образа -IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d +IMAGE_TAG=3.1.0 docker compose -f deploy/compose/docker-compose-remote-server.yml up -d ``` +GHCR image names for releases: + +- `ghcr.io/3252a8/remnawave-minishop-backend` +- `ghcr.io/3252a8/remnawave-minishop-worker` +- `ghcr.io/3252a8/remnawave-minishop-frontend` + ## Поддержка - Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)` diff --git a/backend/app_logging.py b/backend/app_logging.py new file mode 100644 index 0000000..1619c2e --- /dev/null +++ b/backend/app_logging.py @@ -0,0 +1,12 @@ +import logging +import os +import sys + + +def configure_logging() -> None: + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logging.basicConfig( + level=level, + stream=sys.stdout, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) diff --git a/bot/app/controllers/__init__.py b/backend/bot/app/controllers/__init__.py similarity index 100% rename from bot/app/controllers/__init__.py rename to backend/bot/app/controllers/__init__.py diff --git a/bot/app/controllers/dispatcher_controller.py b/backend/bot/app/controllers/dispatcher_controller.py similarity index 83% rename from bot/app/controllers/dispatcher_controller.py rename to backend/bot/app/controllers/dispatcher_controller.py index ed605b5..14deecf 100644 --- a/bot/app/controllers/dispatcher_controller.py +++ b/backend/bot/app/controllers/dispatcher_controller.py @@ -6,6 +6,11 @@ from aiogram.enums import ParseMode from aiogram.fsm.storage.memory import MemoryStorage from sqlalchemy.orm import sessionmaker +try: + from aiogram.fsm.storage.redis import RedisStorage +except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image + RedisStorage = None # type: ignore[assignment] + from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware @@ -18,7 +23,11 @@ from config.settings import Settings def build_dispatcher( settings: Settings, async_session_factory: sessionmaker ) -> tuple[Dispatcher, Bot, Dict]: - storage = MemoryStorage() + storage = ( + RedisStorage.from_url(settings.REDIS_URL) + if settings.REDIS_URL and RedisStorage is not None + else MemoryStorage() + ) default_props = DefaultBotProperties(parse_mode=ParseMode.HTML) bot = Bot(token=settings.BOT_TOKEN, default=default_props) diff --git a/bot/app/factories/__init__.py b/backend/bot/app/factories/__init__.py similarity index 100% rename from bot/app/factories/__init__.py rename to backend/bot/app/factories/__init__.py diff --git a/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py similarity index 100% rename from bot/app/factories/build_services.py rename to backend/bot/app/factories/build_services.py diff --git a/bot/app/web/__init__.py b/backend/bot/app/web/__init__.py similarity index 100% rename from bot/app/web/__init__.py rename to backend/bot/app/web/__init__.py diff --git a/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py similarity index 100% rename from bot/app/web/admin_api.py rename to backend/bot/app/web/admin_api.py diff --git a/bot/app/web/admin_api_impl/__init__.py b/backend/bot/app/web/admin_api_impl/__init__.py similarity index 100% rename from bot/app/web/admin_api_impl/__init__.py rename to backend/bot/app/web/admin_api_impl/__init__.py diff --git a/bot/app/web/admin_api_impl/_runtime.py b/backend/bot/app/web/admin_api_impl/_runtime.py similarity index 97% rename from bot/app/web/admin_api_impl/_runtime.py rename to backend/bot/app/web/admin_api_impl/_runtime.py index 560aef7..6cf286e 100644 --- a/bot/app/web/admin_api_impl/_runtime.py +++ b/backend/bot/app/web/admin_api_impl/_runtime.py @@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker from bot.app.web.admin_settings_manifest import ( manifest_payload, ) +from bot.infra.webhook_queue import enqueue_webhook_event from bot.services.referral_service import ReferralService from bot.services.settings_override_service import ( current_value, diff --git a/bot/app/web/admin_api_impl/ads.py b/backend/bot/app/web/admin_api_impl/ads.py similarity index 100% rename from bot/app/web/admin_api_impl/ads.py rename to backend/bot/app/web/admin_api_impl/ads.py diff --git a/bot/app/web/admin_api_impl/auth.py b/backend/bot/app/web/admin_api_impl/auth.py similarity index 100% rename from bot/app/web/admin_api_impl/auth.py rename to backend/bot/app/web/admin_api_impl/auth.py diff --git a/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py similarity index 100% rename from bot/app/web/admin_api_impl/broadcast.py rename to backend/bot/app/web/admin_api_impl/broadcast.py diff --git a/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py similarity index 100% rename from bot/app/web/admin_api_impl/common.py rename to backend/bot/app/web/admin_api_impl/common.py diff --git a/bot/app/web/admin_api_impl/logs.py b/backend/bot/app/web/admin_api_impl/logs.py similarity index 100% rename from bot/app/web/admin_api_impl/logs.py rename to backend/bot/app/web/admin_api_impl/logs.py diff --git a/bot/app/web/admin_api_impl/panel.py b/backend/bot/app/web/admin_api_impl/panel.py similarity index 100% rename from bot/app/web/admin_api_impl/panel.py rename to backend/bot/app/web/admin_api_impl/panel.py diff --git a/bot/app/web/admin_api_impl/payments.py b/backend/bot/app/web/admin_api_impl/payments.py similarity index 100% rename from bot/app/web/admin_api_impl/payments.py rename to backend/bot/app/web/admin_api_impl/payments.py diff --git a/bot/app/web/admin_api_impl/promos.py b/backend/bot/app/web/admin_api_impl/promos.py similarity index 100% rename from bot/app/web/admin_api_impl/promos.py rename to backend/bot/app/web/admin_api_impl/promos.py diff --git a/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py similarity index 100% rename from bot/app/web/admin_api_impl/routes.py rename to backend/bot/app/web/admin_api_impl/routes.py diff --git a/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py similarity index 96% rename from bot/app/web/admin_api_impl/settings.py rename to backend/bot/app/web/admin_api_impl/settings.py index e201123..5f13ee4 100644 --- a/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -83,5 +83,8 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response: or "WEBAPP_LOGO_FAVICON_URL" in deletes ): request.app["webapp_logo_cache"] = None + from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets + + prune_unused_appearance_assets(settings) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/stats.py b/backend/bot/app/web/admin_api_impl/stats.py similarity index 100% rename from bot/app/web/admin_api_impl/stats.py rename to backend/bot/app/web/admin_api_impl/stats.py diff --git a/backend/bot/app/web/admin_api_impl/sync.py b/backend/bot/app/web/admin_api_impl/sync.py new file mode 100644 index 0000000..6d98664 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/sync.py @@ -0,0 +1,16 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_sync_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + queued = await enqueue_webhook_event( + settings, + "panel_sync", + {"requested_by": _require_admin_user_id(request)}, + event_id=None, + ) + if queued: + return _ok({"result": {"status": "queued"}}) + return _error(503, "queue_unavailable") diff --git a/bot/app/web/admin_api_impl/tariffs.py b/backend/bot/app/web/admin_api_impl/tariffs.py similarity index 100% rename from bot/app/web/admin_api_impl/tariffs.py rename to backend/bot/app/web/admin_api_impl/tariffs.py diff --git a/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py similarity index 72% rename from bot/app/web/admin_api_impl/themes.py rename to backend/bot/app/web/admin_api_impl/themes.py index 22b89f3..b173497 100644 --- a/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405 import asyncio import hashlib import ipaddress +import shutil import re import socket @@ -19,10 +20,11 @@ from config.webapp_themes_config import ( WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 -WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "uploads" +WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" -WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" / "favicons" +WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons" WEBAPP_FAVICON_PATH = "/webapp-favicon" +WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji" WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { ".gif": "image/gif", @@ -73,6 +75,113 @@ def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "" return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}" +def _uploaded_logo_filename(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + return filename + return None + + +def _favicon_digest(url: str) -> Optional[str]: + parsed = urlsplit(str(url or "")) + path = parsed.path if parsed.scheme or parsed.netloc else str(url or "") + match = re.fullmatch( + rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)", + path, + ) + return match.group(1) if match else None + + +def _emoji_to_codepoints(value: str) -> str: + return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) + + +def prune_unused_appearance_assets(settings: Settings) -> None: + keep_logos = { + filename + for filename in [ + _uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")), + ] + if filename + } + keep_favicons = { + digest + for digest in [ + _favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")), + _favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")), + ] + if digest + } + keep_emoji_prefixes = set() + if ( + getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False) + and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip() + == "noto-color-animated" + ): + codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", "")) + if codepoints: + keep_emoji_prefixes.add(f"{codepoints}.512.") + + for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"): + if path.is_file() and path.name not in keep_logos: + try: + path.unlink() + except OSError: + logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True) + + for path in WEBAPP_FAVICON_DIR.glob("*"): + if ( + path.is_dir() + and re.fullmatch(r"[0-9a-f]{16}", path.name) + and path.name not in keep_favicons + ): + try: + shutil.rmtree(path) + except OSError: + logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) + + for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): + if path.is_file() and not any( + path.name.startswith(prefix) for prefix in keep_emoji_prefixes + ): + try: + path.unlink() + except OSError: + logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True) + + +async def _persist_appearance_upload( + request: web.Request, + updates: Dict[str, Any], + actor_id: int, +) -> bool: + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + result = await update_overrides( + settings, + async_session_factory, + updates=updates, + deletes=[], + actor_id=actor_id, + ) + if not result.get("ok"): + logger.warning("Failed to persist uploaded appearance asset settings: %s", result) + return False + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + request.app["webapp_logo_cache"] = None + prune_unused_appearance_assets(settings) + return True + + def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image: fitted = source.copy() fitted.thumbnail((size, size), Image.Resampling.LANCZOS) @@ -221,7 +330,7 @@ async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]: async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) + actor_id = _require_admin_user_id(request) content_type = (request.headers.get("Content-Type") or "").lower() try: if content_type.startswith("multipart/form-data"): @@ -242,12 +351,25 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon except OSError as exc: logger.exception("Failed to save uploaded webapp logo") return _error(500, "write_failed", str(exc)) + persisted = await _persist_appearance_upload( + request, + { + "WEBAPP_LOGO_URL": logo_url, + "WEBAPP_LOGO_USE_EMOJI": False, + **( + {"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]} + if favicon_payload.get("favicon_url") + else {} + ), + }, + actor_id, + ) - return _ok({"logo_url": logo_url, **favicon_payload}) + return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload}) async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) + actor_id = _require_admin_user_id(request) content_type = (request.headers.get("Content-Type") or "").lower() try: if content_type.startswith("multipart/form-data"): @@ -264,8 +386,16 @@ async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Res except OSError as exc: logger.exception("Failed to save uploaded webapp favicon") return _error(500, "write_failed", str(exc)) + persisted = await _persist_appearance_upload( + request, + { + "WEBAPP_FAVICON_URL": favicon_payload["favicon_url"], + "WEBAPP_FAVICON_USE_CUSTOM": True, + }, + actor_id, + ) - return _ok(favicon_payload) + return _ok({"persisted": persisted, **favicon_payload}) async def admin_themes_get_route(request: web.Request) -> web.Response: diff --git a/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py similarity index 100% rename from bot/app/web/admin_api_impl/users.py rename to backend/bot/app/web/admin_api_impl/users.py diff --git a/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py similarity index 99% rename from bot/app/web/admin_settings_manifest.py rename to backend/bot/app/web/admin_settings_manifest.py index 457bc95..739f99a 100644 --- a/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -100,7 +100,12 @@ SETTINGS_MANIFEST: List[SettingField] = [ ("noto-local", "Noto Emoji (local)"), ), ), - SettingField("WEBAPP_FAVICON_USE_CUSTOM", "bool", "appearance", "Использовать отдельную favicon"), + SettingField( + "WEBAPP_FAVICON_USE_CUSTOM", + "bool", + "appearance", + "Использовать отдельную favicon", + ), SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), diff --git a/bot/app/web/session.py b/backend/bot/app/web/session.py similarity index 100% rename from bot/app/web/session.py rename to backend/bot/app/web/session.py diff --git a/bot/app/web/subscription_webapp.py b/backend/bot/app/web/subscription_webapp.py similarity index 100% rename from bot/app/web/subscription_webapp.py rename to backend/bot/app/web/subscription_webapp.py diff --git a/bot/app/web/templates/subscription_webapp.html b/backend/bot/app/web/templates/subscription_webapp.html similarity index 89% rename from bot/app/web/templates/subscription_webapp.html rename to backend/bot/app/web/templates/subscription_webapp.html index 492c5c8..7e56ab7 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/backend/bot/app/web/templates/subscription_webapp.html @@ -9,7 +9,6 @@ /minishop - diff --git a/bot/app/web/themes/ascii/style.css b/backend/bot/app/web/themes/ascii/style.css similarity index 100% rename from bot/app/web/themes/ascii/style.css rename to backend/bot/app/web/themes/ascii/style.css diff --git a/bot/app/web/themes/ascii/theme.json b/backend/bot/app/web/themes/ascii/theme.json similarity index 100% rename from bot/app/web/themes/ascii/theme.json rename to backend/bot/app/web/themes/ascii/theme.json diff --git a/bot/app/web/themes/dark/theme.json b/backend/bot/app/web/themes/dark/theme.json similarity index 100% rename from bot/app/web/themes/dark/theme.json rename to backend/bot/app/web/themes/dark/theme.json diff --git a/bot/app/web/themes/light/style.css b/backend/bot/app/web/themes/light/style.css similarity index 100% rename from bot/app/web/themes/light/style.css rename to backend/bot/app/web/themes/light/style.css diff --git a/bot/app/web/themes/light/theme.json b/backend/bot/app/web/themes/light/theme.json similarity index 100% rename from bot/app/web/themes/light/theme.json rename to backend/bot/app/web/themes/light/theme.json diff --git a/bot/app/web/themes/windows95/icons/arrow-left.png b/backend/bot/app/web/themes/windows95/icons/arrow-left.png similarity index 100% rename from bot/app/web/themes/windows95/icons/arrow-left.png rename to backend/bot/app/web/themes/windows95/icons/arrow-left.png diff --git a/bot/app/web/themes/windows95/icons/arrow-right.png b/backend/bot/app/web/themes/windows95/icons/arrow-right.png similarity index 100% rename from bot/app/web/themes/windows95/icons/arrow-right.png rename to backend/bot/app/web/themes/windows95/icons/arrow-right.png diff --git a/bot/app/web/themes/windows95/icons/bitcoin.png b/backend/bot/app/web/themes/windows95/icons/bitcoin.png similarity index 100% rename from bot/app/web/themes/windows95/icons/bitcoin.png rename to backend/bot/app/web/themes/windows95/icons/bitcoin.png diff --git a/bot/app/web/themes/windows95/icons/check.png b/backend/bot/app/web/themes/windows95/icons/check.png similarity index 100% rename from bot/app/web/themes/windows95/icons/check.png rename to backend/bot/app/web/themes/windows95/icons/check.png diff --git a/bot/app/web/themes/windows95/icons/chevrons.png b/backend/bot/app/web/themes/windows95/icons/chevrons.png similarity index 100% rename from bot/app/web/themes/windows95/icons/chevrons.png rename to backend/bot/app/web/themes/windows95/icons/chevrons.png diff --git a/bot/app/web/themes/windows95/icons/coins.png b/backend/bot/app/web/themes/windows95/icons/coins.png similarity index 100% rename from bot/app/web/themes/windows95/icons/coins.png rename to backend/bot/app/web/themes/windows95/icons/coins.png diff --git a/bot/app/web/themes/windows95/icons/dashboard.png b/backend/bot/app/web/themes/windows95/icons/dashboard.png similarity index 100% rename from bot/app/web/themes/windows95/icons/dashboard.png rename to backend/bot/app/web/themes/windows95/icons/dashboard.png diff --git a/bot/app/web/themes/windows95/icons/database.png b/backend/bot/app/web/themes/windows95/icons/database.png similarity index 100% rename from bot/app/web/themes/windows95/icons/database.png rename to backend/bot/app/web/themes/windows95/icons/database.png diff --git a/bot/app/web/themes/windows95/icons/download.png b/backend/bot/app/web/themes/windows95/icons/download.png similarity index 100% rename from bot/app/web/themes/windows95/icons/download.png rename to backend/bot/app/web/themes/windows95/icons/download.png diff --git a/bot/app/web/themes/windows95/icons/error.png b/backend/bot/app/web/themes/windows95/icons/error.png similarity index 100% rename from bot/app/web/themes/windows95/icons/error.png rename to backend/bot/app/web/themes/windows95/icons/error.png diff --git a/bot/app/web/themes/windows95/icons/file-text.png b/backend/bot/app/web/themes/windows95/icons/file-text.png similarity index 100% rename from bot/app/web/themes/windows95/icons/file-text.png rename to backend/bot/app/web/themes/windows95/icons/file-text.png diff --git a/bot/app/web/themes/windows95/icons/folder.png b/backend/bot/app/web/themes/windows95/icons/folder.png similarity index 100% rename from bot/app/web/themes/windows95/icons/folder.png rename to backend/bot/app/web/themes/windows95/icons/folder.png diff --git a/bot/app/web/themes/windows95/icons/gift.png b/backend/bot/app/web/themes/windows95/icons/gift.png similarity index 100% rename from bot/app/web/themes/windows95/icons/gift.png rename to backend/bot/app/web/themes/windows95/icons/gift.png diff --git a/bot/app/web/themes/windows95/icons/globe.png b/backend/bot/app/web/themes/windows95/icons/globe.png similarity index 100% rename from bot/app/web/themes/windows95/icons/globe.png rename to backend/bot/app/web/themes/windows95/icons/globe.png diff --git a/bot/app/web/themes/windows95/icons/help.png b/backend/bot/app/web/themes/windows95/icons/help.png similarity index 100% rename from bot/app/web/themes/windows95/icons/help.png rename to backend/bot/app/web/themes/windows95/icons/help.png diff --git a/bot/app/web/themes/windows95/icons/home.png b/backend/bot/app/web/themes/windows95/icons/home.png similarity index 100% rename from bot/app/web/themes/windows95/icons/home.png rename to backend/bot/app/web/themes/windows95/icons/home.png diff --git a/bot/app/web/themes/windows95/icons/info.png b/backend/bot/app/web/themes/windows95/icons/info.png similarity index 100% rename from bot/app/web/themes/windows95/icons/info.png rename to backend/bot/app/web/themes/windows95/icons/info.png diff --git a/bot/app/web/themes/windows95/icons/key.png b/backend/bot/app/web/themes/windows95/icons/key.png similarity index 100% rename from bot/app/web/themes/windows95/icons/key.png rename to backend/bot/app/web/themes/windows95/icons/key.png diff --git a/bot/app/web/themes/windows95/icons/lock.png b/backend/bot/app/web/themes/windows95/icons/lock.png similarity index 100% rename from bot/app/web/themes/windows95/icons/lock.png rename to backend/bot/app/web/themes/windows95/icons/lock.png diff --git a/bot/app/web/themes/windows95/icons/megaphone.png b/backend/bot/app/web/themes/windows95/icons/megaphone.png similarity index 100% rename from bot/app/web/themes/windows95/icons/megaphone.png rename to backend/bot/app/web/themes/windows95/icons/megaphone.png diff --git a/bot/app/web/themes/windows95/icons/paintbrush.png b/backend/bot/app/web/themes/windows95/icons/paintbrush.png similarity index 100% rename from bot/app/web/themes/windows95/icons/paintbrush.png rename to backend/bot/app/web/themes/windows95/icons/paintbrush.png diff --git a/bot/app/web/themes/windows95/icons/payment-card.png b/backend/bot/app/web/themes/windows95/icons/payment-card.png similarity index 100% rename from bot/app/web/themes/windows95/icons/payment-card.png rename to backend/bot/app/web/themes/windows95/icons/payment-card.png diff --git a/bot/app/web/themes/windows95/icons/print.png b/backend/bot/app/web/themes/windows95/icons/print.png similarity index 100% rename from bot/app/web/themes/windows95/icons/print.png rename to backend/bot/app/web/themes/windows95/icons/print.png diff --git a/bot/app/web/themes/windows95/icons/refresh.png b/backend/bot/app/web/themes/windows95/icons/refresh.png similarity index 100% rename from bot/app/web/themes/windows95/icons/refresh.png rename to backend/bot/app/web/themes/windows95/icons/refresh.png diff --git a/bot/app/web/themes/windows95/icons/save.png b/backend/bot/app/web/themes/windows95/icons/save.png similarity index 100% rename from bot/app/web/themes/windows95/icons/save.png rename to backend/bot/app/web/themes/windows95/icons/save.png diff --git a/bot/app/web/themes/windows95/icons/search.png b/backend/bot/app/web/themes/windows95/icons/search.png similarity index 100% rename from bot/app/web/themes/windows95/icons/search.png rename to backend/bot/app/web/themes/windows95/icons/search.png diff --git a/bot/app/web/themes/windows95/icons/send.png b/backend/bot/app/web/themes/windows95/icons/send.png similarity index 100% rename from bot/app/web/themes/windows95/icons/send.png rename to backend/bot/app/web/themes/windows95/icons/send.png diff --git a/bot/app/web/themes/windows95/icons/settings.png b/backend/bot/app/web/themes/windows95/icons/settings.png similarity index 100% rename from bot/app/web/themes/windows95/icons/settings.png rename to backend/bot/app/web/themes/windows95/icons/settings.png diff --git a/bot/app/web/themes/windows95/icons/shield.png b/backend/bot/app/web/themes/windows95/icons/shield.png similarity index 100% rename from bot/app/web/themes/windows95/icons/shield.png rename to backend/bot/app/web/themes/windows95/icons/shield.png diff --git a/bot/app/web/themes/windows95/icons/sliders.png b/backend/bot/app/web/themes/windows95/icons/sliders.png similarity index 100% rename from bot/app/web/themes/windows95/icons/sliders.png rename to backend/bot/app/web/themes/windows95/icons/sliders.png diff --git a/bot/app/web/themes/windows95/icons/smartphone.png b/backend/bot/app/web/themes/windows95/icons/smartphone.png similarity index 100% rename from bot/app/web/themes/windows95/icons/smartphone.png rename to backend/bot/app/web/themes/windows95/icons/smartphone.png diff --git a/bot/app/web/themes/windows95/icons/sparkles.png b/backend/bot/app/web/themes/windows95/icons/sparkles.png similarity index 100% rename from bot/app/web/themes/windows95/icons/sparkles.png rename to backend/bot/app/web/themes/windows95/icons/sparkles.png diff --git a/bot/app/web/themes/windows95/icons/tag.png b/backend/bot/app/web/themes/windows95/icons/tag.png similarity index 100% rename from bot/app/web/themes/windows95/icons/tag.png rename to backend/bot/app/web/themes/windows95/icons/tag.png diff --git a/bot/app/web/themes/windows95/icons/ticket.png b/backend/bot/app/web/themes/windows95/icons/ticket.png similarity index 100% rename from bot/app/web/themes/windows95/icons/ticket.png rename to backend/bot/app/web/themes/windows95/icons/ticket.png diff --git a/bot/app/web/themes/windows95/icons/trash.png b/backend/bot/app/web/themes/windows95/icons/trash.png similarity index 100% rename from bot/app/web/themes/windows95/icons/trash.png rename to backend/bot/app/web/themes/windows95/icons/trash.png diff --git a/bot/app/web/themes/windows95/icons/user.png b/backend/bot/app/web/themes/windows95/icons/user.png similarity index 100% rename from bot/app/web/themes/windows95/icons/user.png rename to backend/bot/app/web/themes/windows95/icons/user.png diff --git a/bot/app/web/themes/windows95/icons/users.png b/backend/bot/app/web/themes/windows95/icons/users.png similarity index 100% rename from bot/app/web/themes/windows95/icons/users.png rename to backend/bot/app/web/themes/windows95/icons/users.png diff --git a/bot/app/web/themes/windows95/icons/warning.png b/backend/bot/app/web/themes/windows95/icons/warning.png similarity index 100% rename from bot/app/web/themes/windows95/icons/warning.png rename to backend/bot/app/web/themes/windows95/icons/warning.png diff --git a/bot/app/web/themes/windows95/icons/x.png b/backend/bot/app/web/themes/windows95/icons/x.png similarity index 100% rename from bot/app/web/themes/windows95/icons/x.png rename to backend/bot/app/web/themes/windows95/icons/x.png diff --git a/bot/app/web/themes/windows95/style.css b/backend/bot/app/web/themes/windows95/style.css similarity index 100% rename from bot/app/web/themes/windows95/style.css rename to backend/bot/app/web/themes/windows95/style.css diff --git a/bot/app/web/themes/windows95/theme.json b/backend/bot/app/web/themes/windows95/theme.json similarity index 100% rename from bot/app/web/themes/windows95/theme.json rename to backend/bot/app/web/themes/windows95/theme.json diff --git a/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py similarity index 89% rename from bot/app/web/web_server.py rename to backend/bot/app/web/web_server.py index cf3400a..6e00c33 100644 --- a/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -56,9 +56,24 @@ async def build_and_start_web_app( _inject_shared_instances(app, dp, bot, settings, async_session_factory) async def _healthcheck(request: web.Request) -> web.Response: - return web.json_response({"status": "ok"}) + payload = {"status": "ok"} + try: + from db.database_setup import async_engine + + pool = async_engine.pool if async_engine is not None else None + if pool is not None: + payload["db_pool"] = { + "checked_in": pool.checkedin(), + "checked_out": pool.checkedout(), + "size": pool.size(), + "overflow": pool.overflow(), + } + except Exception: + logging.exception("Failed to collect DB pool health metrics") + return web.json_response(payload) app.router.add_get("/healthz", _healthcheck) + app.router.add_get("/health", _healthcheck) setup_application(app, dp, bot=bot) diff --git a/bot/app/web/webapp/__init__.py b/backend/bot/app/web/webapp/__init__.py similarity index 100% rename from bot/app/web/webapp/__init__.py rename to backend/bot/app/web/webapp/__init__.py diff --git a/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py similarity index 94% rename from bot/app/web/webapp/_runtime.py rename to backend/bot/app/web/webapp/_runtime.py index a148340..fddbe4e 100644 --- a/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -42,6 +42,7 @@ from bot.app.web.webapp_auth import ( verify_telegram_oauth_nonce, verify_webapp_session_token, ) +from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key from bot.services.crypto_pay_service import CryptoPayService from bot.services.email_auth_service import EmailAuthService, normalize_email from bot.services.email_templates import render_account_merged @@ -64,13 +65,14 @@ logger = logging.getLogger(__name__) TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html" ASSET_DIR = TEMPLATE_PATH.parent +APP_ROOT = Path(__file__).resolve().parents[5] WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" -WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" WEBAPP_FAVICON_PATH = "/webapp-favicon" -WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" +WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_CONFIG_PLACEHOLDER = "" WEBAPP_I18N_PLACEHOLDER = "" WEBAPP_JS_PLACEHOLDER = "" diff --git a/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py similarity index 98% rename from bot/app/web/webapp/account.py rename to backend/bot/app/web/webapp/account.py index 0471d38..a20a1ef 100644 --- a/bot/app/web/webapp/account.py +++ b/backend/bot/app/web/webapp/account.py @@ -350,7 +350,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response: async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + cache_key = redis_key(settings, "cache", "webapp", "me", user_id) + cached = await cache_get_json(settings, cache_key) + if cached: + return web.json_response({"ok": True, **cached}) data = await _build_user_payload(request, user_id) + await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS) return web.json_response({"ok": True, **data}) diff --git a/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py similarity index 100% rename from bot/app/web/webapp/application.py rename to backend/bot/app/web/webapp/application.py diff --git a/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py similarity index 90% rename from bot/app/web/webapp/assets.py rename to backend/bot/app/web/webapp/assets.py index ca3beee..f23bc0e 100644 --- a/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -182,6 +182,48 @@ def _is_proxyable_webapp_logo_url(logo_url: str) -> bool: return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname) +def _uploaded_webapp_logo_filename(logo_url: str) -> Optional[str]: + parsed_logo_url = urlsplit(str(logo_url or "")) + path = parsed_logo_url.path if parsed_logo_url.scheme or parsed_logo_url.netloc else logo_url + prefix = f"{WEBAPP_UPLOADED_LOGO_PATH}/" + if not path.startswith(prefix): + return None + filename = path.removeprefix(prefix) + if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + return filename + return None + + +def _uploaded_webapp_logo_response(filename: str) -> web.Response: + if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): + raise web.HTTPNotFound(text="webapp_logo_not_found") + + root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() + path = (root / filename).resolve() + try: + path.relative_to(root) + except ValueError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) + if not content_type: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + try: + if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: + raise web.HTTPNotFound(text="webapp_logo_too_large") + body = path.read_bytes() + except OSError: + raise web.HTTPNotFound(text="webapp_logo_not_found") from None + + if not body: + raise web.HTTPNotFound(text="webapp_logo_not_found") + + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + def _emoji_to_codepoints(value: str) -> str: return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) @@ -209,6 +251,10 @@ async def webapp_logo_route(request: web.Request) -> web.Response: if not raw_logo_url: raise web.HTTPNotFound(text="webapp_logo_not_configured") + uploaded_filename = _uploaded_webapp_logo_filename(raw_logo_url) + if uploaded_filename: + return _uploaded_webapp_logo_response(uploaded_filename) + if not _is_proxyable_webapp_logo_url(raw_logo_url): raise web.HTTPNotFound(text="webapp_logo_not_proxied") @@ -244,33 +290,7 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response: raise web.HTTPNotFound(text="webapp_disabled") filename = str(request.match_info.get("filename") or "").strip() - if not re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename): - raise web.HTTPNotFound(text="webapp_logo_not_found") - - root = WEBAPP_UPLOADED_LOGO_DIR.expanduser().resolve() - path = (root / filename).resolve() - try: - path.relative_to(root) - except ValueError: - raise web.HTTPNotFound(text="webapp_logo_not_found") from None - - content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower()) - if not content_type: - raise web.HTTPNotFound(text="webapp_logo_not_found") - - try: - if path.stat().st_size > WEBAPP_LOGO_MAX_BYTES: - raise web.HTTPNotFound(text="webapp_logo_too_large") - body = path.read_bytes() - except OSError: - raise web.HTTPNotFound(text="webapp_logo_not_found") from None - - if not body: - raise web.HTTPNotFound(text="webapp_logo_not_found") - - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response + return _uploaded_webapp_logo_response(filename) async def webapp_favicon_route(request: web.Request) -> web.Response: @@ -727,7 +747,7 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: def _run_git_command(*args: str) -> str: - repo_root = Path(__file__).resolve().parents[3] + repo_root = APP_ROOT try: result = subprocess.run( ["git", *args], @@ -752,7 +772,7 @@ def _resolve_app_version() -> str: _APP_VERSION_CACHE = env_version return env_version - build_version_path = Path(__file__).resolve().parents[3] / ".build-version" + build_version_path = APP_ROOT / ".build-version" try: build_version = build_version_path.read_text(encoding="utf-8").strip() except OSError: @@ -796,6 +816,26 @@ async def _enforce_webapp_rate_limit( or "unknown" ) key = f"{action}:{ip_address}:{int(user_id)}" + redis = await get_redis(settings) + if redis is not None: + redis_rate_key = redis_key(settings, "rate-limit", "webapp", key) + current = await redis.incr(redis_rate_key) + if current == 1: + await redis.expire(redis_rate_key, settings.WEBAPP_RATE_LIMIT_TTL_SECONDS) + if current > settings.WEBAPP_RATE_LIMIT_MAX_REQUESTS: + ttl = await redis.ttl(redis_rate_key) + retry_after = max(1, int(ttl if ttl and ttl > 0 else WEBAPP_RATE_LIMIT_WINDOW_SECONDS)) + return web.json_response( + { + "ok": False, + "error": "rate_limited", + "retry_after": retry_after, + }, + status=429, + headers={"Retry-After": str(retry_after)}, + ) + return None + buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"] lock: asyncio.Lock = request.app["webapp_rate_limit_lock"] now = time.monotonic() @@ -847,6 +887,56 @@ async def js_asset_route(request: web.Request) -> web.Response: return response +def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + cached = _get_cached_webapp_settings(request) + themes_catalog = settings.webapp_themes_catalog + primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" + preview_key = str(request.query.get("theme_preview") or "").strip() + preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None + if preview_theme is None or not preview_theme.enabled: + preview_key = "" + i18n_instance: Optional[object] = request.app.get("i18n") + return { + "config": { + "title": settings.WEBAPP_TITLE, + "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "themesCatalog": public_themes_catalog_payload( + themes_catalog, + primary_color, + enabled_only=True, + ), + "themesDir": settings.WEBAPP_THEMES_DIR, + "themePreviewKey": preview_key, + "logoUrl": cached["logo_url"], + "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), + "logoEmoji": settings.WEBAPP_LOGO_EMOJI, + "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "faviconUrl": cached["favicon_url"], + "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), + "apiBase": "/api", + "telegramLoginBotUsername": request.app.get("bot_username") or "", + "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, + "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, + "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), + "supportUrl": cached["support_url"], + "termsUrl": cached["terms_url"], + "privacyPolicyUrl": cached["privacy_policy_url"], + "userAgreementUrl": cached["user_agreement_url"], + "currency": cached["currency"], + "language": cached["language"], + "emailAuthEnabled": cached["email_auth_enabled"], + "appVersion": _resolve_app_version(), + "appRepositoryUrl": APP_REPOSITORY_URL, + }, + "i18n": getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}, + } + + +async def bootstrap_route(request: web.Request) -> web.Response: + return web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)}) + + async def index_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.WEBAPP_ENABLED: @@ -857,47 +947,13 @@ async def index_route(request: web.Request) -> web.Response: themes_catalog = settings.webapp_themes_catalog primary_color = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a" initial_theme = _initial_theme_for_request(request, themes_catalog) - preview_key = str(request.query.get("theme_preview") or "").strip() - preview_theme = themes_catalog.theme_by_key(preview_key) if preview_key else None - if preview_theme is None or not preview_theme.enabled: - preview_key = "" - config = { - "title": settings.WEBAPP_TITLE, - "primaryColor": settings.WEBAPP_PRIMARY_COLOR, - "themesCatalog": public_themes_catalog_payload( - themes_catalog, - primary_color, - enabled_only=True, - ), - "themesDir": settings.WEBAPP_THEMES_DIR, - "themePreviewKey": preview_key, - "logoUrl": cached["logo_url"], - "logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI), - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, - "faviconUrl": cached["favicon_url"], - "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), - "apiBase": "/api", - "telegramLoginBotUsername": request.app.get("bot_username") or "", - "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, - "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, - "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), - "supportUrl": cached["support_url"], - "termsUrl": cached["terms_url"], - "privacyPolicyUrl": cached["privacy_policy_url"], - "userAgreementUrl": cached["user_agreement_url"], - "currency": cached["currency"], - "language": cached["language"], - "emailAuthEnabled": cached["email_auth_enabled"], - "appVersion": _resolve_app_version(), - "appRepositoryUrl": APP_REPOSITORY_URL, - } + bootstrap = _build_webapp_bootstrap_payload(request) + config = bootstrap["config"] html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color) if initial_theme_markup: html = html.replace("", f"{initial_theme_markup}\n", 1) - i18n_instance: Optional[object] = request.app.get("i18n") - i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} + i18n_payload = bootstrap["i18n"] nonce = request.get("csp_nonce", "") html = html.replace( WEBAPP_CONFIG_PLACEHOLDER, @@ -934,13 +990,12 @@ async def index_route(request: web.Request) -> web.Response: brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) if brand_asset_url: html = html.replace( - '', - f'', - ) - else: - html = html.replace( - '', - "", + "", + ( + f'\n' + ), + 1, ) return web.Response(text=html, content_type="text/html", charset="utf-8") diff --git a/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py similarity index 100% rename from bot/app/web/webapp/auth.py rename to backend/bot/app/web/webapp/auth.py diff --git a/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py similarity index 100% rename from bot/app/web/webapp/billing.py rename to backend/bot/app/web/webapp/billing.py diff --git a/bot/app/web/webapp/common.py b/backend/bot/app/web/webapp/common.py similarity index 100% rename from bot/app/web/webapp/common.py rename to backend/bot/app/web/webapp/common.py diff --git a/bot/app/web/webapp/devices.py b/backend/bot/app/web/webapp/devices.py similarity index 100% rename from bot/app/web/webapp/devices.py rename to backend/bot/app/web/webapp/devices.py diff --git a/bot/app/web/webapp/payloads.py b/backend/bot/app/web/webapp/payloads.py similarity index 100% rename from bot/app/web/webapp/payloads.py rename to backend/bot/app/web/webapp/payloads.py diff --git a/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py similarity index 98% rename from bot/app/web/webapp/routes.py rename to backend/bot/app/web/webapp/routes.py index d8a0b59..5d1f5ca 100644 --- a/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -44,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/auth/email/verify", email_auth_verify_route) app.router.add_post("/api/auth/email/magic", email_auth_magic_route) app.router.add_post("/api/auth/logout", logout_route) + app.router.add_get("/api/bootstrap", bootstrap_route) app.router.add_get("/api/me", me_route) app.router.add_get("/api/account/avatar", account_avatar_route) app.router.add_post("/api/account/language", account_language_route) diff --git a/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py similarity index 100% rename from bot/app/web/webapp/serializers.py rename to backend/bot/app/web/webapp/serializers.py diff --git a/bot/app/web/webapp_auth.py b/backend/bot/app/web/webapp_auth.py similarity index 100% rename from bot/app/web/webapp_auth.py rename to backend/bot/app/web/webapp_auth.py diff --git a/bot/filters/admin_filter.py b/backend/bot/filters/admin_filter.py similarity index 100% rename from bot/filters/admin_filter.py rename to backend/bot/filters/admin_filter.py diff --git a/bot/handlers/admin/__init__.py b/backend/bot/handlers/admin/__init__.py similarity index 100% rename from bot/handlers/admin/__init__.py rename to backend/bot/handlers/admin/__init__.py diff --git a/bot/handlers/admin/ads.py b/backend/bot/handlers/admin/ads.py similarity index 100% rename from bot/handlers/admin/ads.py rename to backend/bot/handlers/admin/ads.py diff --git a/bot/handlers/admin/broadcast.py b/backend/bot/handlers/admin/broadcast.py similarity index 100% rename from bot/handlers/admin/broadcast.py rename to backend/bot/handlers/admin/broadcast.py diff --git a/bot/handlers/admin/common.py b/backend/bot/handlers/admin/common.py similarity index 100% rename from bot/handlers/admin/common.py rename to backend/bot/handlers/admin/common.py diff --git a/bot/handlers/admin/logs_admin.py b/backend/bot/handlers/admin/logs_admin.py similarity index 100% rename from bot/handlers/admin/logs_admin.py rename to backend/bot/handlers/admin/logs_admin.py diff --git a/bot/handlers/admin/payments.py b/backend/bot/handlers/admin/payments.py similarity index 100% rename from bot/handlers/admin/payments.py rename to backend/bot/handlers/admin/payments.py diff --git a/bot/handlers/admin/promo/__init__.py b/backend/bot/handlers/admin/promo/__init__.py similarity index 100% rename from bot/handlers/admin/promo/__init__.py rename to backend/bot/handlers/admin/promo/__init__.py diff --git a/bot/handlers/admin/promo/bulk.py b/backend/bot/handlers/admin/promo/bulk.py similarity index 100% rename from bot/handlers/admin/promo/bulk.py rename to backend/bot/handlers/admin/promo/bulk.py diff --git a/bot/handlers/admin/promo/create.py b/backend/bot/handlers/admin/promo/create.py similarity index 100% rename from bot/handlers/admin/promo/create.py rename to backend/bot/handlers/admin/promo/create.py diff --git a/bot/handlers/admin/promo/manage.py b/backend/bot/handlers/admin/promo/manage.py similarity index 100% rename from bot/handlers/admin/promo/manage.py rename to backend/bot/handlers/admin/promo/manage.py diff --git a/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py similarity index 100% rename from bot/handlers/admin/statistics.py rename to backend/bot/handlers/admin/statistics.py diff --git a/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py similarity index 100% rename from bot/handlers/admin/sync_admin.py rename to backend/bot/handlers/admin/sync_admin.py diff --git a/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py similarity index 98% rename from bot/handlers/admin/user_management.py rename to backend/bot/handlers/admin/user_management.py index 4d1e0bb..3a14cf8 100644 --- a/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -599,18 +599,29 @@ async def user_action_handler( return if action == "reset_trial": - await handle_reset_trial(callback, user, subscription_service, session, i18n, current_lang) + await handle_reset_trial( + callback, user, subscription_service, session, settings, i18n, current_lang + ) elif action == "add_subscription": await handle_add_subscription_prompt(callback, state, user, i18n, current_lang) elif action == "toggle_ban": - await handle_toggle_ban(callback, user, panel_service, session, i18n, current_lang) + await handle_toggle_ban( + callback, + user, + panel_service, + subscription_service, + session, + settings, + i18n, + current_lang, + ) elif action == "send_message": await handle_send_message_prompt(callback, state, user, i18n, current_lang) elif action == "view_logs": await handle_view_user_logs(callback, user, session, settings, i18n, current_lang) elif action == "refresh": await handle_refresh_user_card( - callback, user, subscription_service, session, i18n, current_lang + callback, user, subscription_service, session, settings, i18n, current_lang ) elif action == "delete_user": await handle_delete_user_prompt( @@ -626,6 +637,7 @@ async def user_action_handler( user, subscription_service, session, + settings, i18n, current_lang, unlimited=True, @@ -637,6 +649,7 @@ async def user_action_handler( user, subscription_service, session, + settings, i18n, current_lang, unlimited=False, @@ -724,6 +737,7 @@ async def handle_premium_override_apply( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, *, @@ -764,7 +778,7 @@ async def handle_premium_override_apply( await callback.answer(_("admin_premium_override_saved"), show_alert=False) await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang + callback, user, subscription_service, session, settings, i18n_instance, lang ) except Exception as exc: logging.error( @@ -867,6 +881,7 @@ async def handle_reset_trial( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -882,7 +897,7 @@ async def handle_reset_trial( # Refresh user card await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang + callback, user, subscription_service, session, settings, i18n_instance, lang ) except Exception as e: @@ -914,7 +929,9 @@ async def handle_toggle_ban( callback: types.CallbackQuery, user: User, panel_service: PanelApiService, + subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -946,15 +963,9 @@ async def handle_toggle_ban( # Refresh user card with updated ban status user.is_banned = new_ban_status # Update local object - from bot.services.panel_api_service import PanelApiService - from config.settings import Settings - - settings = Settings() - async with PanelApiService(settings) as panel_service: - subscription_service = SubscriptionService(settings, panel_service) - await handle_refresh_user_card( - callback, user, subscription_service, session, i18n_instance, lang - ) + await handle_refresh_user_card( + callback, user, subscription_service, session, settings, i18n_instance, lang + ) except Exception as e: logging.error(f"Error toggling ban for user {user.user_id}: {e}") @@ -1050,6 +1061,7 @@ async def handle_refresh_user_card( user: User, subscription_service: SubscriptionService, session: AsyncSession, + settings: Settings, i18n_instance, lang: str, ): @@ -1061,11 +1073,8 @@ async def handle_refresh_user_card( await callback.answer("User not found", show_alert=True) return - from config.settings import Settings as _Settings - - _settings = _Settings() referral_service = ReferralService( - _settings, subscription_service, callback.message.bot, i18n_instance + settings, subscription_service, callback.message.bot, i18n_instance ) bot_username = await _resolve_bot_username(callback.message.bot) user_card_text = await format_user_card( @@ -1075,7 +1084,7 @@ async def handle_refresh_user_card( i18n_instance, lang, referral_service, - settings=_settings, + settings=settings, bot_username=bot_username, ) keyboard = get_user_card_keyboard( diff --git a/bot/handlers/inline_mode.py b/backend/bot/handlers/inline_mode.py similarity index 100% rename from bot/handlers/inline_mode.py rename to backend/bot/handlers/inline_mode.py diff --git a/bot/handlers/user/__init__.py b/backend/bot/handlers/user/__init__.py similarity index 100% rename from bot/handlers/user/__init__.py rename to backend/bot/handlers/user/__init__.py diff --git a/bot/handlers/user/payment.py b/backend/bot/handlers/user/payment.py similarity index 98% rename from bot/handlers/user/payment.py rename to backend/bot/handlers/user/payment.py index b00eaa7..7b21aee 100644 --- a/bot/handlers/user/payment.py +++ b/backend/bot/handlers/user/payment.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker from yookassa.domain.notification import WebhookNotification +from bot.infra.webhook_queue import enqueue_webhook_event from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.middlewares.i18n import JsonI18n from bot.services.lknpd_service import LknpdService @@ -611,6 +612,22 @@ async def yookassa_webhook_route(request: web.Request): "payment_method": pm_dict, } + if notification_object.event in { + YOOKASSA_EVENT_PAYMENT_SUCCEEDED, + YOOKASSA_EVENT_PAYMENT_CANCELED, + }: + queued = await enqueue_webhook_event( + settings, + "yookassa", + { + "event": notification_object.event, + "payment": payment_dict_for_processing, + }, + event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}", + ) + if queued: + return web.Response(status=200, text="queued") + async with payment_processing_lock: async with async_session_factory() as session: try: diff --git a/bot/handlers/user/promo_user.py b/backend/bot/handlers/user/promo_user.py similarity index 100% rename from bot/handlers/user/promo_user.py rename to backend/bot/handlers/user/promo_user.py diff --git a/bot/handlers/user/referral.py b/backend/bot/handlers/user/referral.py similarity index 100% rename from bot/handlers/user/referral.py rename to backend/bot/handlers/user/referral.py diff --git a/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py similarity index 100% rename from bot/handlers/user/start.py rename to backend/bot/handlers/user/start.py diff --git a/bot/handlers/user/subscription/__init__.py b/backend/bot/handlers/user/subscription/__init__.py similarity index 100% rename from bot/handlers/user/subscription/__init__.py rename to backend/bot/handlers/user/subscription/__init__.py diff --git a/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py similarity index 100% rename from bot/handlers/user/subscription/core.py rename to backend/bot/handlers/user/subscription/core.py diff --git a/bot/handlers/user/subscription/payment_methods.py b/backend/bot/handlers/user/subscription/payment_methods.py similarity index 100% rename from bot/handlers/user/subscription/payment_methods.py rename to backend/bot/handlers/user/subscription/payment_methods.py diff --git a/bot/handlers/user/subscription/payments.py b/backend/bot/handlers/user/subscription/payments.py similarity index 100% rename from bot/handlers/user/subscription/payments.py rename to backend/bot/handlers/user/subscription/payments.py diff --git a/bot/handlers/user/subscription/payments_crypto.py b/backend/bot/handlers/user/subscription/payments_crypto.py similarity index 100% rename from bot/handlers/user/subscription/payments_crypto.py rename to backend/bot/handlers/user/subscription/payments_crypto.py diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/backend/bot/handlers/user/subscription/payments_freekassa.py similarity index 100% rename from bot/handlers/user/subscription/payments_freekassa.py rename to backend/bot/handlers/user/subscription/payments_freekassa.py diff --git a/bot/handlers/user/subscription/payments_platega.py b/backend/bot/handlers/user/subscription/payments_platega.py similarity index 100% rename from bot/handlers/user/subscription/payments_platega.py rename to backend/bot/handlers/user/subscription/payments_platega.py diff --git a/bot/handlers/user/subscription/payments_severpay.py b/backend/bot/handlers/user/subscription/payments_severpay.py similarity index 100% rename from bot/handlers/user/subscription/payments_severpay.py rename to backend/bot/handlers/user/subscription/payments_severpay.py diff --git a/bot/handlers/user/subscription/payments_stars.py b/backend/bot/handlers/user/subscription/payments_stars.py similarity index 100% rename from bot/handlers/user/subscription/payments_stars.py rename to backend/bot/handlers/user/subscription/payments_stars.py diff --git a/bot/handlers/user/subscription/payments_subscription.py b/backend/bot/handlers/user/subscription/payments_subscription.py similarity index 100% rename from bot/handlers/user/subscription/payments_subscription.py rename to backend/bot/handlers/user/subscription/payments_subscription.py diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/backend/bot/handlers/user/subscription/payments_yookassa.py similarity index 100% rename from bot/handlers/user/subscription/payments_yookassa.py rename to backend/bot/handlers/user/subscription/payments_yookassa.py diff --git a/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py similarity index 100% rename from bot/handlers/user/trial_handler.py rename to backend/bot/handlers/user/trial_handler.py diff --git a/backend/bot/infra/redis.py b/backend/bot/infra/redis.py new file mode 100644 index 0000000..7703bfc --- /dev/null +++ b/backend/bot/infra/redis.py @@ -0,0 +1,113 @@ +import asyncio +import json +import logging +import secrets +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Optional + +try: + from redis.asyncio import Redis +except ModuleNotFoundError: # pragma: no cover - local dev environments may not be installed yet + Redis = None # type: ignore[assignment] + +from config.settings import Settings + +logger = logging.getLogger(__name__) + +_redis: Optional["Redis"] = None + + +def redis_key(settings: Settings, *parts: object) -> str: + prefix = (settings.REDIS_KEY_PREFIX or "remnawave-tg-shop").strip(":") + clean = [str(part).strip(":") for part in parts if str(part).strip(":")] + return ":".join([prefix, *clean]) + + +async def get_redis(settings: Settings) -> Optional["Redis"]: + global _redis + if not settings.REDIS_URL: + return None + if Redis is None: + logger.warning("REDIS_URL is set but redis package is not installed") + return None + if _redis is None: + _redis = Redis.from_url( + settings.REDIS_URL, + encoding="utf-8", + decode_responses=True, + health_check_interval=30, + ) + return _redis + + +async def close_redis() -> None: + global _redis + if _redis is not None: + await _redis.aclose() + _redis = None + + +async def cache_get_json(settings: Settings, key: str) -> Any: + redis = await get_redis(settings) + if redis is None: + return None + raw = await redis.get(key) + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid JSON in Redis cache key %s", key) + return None + + +async def cache_set_json(settings: Settings, key: str, value: Any, ttl_seconds: int) -> None: + redis = await get_redis(settings) + if redis is None: + return + await redis.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl_seconds) + + +async def cache_delete(settings: Settings, *keys: str) -> None: + redis = await get_redis(settings) + if redis is None or not keys: + return + await redis.delete(*keys) + + +@asynccontextmanager +async def redis_lock( + settings: Settings, + name: str, + *, + ttl_seconds: int, +) -> AsyncIterator[bool]: + redis = await get_redis(settings) + if redis is None: + yield True + return + + key = redis_key(settings, "lock", name) + token = secrets.token_urlsafe(16) + acquired = bool(await redis.set(key, token, nx=True, ex=ttl_seconds)) + try: + yield acquired + finally: + if acquired: + script = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 + """ + try: + await redis.eval(script, 1, key, token) + except Exception: + logger.exception("Failed to release Redis lock %s", key) + + +async def sleep_or_stop(stop_event: asyncio.Event, seconds: float) -> None: + try: + await asyncio.wait_for(stop_event.wait(), timeout=seconds) + except asyncio.TimeoutError: + return diff --git a/backend/bot/infra/webhook_queue.py b/backend/bot/infra/webhook_queue.py new file mode 100644 index 0000000..5d794be --- /dev/null +++ b/backend/bot/infra/webhook_queue.py @@ -0,0 +1,63 @@ +import json +import logging +import time +from typing import Any, Dict, Optional + +from bot.infra.redis import get_redis, redis_key +from config.settings import Settings + +logger = logging.getLogger(__name__) + + +def webhook_queue_key(settings: Settings) -> str: + return redis_key(settings, "queue", settings.WEBHOOK_QUEUE_NAME) + + +async def enqueue_webhook_event( + settings: Settings, + provider: str, + payload: Dict[str, Any], + *, + event_id: Optional[str] = None, +) -> bool: + redis = await get_redis(settings) + if redis is None: + return False + + dedupe_id = event_id or payload.get("id") or payload.get("event_id") + if dedupe_id: + dedupe_key = redis_key(settings, "webhook", "seen", provider, dedupe_id) + if not await redis.set(dedupe_key, "1", nx=True, ex=24 * 60 * 60): + logger.info("Skipping duplicate %s webhook event %s", provider, dedupe_id) + return True + + message = { + "provider": provider, + "event_id": dedupe_id, + "payload": payload, + "enqueued_at": time.time(), + } + await redis.lpush(webhook_queue_key(settings), json.dumps(message, ensure_ascii=False)) + return True + + +async def pop_webhook_event(settings: Settings, timeout_seconds: int = 5) -> Optional[dict]: + redis = await get_redis(settings) + if redis is None: + return None + item = await redis.brpop(webhook_queue_key(settings), timeout=timeout_seconds) + if not item: + return None + _, raw = item + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid webhook queue payload discarded") + return None + + +async def webhook_queue_depth(settings: Settings) -> int: + redis = await get_redis(settings) + if redis is None: + return 0 + return int(await redis.llen(webhook_queue_key(settings))) diff --git a/bot/keyboards/inline/admin_keyboards.py b/backend/bot/keyboards/inline/admin_keyboards.py similarity index 100% rename from bot/keyboards/inline/admin_keyboards.py rename to backend/bot/keyboards/inline/admin_keyboards.py diff --git a/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py similarity index 100% rename from bot/keyboards/inline/user_keyboards.py rename to backend/bot/keyboards/inline/user_keyboards.py diff --git a/bot/main_bot.py b/backend/bot/main_bot.py similarity index 91% rename from bot/main_bot.py rename to backend/bot/main_bot.py index b8b9647..8cf1f81 100644 --- a/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -10,9 +10,11 @@ from bot.app.controllers.dispatcher_controller import build_dispatcher from bot.app.factories.build_services import build_core_services from bot.app.web.web_server import build_and_start_web_app from bot.handlers.admin.sync_admin import perform_sync +from bot.infra.redis import close_redis from bot.middlewares.i18n import JsonI18n from bot.routers import build_root_router from bot.services.panel_api_service import PanelApiService +from bot.services.settings_override_service import load_overrides_from_db from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager from config.settings import Settings @@ -34,9 +36,6 @@ async def on_startup_configured(dispatcher: Dispatcher): bot: Bot = dispatcher["bot_instance"] settings: Settings = dispatcher["settings"] i18n_instance: JsonI18n = dispatcher["i18n_instance"] - panel_service: PanelApiService = dispatcher["panel_service"] - - async_session_factory: sessionmaker = dispatcher["async_session_factory"] logging.info("STARTUP: on_startup_configured executing...") @@ -134,20 +133,6 @@ async def on_startup_configured(dispatcher: Dispatcher): # start serving Telegram webhooks immediately even if the panel is slow. # perform_sync is single-flight, so concurrent admin-triggered runs will # be skipped while this one is in progress. - try: - logging.info("STARTUP: Scheduling automatic panel sync in background...") - asyncio.create_task( - _background_startup_sync( - panel_service=panel_service, - session_factory=async_session_factory, - settings=settings, - i18n_instance=i18n_instance, - ), - name="StartupPanelSync", - ) - except Exception: - logging.exception("STARTUP: Failed to schedule automatic sync.") - logging.info("STARTUP: Bot on_startup_configured completed.") @@ -233,15 +218,22 @@ async def on_shutdown_configured(dispatcher: Dispatcher): logging.info("SHUTDOWN: Disposing SQLAlchemy engine...") await global_async_engine.dispose() logging.info("SHUTDOWN: SQLAlchemy engine disposed.") + await close_redis() logging.info("SHUTDOWN: Bot on_shutdown_configured completed.") -async def run_bot(settings_param: Settings): +async def run_bot( + settings_param: Settings, + *, + run_web: bool = True, + run_tariff_worker: bool = False, +): local_async_session_factory = init_db_connection(settings_param) if local_async_session_factory is None: logging.critical("Failed to initialize database connection and session factory. Exiting.") return + await load_overrides_from_db(settings_param, local_async_session_factory) dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory) i18n_instance = extra["i18n_instance"] @@ -271,15 +263,17 @@ async def run_bot(settings_param: Settings): dp[key] = service dp["panel_service"] = services["panel_service"] dp["async_session_factory"] = local_async_session_factory - tariff_worker = TariffTrafficWorker( - settings_param, - local_async_session_factory, - services["panel_service"], - services["subscription_service"], - bot, - i18n_instance, - ) - dp["tariff_worker"] = tariff_worker + tariff_worker = None + if run_tariff_worker: + tariff_worker = TariffTrafficWorker( + settings_param, + local_async_session_factory, + services["panel_service"], + services["subscription_service"], + bot, + i18n_instance, + ) + dp["tariff_worker"] = tariff_worker # Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed) async def _on_startup_wrapper(): @@ -314,13 +308,14 @@ async def run_bot(settings_param: Settings): async def web_server_task(): await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) - main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) - if settings_param.tariffs_config: + if run_web: + main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + if run_tariff_worker and tariff_worker and settings_param.tariffs_config: main_tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) # Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here. - logging.info("Starting bot in Webhook mode with AIOHTTP server...") + logging.info("Starting bot runtime...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") try: diff --git a/bot/middlewares/action_logger_middleware.py b/backend/bot/middlewares/action_logger_middleware.py similarity index 100% rename from bot/middlewares/action_logger_middleware.py rename to backend/bot/middlewares/action_logger_middleware.py diff --git a/bot/middlewares/ban_check_middleware.py b/backend/bot/middlewares/ban_check_middleware.py similarity index 100% rename from bot/middlewares/ban_check_middleware.py rename to backend/bot/middlewares/ban_check_middleware.py diff --git a/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py similarity index 100% rename from bot/middlewares/channel_subscription.py rename to backend/bot/middlewares/channel_subscription.py diff --git a/bot/middlewares/db_session.py b/backend/bot/middlewares/db_session.py similarity index 100% rename from bot/middlewares/db_session.py rename to backend/bot/middlewares/db_session.py diff --git a/bot/middlewares/i18n.py b/backend/bot/middlewares/i18n.py similarity index 100% rename from bot/middlewares/i18n.py rename to backend/bot/middlewares/i18n.py diff --git a/bot/middlewares/profile_sync.py b/backend/bot/middlewares/profile_sync.py similarity index 100% rename from bot/middlewares/profile_sync.py rename to backend/bot/middlewares/profile_sync.py diff --git a/bot/routers.py b/backend/bot/routers.py similarity index 100% rename from bot/routers.py rename to backend/bot/routers.py diff --git a/bot/services/crypto_pay_service.py b/backend/bot/services/crypto_pay_service.py similarity index 100% rename from bot/services/crypto_pay_service.py rename to backend/bot/services/crypto_pay_service.py diff --git a/bot/services/email_auth_service.py b/backend/bot/services/email_auth_service.py similarity index 100% rename from bot/services/email_auth_service.py rename to backend/bot/services/email_auth_service.py diff --git a/bot/services/email_templates.py b/backend/bot/services/email_templates.py similarity index 100% rename from bot/services/email_templates.py rename to backend/bot/services/email_templates.py diff --git a/bot/services/freekassa_service.py b/backend/bot/services/freekassa_service.py similarity index 100% rename from bot/services/freekassa_service.py rename to backend/bot/services/freekassa_service.py diff --git a/bot/services/lknpd_client.py b/backend/bot/services/lknpd_client.py similarity index 100% rename from bot/services/lknpd_client.py rename to backend/bot/services/lknpd_client.py diff --git a/bot/services/lknpd_service.py b/backend/bot/services/lknpd_service.py similarity index 100% rename from bot/services/lknpd_service.py rename to backend/bot/services/lknpd_service.py diff --git a/bot/services/notification_service.py b/backend/bot/services/notification_service.py similarity index 100% rename from bot/services/notification_service.py rename to backend/bot/services/notification_service.py diff --git a/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py similarity index 98% rename from bot/services/panel_api_service.py rename to backend/bot/services/panel_api_service.py index eb1d666..2f0e980 100644 --- a/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -2,6 +2,7 @@ import asyncio import json import logging import re +import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from urllib.parse import urlencode @@ -30,8 +31,16 @@ class PanelApiService: self.default_client_ip = "127.0.0.1" # Cache slow-changing reference data fetched from the panel. Errors and # None responses are not cached, so transient failures self-heal. - self._squads_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) - self._hosts_cache: AsyncTTLCache = AsyncTTLCache(ttl_seconds=300) + self._squads_cache: AsyncTTLCache = AsyncTTLCache( + ttl_seconds=300, + settings=settings, + namespace="panel:squads", + ) + self._hosts_cache: AsyncTTLCache = AsyncTTLCache( + ttl_seconds=300, + settings=settings, + namespace="panel:hosts", + ) async def __aenter__(self): """Context manager entry""" @@ -132,11 +141,19 @@ class PanelApiService: except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." try: + started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: response_status = response.status response_text = await response.text() + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s", + time.monotonic() - started, + method.upper(), + endpoint, + response_status, + ) log_suffix = f"| Status: {response_status}" diff --git a/bot/services/panel_webhook_service.py b/backend/bot/services/panel_webhook_service.py similarity index 95% rename from bot/services/panel_webhook_service.py rename to backend/bot/services/panel_webhook_service.py index 63be592..3320f64 100644 --- a/bot/services/panel_webhook_service.py +++ b/backend/bot/services/panel_webhook_service.py @@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup from aiohttp import web from sqlalchemy.orm import sessionmaker +from bot.infra.webhook_queue import enqueue_webhook_event from bot.keyboards.inline.user_keyboards import ( get_autorenew_cancel_keyboard, get_subscribe_only_markup, @@ -249,13 +250,20 @@ class PanelWebhookService: telegram_id if telegram_id is not None else "N/A", ) - # Acknowledge immediately so the panel does not retry while we run - # auto-renew charges, DB lookups and SMTP. Heavy work is bounded by a - # semaphore to survive expiry bursts. - asyncio.create_task( - self._run_event_in_background(event_name, user_data), - name=f"panel_event_{event_name}", + queued = await enqueue_webhook_event( + self.settings, + "panel", + {"event": event_name, "user": user_data}, + event_id=( + f"{event_name}:" + f"{telegram_id or user_data.get('uuid') or user_data.get('shortUuid')}" + ), ) + if not queued: + asyncio.create_task( + self._run_event_in_background(event_name, user_data), + name=f"panel_event_{event_name}", + ) return web.Response(status=200, text="ok") async def _run_event_in_background(self, event_name: str, user_payload: dict) -> None: diff --git a/bot/services/platega_service.py b/backend/bot/services/platega_service.py similarity index 100% rename from bot/services/platega_service.py rename to backend/bot/services/platega_service.py diff --git a/bot/services/promo_code_service.py b/backend/bot/services/promo_code_service.py similarity index 100% rename from bot/services/promo_code_service.py rename to backend/bot/services/promo_code_service.py diff --git a/bot/services/referral_service.py b/backend/bot/services/referral_service.py similarity index 100% rename from bot/services/referral_service.py rename to backend/bot/services/referral_service.py diff --git a/bot/services/settings_override_service.py b/backend/bot/services/settings_override_service.py similarity index 56% rename from bot/services/settings_override_service.py rename to backend/bot/services/settings_override_service.py index 413aa5d..d4d8ca6 100644 --- a/bot/services/settings_override_service.py +++ b/backend/bot/services/settings_override_service.py @@ -9,7 +9,9 @@ without restarting the container. from __future__ import annotations +import json import logging +from pathlib import Path from typing import Any, Dict, Optional from sqlalchemy.orm import sessionmaker @@ -25,6 +27,19 @@ from db.dal import app_settings_dal logger = logging.getLogger(__name__) +APPEARANCE_OVERRIDE_KEYS = { + "WEBAPP_LOGO_USE_EMOJI", + "WEBAPP_LOGO_URL", + "WEBAPP_LOGO_EMOJI", + "WEBAPP_LOGO_EMOJI_FONT", + "WEBAPP_FAVICON_USE_CUSTOM", + "WEBAPP_FAVICON_URL", + "WEBAPP_LOGO_FAVICON_URL", + "WEBAPP_PRIMARY_COLOR", +} +APP_ROOT = Path(__file__).resolve().parents[3] +APPEARANCE_OVERRIDES_BACKUP_PATH = APP_ROOT / "data" / "webapp-logo" / "appearance-settings.json" + def _resolve_attribute_name(settings: Settings, key: str) -> Optional[str]: """Resolve the actual attribute name on the Settings model. @@ -73,12 +88,97 @@ def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int: return applied +def _appearance_snapshot(settings: Settings) -> Dict[str, Any]: + snapshot: Dict[str, Any] = {} + logo_url = getattr(settings, "WEBAPP_LOGO_URL", None) + logo_favicon_url = getattr(settings, "WEBAPP_LOGO_FAVICON_URL", None) + favicon_url = getattr(settings, "WEBAPP_FAVICON_URL", None) + if logo_url: + snapshot["WEBAPP_LOGO_URL"] = logo_url + if logo_favicon_url: + snapshot["WEBAPP_LOGO_FAVICON_URL"] = logo_favicon_url + if favicon_url: + snapshot["WEBAPP_FAVICON_URL"] = favicon_url + if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False): + snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True + if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False): + snapshot["WEBAPP_LOGO_USE_EMOJI"] = True + snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "") + emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") + if emoji_font and emoji_font != "system": + snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font + primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) + if primary_color and primary_color != "#00fe7a": + snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color + return snapshot + + +def _read_appearance_backup() -> Dict[str, Any]: + try: + payload = json.loads(APPEARANCE_OVERRIDES_BACKUP_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read appearance settings backup: %s", exc) + return {} + if not isinstance(payload, dict): + return {} + values = payload.get("settings") if isinstance(payload.get("settings"), dict) else payload + restored: Dict[str, Any] = {} + for key, value in values.items(): + if key not in APPEARANCE_OVERRIDE_KEYS: + continue + if value in (None, "") or value is False: + continue + field = get_field_by_key(key) + if not field: + continue + try: + restored[key] = coerce_value(field, value) + except ValueError as exc: + logger.warning("Skipping appearance backup key %s: %s", key, exc) + return restored + + +def write_appearance_backup(settings: Settings) -> None: + payload = { + "version": 1, + "settings": _appearance_snapshot(settings), + } + try: + APPEARANCE_OVERRIDES_BACKUP_PATH.parent.mkdir(parents=True, exist_ok=True) + APPEARANCE_OVERRIDES_BACKUP_PATH.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + except OSError as exc: + logger.warning("Failed to write appearance settings backup: %s", exc) + + async def load_overrides_from_db(settings: Settings, async_session_factory: sessionmaker) -> int: """Fetch overrides from the DB and apply them to the in-memory settings.""" try: async with async_session_factory() as session: overrides = await app_settings_dal.get_all_overrides(session) + backup_overrides = _read_appearance_backup() + missing_backup_overrides = { + key: value + for key, value in backup_overrides.items() + if key not in overrides + } + if missing_backup_overrides: + for key, value in missing_backup_overrides.items(): + await app_settings_dal.upsert_override( + session, key=key, value=value, updated_by=None + ) + await session.commit() + overrides.update(missing_backup_overrides) + logger.info( + "Restored %s appearance setting overrides from %s", + len(missing_backup_overrides), + APPEARANCE_OVERRIDES_BACKUP_PATH, + ) except Exception as exc: logger.warning("Could not load setting overrides from DB: %s", exc) return 0 @@ -146,6 +246,11 @@ async def update_overrides( logger.warning("Failed to restore env defaults: %s", exc) apply_overrides(settings, coerced_updates) + appearance_changed = APPEARANCE_OVERRIDE_KEYS.intersection( + coerced_updates + ) or APPEARANCE_OVERRIDE_KEYS.intersection(valid_deletes) + if appearance_changed: + write_appearance_backup(settings) return {"ok": True, "applied": len(coerced_updates), "reverted": len(valid_deletes)} diff --git a/bot/services/severpay_service.py b/backend/bot/services/severpay_service.py similarity index 100% rename from bot/services/severpay_service.py rename to backend/bot/services/severpay_service.py diff --git a/bot/services/stars_service.py b/backend/bot/services/stars_service.py similarity index 100% rename from bot/services/stars_service.py rename to backend/bot/services/stars_service.py diff --git a/bot/services/subscription_service.py b/backend/bot/services/subscription_service.py similarity index 100% rename from bot/services/subscription_service.py rename to backend/bot/services/subscription_service.py diff --git a/bot/services/subscription_service_impl/__init__.py b/backend/bot/services/subscription_service_impl/__init__.py similarity index 100% rename from bot/services/subscription_service_impl/__init__.py rename to backend/bot/services/subscription_service_impl/__init__.py diff --git a/bot/services/subscription_service_impl/_runtime.py b/backend/bot/services/subscription_service_impl/_runtime.py similarity index 100% rename from bot/services/subscription_service_impl/_runtime.py rename to backend/bot/services/subscription_service_impl/_runtime.py diff --git a/bot/services/subscription_service_impl/core.py b/backend/bot/services/subscription_service_impl/core.py similarity index 100% rename from bot/services/subscription_service_impl/core.py rename to backend/bot/services/subscription_service_impl/core.py diff --git a/bot/services/subscription_service_impl/devices.py b/backend/bot/services/subscription_service_impl/devices.py similarity index 100% rename from bot/services/subscription_service_impl/devices.py rename to backend/bot/services/subscription_service_impl/devices.py diff --git a/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py similarity index 100% rename from bot/services/subscription_service_impl/lifecycle.py rename to backend/bot/services/subscription_service_impl/lifecycle.py diff --git a/bot/services/subscription_service_impl/panel_identity.py b/backend/bot/services/subscription_service_impl/panel_identity.py similarity index 100% rename from bot/services/subscription_service_impl/panel_identity.py rename to backend/bot/services/subscription_service_impl/panel_identity.py diff --git a/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py similarity index 100% rename from bot/services/subscription_service_impl/payments.py rename to backend/bot/services/subscription_service_impl/payments.py diff --git a/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py similarity index 100% rename from bot/services/subscription_service_impl/renewal.py rename to backend/bot/services/subscription_service_impl/renewal.py diff --git a/bot/services/subscription_service_impl/tariffs.py b/backend/bot/services/subscription_service_impl/tariffs.py similarity index 100% rename from bot/services/subscription_service_impl/tariffs.py rename to backend/bot/services/subscription_service_impl/tariffs.py diff --git a/bot/services/subscription_service_impl/traffic.py b/backend/bot/services/subscription_service_impl/traffic.py similarity index 100% rename from bot/services/subscription_service_impl/traffic.py rename to backend/bot/services/subscription_service_impl/traffic.py diff --git a/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py similarity index 98% rename from bot/services/subscription_service_impl/trial.py rename to backend/bot/services/subscription_service_impl/trial.py index 23dcfcb..59f2a50 100644 --- a/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -61,7 +61,6 @@ class TrialSubscriptionMixin: "is_active": True, "status_from_panel": "TRIAL", "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, - "traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY, "auto_renew_enabled": False, } try: diff --git a/bot/services/tariff_worker.py b/backend/bot/services/tariff_worker.py similarity index 93% rename from bot/services/tariff_worker.py rename to backend/bot/services/tariff_worker.py index c0e2351..88c2c21 100644 --- a/bot/services/tariff_worker.py +++ b/backend/bot/services/tariff_worker.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from datetime import datetime, timezone from typing import Optional @@ -10,6 +11,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker +from bot.infra.redis import redis_lock from bot.middlewares.i18n import JsonI18n from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService @@ -47,6 +49,7 @@ class TariffTrafficWorker: self.i18n = i18n self._stopped = asyncio.Event() self._premium_nodes_cache = {} + self._premium_node_stats_tick_cache = {} async def _user_lang(self, session: AsyncSession, user_id: int) -> str: try: @@ -96,16 +99,32 @@ class TariffTrafficWorker: return while not self._stopped.is_set(): try: - async with self.session_factory() as session: - await self.traffic_period_tick(session) - await session.commit() - async with self.session_factory() as session: - await self.legacy_throttle_recovery_tick(session) - await session.commit() + async with redis_lock( + self.settings, + "tariff-traffic-worker", + ttl_seconds=self.settings.TARIFF_WORKER_LOCK_TTL_SECONDS, + ) as acquired: + if not acquired: + logging.info("TariffTrafficWorker tick skipped: Redis lock is held") + else: + started = time.monotonic() + async with self.session_factory() as session: + await self.traffic_period_tick(session) + await session.commit() + async with self.session_factory() as session: + await self.legacy_throttle_recovery_tick(session) + await session.commit() + logging.info( + "metric worker_tick_duration_seconds=%.3f worker=tariff", + time.monotonic() - started, + ) except Exception: logging.exception("TariffTrafficWorker tick failed") try: - await asyncio.wait_for(self._stopped.wait(), timeout=300) + await asyncio.wait_for( + self._stopped.wait(), + timeout=self.settings.TARIFF_WORKER_TICK_SECONDS, + ) except asyncio.TimeoutError: pass @@ -114,6 +133,7 @@ class TariffTrafficWorker: async def traffic_period_tick(self, session: AsyncSession) -> None: now = datetime.now(timezone.utc) + self._premium_node_stats_tick_cache = {} warning_period_start = month_start(now) result = await session.execute( select(Subscription).where( @@ -622,11 +642,16 @@ class TariffTrafficWorker: found = False username = (panel_username or "").strip() or None for node_uuid in node_uuids: - stats = await self.panel_service.get_node_users_bandwidth_stats( - node_uuid, - start=start_date, - end=end_date, - ) + stats_cache_key = (node_uuid, start_date, end_date) + if stats_cache_key not in self._premium_node_stats_tick_cache: + self._premium_node_stats_tick_cache[stats_cache_key] = ( + await self.panel_service.get_node_users_bandwidth_stats( + node_uuid, + start=start_date, + end=end_date, + ) + ) + stats = self._premium_node_stats_tick_cache.get(stats_cache_key) if not stats: continue entries = stats.get("topUsers") or stats.get("usersStats") or stats.get("users") or [] diff --git a/bot/services/yookassa_service.py b/backend/bot/services/yookassa_service.py similarity index 100% rename from bot/services/yookassa_service.py rename to backend/bot/services/yookassa_service.py diff --git a/bot/states/admin_states.py b/backend/bot/states/admin_states.py similarity index 100% rename from bot/states/admin_states.py rename to backend/bot/states/admin_states.py diff --git a/bot/states/user_states.py b/backend/bot/states/user_states.py similarity index 100% rename from bot/states/user_states.py rename to backend/bot/states/user_states.py diff --git a/bot/utils/__init__.py b/backend/bot/utils/__init__.py similarity index 100% rename from bot/utils/__init__.py rename to backend/bot/utils/__init__.py diff --git a/bot/utils/callback_answer.py b/backend/bot/utils/callback_answer.py similarity index 100% rename from bot/utils/callback_answer.py rename to backend/bot/utils/callback_answer.py diff --git a/bot/utils/config_link.py b/backend/bot/utils/config_link.py similarity index 100% rename from bot/utils/config_link.py rename to backend/bot/utils/config_link.py diff --git a/bot/utils/date_utils.py b/backend/bot/utils/date_utils.py similarity index 100% rename from bot/utils/date_utils.py rename to backend/bot/utils/date_utils.py diff --git a/bot/utils/message_queue.py b/backend/bot/utils/message_queue.py similarity index 100% rename from bot/utils/message_queue.py rename to backend/bot/utils/message_queue.py diff --git a/bot/utils/mini_app_url.py b/backend/bot/utils/mini_app_url.py similarity index 100% rename from bot/utils/mini_app_url.py rename to backend/bot/utils/mini_app_url.py diff --git a/bot/utils/request_security.py b/backend/bot/utils/request_security.py similarity index 100% rename from bot/utils/request_security.py rename to backend/bot/utils/request_security.py diff --git a/bot/utils/telegram_markup.py b/backend/bot/utils/telegram_markup.py similarity index 100% rename from bot/utils/telegram_markup.py rename to backend/bot/utils/telegram_markup.py diff --git a/bot/utils/text_sanitizer.py b/backend/bot/utils/text_sanitizer.py similarity index 100% rename from bot/utils/text_sanitizer.py rename to backend/bot/utils/text_sanitizer.py diff --git a/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py similarity index 64% rename from bot/utils/ttl_cache.py rename to backend/bot/utils/ttl_cache.py index bf5bd1f..a65ad6a 100644 --- a/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -9,8 +9,10 @@ class AsyncTTLCache: Concurrent get_or_load() calls for the same key share one loader execution. """ - def __init__(self, ttl_seconds: float): + def __init__(self, ttl_seconds: float, settings: Any = None, namespace: Optional[str] = None): self.ttl_seconds = ttl_seconds + self.settings = settings + self.namespace = namespace self._data: Dict[str, Tuple[float, Any]] = {} self._locks: Dict[str, asyncio.Lock] = {} @@ -35,6 +37,26 @@ class AsyncTTLCache: return True async def get_or_load(self, key: str, loader: Callable[[], Awaitable[Any]]) -> Any: + if self.settings is not None and self.namespace: + try: + from bot.infra.redis import cache_get_json, cache_set_json, redis_key + + cache_key = redis_key(self.settings, "cache", self.namespace, key) + cached = await cache_get_json(self.settings, cache_key) + if cached is not None: + return cached + value = await loader() + if self._is_cacheable(value): + await cache_set_json( + self.settings, + cache_key, + value, + max(1, int(self.ttl_seconds)), + ) + return value + except Exception: + pass + cached = self.get_fresh(key) if cached is not None: return cached diff --git a/config/settings.py b/backend/config/settings.py similarity index 98% rename from config/settings.py rename to backend/config/settings.py index 99ea47a..e845a3c 100644 --- a/config/settings.py +++ b/backend/config/settings.py @@ -120,6 +120,21 @@ class Settings(BaseSettings): POSTGRES_HOST: str = Field(default="localhost") POSTGRES_PORT: int = Field(default=5432) POSTGRES_DB: str = Field(default="vpn_shop_db") + DB_POOL_SIZE: int = Field(default=20) + DB_MAX_OVERFLOW: int = Field(default=10) + DB_POOL_TIMEOUT_SECONDS: int = Field(default=30) + DB_POOL_RECYCLE_SECONDS: int = Field(default=1800) + + REDIS_URL: Optional[str] = Field(default=None) + REDIS_KEY_PREFIX: str = Field(default="remnawave-tg-shop") + WEBAPP_ME_CACHE_TTL_SECONDS: int = Field(default=15) + WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60) + WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30) + WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events") + WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4) + WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900) + TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240) + TARIFF_WORKER_TICK_SECONDS: int = Field(default=300) DEFAULT_LANGUAGE: str = Field(default="ru") DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB") diff --git a/config/tariffs_config.py b/backend/config/tariffs_config.py similarity index 100% rename from config/tariffs_config.py rename to backend/config/tariffs_config.py diff --git a/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py similarity index 100% rename from config/webapp_themes_config.py rename to backend/config/webapp_themes_config.py diff --git a/db/dal/__init__.py b/backend/db/dal/__init__.py similarity index 100% rename from db/dal/__init__.py rename to backend/db/dal/__init__.py diff --git a/db/dal/ad_dal.py b/backend/db/dal/ad_dal.py similarity index 100% rename from db/dal/ad_dal.py rename to backend/db/dal/ad_dal.py diff --git a/db/dal/app_settings_dal.py b/backend/db/dal/app_settings_dal.py similarity index 100% rename from db/dal/app_settings_dal.py rename to backend/db/dal/app_settings_dal.py diff --git a/db/dal/message_log_dal.py b/backend/db/dal/message_log_dal.py similarity index 100% rename from db/dal/message_log_dal.py rename to backend/db/dal/message_log_dal.py diff --git a/db/dal/panel_sync_dal.py b/backend/db/dal/panel_sync_dal.py similarity index 100% rename from db/dal/panel_sync_dal.py rename to backend/db/dal/panel_sync_dal.py diff --git a/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py similarity index 100% rename from db/dal/payment_dal.py rename to backend/db/dal/payment_dal.py diff --git a/db/dal/promo_code_dal.py b/backend/db/dal/promo_code_dal.py similarity index 100% rename from db/dal/promo_code_dal.py rename to backend/db/dal/promo_code_dal.py diff --git a/db/dal/security_dal.py b/backend/db/dal/security_dal.py similarity index 100% rename from db/dal/security_dal.py rename to backend/db/dal/security_dal.py diff --git a/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py similarity index 93% rename from db/dal/subscription_dal.py rename to backend/db/dal/subscription_dal.py index b02c1c3..e168c31 100644 --- a/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -10,6 +10,15 @@ from sqlalchemy.orm import selectinload from db.models import Subscription +def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: + model_columns = Subscription.__mapper__.columns.keys() + filtered_payload = {key: value for key, value in sub_payload.items() if key in model_columns} + ignored_keys = sorted(set(sub_payload) - set(filtered_payload)) + if ignored_keys: + logging.warning("Ignoring unsupported subscription payload keys: %s", ignored_keys) + return filtered_payload + + async def get_active_subscription_by_user_id( session: AsyncSession, user_id: int, panel_user_uuid: Optional[str] = None ) -> Optional[Subscription]: @@ -101,9 +110,8 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] logging.info( f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" # noqa: E501 ) - for key, value in sub_payload.items(): - if hasattr(existing_sub, key): - setattr(existing_sub, key, value) + for key, value in _subscription_model_payload(sub_payload).items(): + setattr(existing_sub, key, value) await session.flush() await session.refresh(existing_sub) return existing_sub @@ -123,7 +131,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." # noqa: E501 ) - new_sub = Subscription(**sub_payload) + new_sub = Subscription(**_subscription_model_payload(sub_payload)) session.add(new_sub) await session.flush() await session.refresh(new_sub) diff --git a/db/dal/tariff_dal.py b/backend/db/dal/tariff_dal.py similarity index 100% rename from db/dal/tariff_dal.py rename to backend/db/dal/tariff_dal.py diff --git a/db/dal/user_billing_dal.py b/backend/db/dal/user_billing_dal.py similarity index 100% rename from db/dal/user_billing_dal.py rename to backend/db/dal/user_billing_dal.py diff --git a/db/dal/user_dal.py b/backend/db/dal/user_dal.py similarity index 100% rename from db/dal/user_dal.py rename to backend/db/dal/user_dal.py diff --git a/db/database_setup.py b/backend/db/database_setup.py similarity index 96% rename from db/database_setup.py rename to backend/db/database_setup.py index 5763187..43aa367 100644 --- a/db/database_setup.py +++ b/backend/db/database_setup.py @@ -31,8 +31,10 @@ def init_db_connection(settings: Settings) -> sessionmaker: settings.DATABASE_URL, echo=False, pool_pre_ping=True, - pool_size=20, - max_overflow=10, + pool_size=settings.DB_POOL_SIZE, + max_overflow=settings.DB_MAX_OVERFLOW, + pool_timeout=settings.DB_POOL_TIMEOUT_SECONDS, + pool_recycle=settings.DB_POOL_RECYCLE_SECONDS, ) local_async_session_factory = async_sessionmaker( diff --git a/db/migrator.py b/backend/db/migrator.py similarity index 100% rename from db/migrator.py rename to backend/db/migrator.py diff --git a/db/models.py b/backend/db/models.py similarity index 100% rename from db/models.py rename to backend/db/models.py diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b8f2676 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,36 @@ +import asyncio +import logging +import os +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from bot.main_bot import run_bot +from config.settings import get_settings + + +def _resolve_log_level(value: str) -> int: + return getattr(logging, value.upper(), logging.INFO) + + +async def main(): + settings = get_settings() + await run_bot(settings, run_web=True, run_tariff_worker=False) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("bot") + logging.basicConfig( + level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), + stream=sys.stdout, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Bot stopped manually") + except Exception as e_global: + logging.critical(f"Global unhandled exception in main: {e_global}", exc_info=True) + sys.exit(1) diff --git a/backend/main_backend.py b/backend/main_backend.py new file mode 100644 index 0000000..9916f79 --- /dev/null +++ b/backend/main_backend.py @@ -0,0 +1,28 @@ +import asyncio +import logging +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from app_logging import configure_logging +from bot.main_bot import run_bot +from config.settings import get_settings + + +async def main() -> None: + settings = get_settings() + await run_bot(settings, run_web=True, run_tariff_worker=False) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("backend") + configure_logging() + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Backend stopped") + except Exception as exc: + logging.critical("Backend failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/backend/main_migrate.py b/backend/main_migrate.py new file mode 100644 index 0000000..4ff1f54 --- /dev/null +++ b/backend/main_migrate.py @@ -0,0 +1,27 @@ +import asyncio +import logging +import sys + +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +from app_logging import configure_logging +from config.settings import get_settings +from db.database_setup import init_db, init_db_connection + + +async def main() -> None: + settings = get_settings() + session_factory = init_db_connection(settings) + await init_db(settings, session_factory) + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("migrate") + configure_logging() + try: + asyncio.run(main()) + except Exception as exc: + logging.critical("Migration failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/backend/main_worker.py b/backend/main_worker.py new file mode 100644 index 0000000..f86b730 --- /dev/null +++ b/backend/main_worker.py @@ -0,0 +1,189 @@ +import asyncio +import logging +import sys +import time + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from dotenv import load_dotenv +from startup_banner import print_startup_banner + +import db.database_setup as database_setup +from app_logging import configure_logging +from bot.app.factories.build_services import build_core_services +from bot.handlers.admin.sync_admin import perform_sync +from bot.handlers.user.payment import ( + YOOKASSA_EVENT_PAYMENT_CANCELED, + YOOKASSA_EVENT_PAYMENT_SUCCEEDED, + payment_processing_lock, + process_cancelled_payment, + process_successful_payment, +) +from bot.infra.redis import close_redis, redis_lock +from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth +from bot.middlewares.i18n import get_i18n_instance +from bot.services.settings_override_service import load_overrides_from_db +from bot.services.tariff_worker import TariffTrafficWorker +from bot.utils.message_queue import init_queue_manager +from config.settings import get_settings + + +async def _build_worker_context(settings): + session_factory = database_setup.init_db_connection(settings) + await load_overrides_from_db(settings, session_factory) + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + i18n = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE) + bot_username = "your_bot_username" + try: + bot_info = await bot.get_me() + bot_username = bot_info.username or bot_username + except Exception: + logging.exception("Worker failed to resolve bot username") + services = build_core_services(settings, bot, session_factory, i18n, bot_username) + init_queue_manager(bot) + return session_factory, bot, i18n, services + + +async def _webhook_consumer(settings, session_factory, bot, i18n, services): + while True: + event = await pop_webhook_event(settings) + if not event: + continue + provider = event.get("provider") + payload = event.get("payload") or {} + started = time.monotonic() + try: + if provider == "yookassa": + payment_payload = payload.get("payment") or {} + async with payment_processing_lock: + async with session_factory() as session: + if payload.get("event") == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: + await process_successful_payment( + session, + bot, + payment_payload, + i18n, + settings, + services["panel_service"], + services["subscription_service"], + services["referral_service"], + services.get("lknpd_service"), + ) + elif payload.get("event") == YOOKASSA_EVENT_PAYMENT_CANCELED: + await process_cancelled_payment( + session, + bot, + payment_payload, + i18n, + settings, + ) + await session.commit() + elif provider == "panel": + await services["panel_webhook_service"].handle_event( + str(payload.get("event") or ""), + payload.get("user") or {}, + ) + elif provider == "panel_sync": + async with redis_lock( + settings, + "panel-sync", + ttl_seconds=max(60, settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS - 10), + ) as acquired: + if acquired: + async with session_factory() as session: + await perform_sync( + panel_service=services["panel_service"], + session=session, + settings=settings, + i18n_instance=i18n, + ) + else: + logging.warning("Unknown webhook event provider: %s", provider) + except Exception: + logging.exception("Webhook queue event failed: %s", event.get("event_id")) + finally: + depth = await webhook_queue_depth(settings) + logging.info( + "metric webhook_event_duration_seconds=%.3f provider=%s queue_depth=%s", + time.monotonic() - started, + provider, + depth, + ) + + +async def _panel_sync_loop(settings, session_factory, i18n, services): + while True: + try: + async with redis_lock( + settings, + "panel-sync", + ttl_seconds=max(60, settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS - 10), + ) as acquired: + if acquired: + started = time.monotonic() + async with session_factory() as session: + await perform_sync( + panel_service=services["panel_service"], + session=session, + settings=settings, + i18n_instance=i18n, + ) + logging.info( + "metric worker_tick_duration_seconds=%.3f worker=panel_sync", + time.monotonic() - started, + ) + except Exception: + logging.exception("Panel sync worker tick failed") + await asyncio.sleep(settings.WORKER_PANEL_SYNC_INTERVAL_SECONDS) + + +async def main() -> None: + settings = get_settings() + session_factory, bot, i18n, services = await _build_worker_context(settings) + tariff_worker = TariffTrafficWorker( + settings, + session_factory, + services["panel_service"], + services["subscription_service"], + bot, + i18n, + ) + tasks = [] + if settings.tariffs_config: + tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) + tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services))) + for idx in range(max(1, settings.WEBHOOK_QUEUE_CONCURRENCY)): + tasks.append( + asyncio.create_task( + _webhook_consumer(settings, session_factory, bot, i18n, services), + name=f"WebhookConsumer{idx + 1}", + ) + ) + try: + await asyncio.gather(*tasks) + finally: + for service in services.values(): + close = getattr(service, "close", None) or getattr(service, "close_session", None) + if callable(close): + await close() + await bot.session.close() + await close_redis() + if database_setup.async_engine: + await database_setup.async_engine.dispose() + + +if __name__ == "__main__": + load_dotenv() + print_startup_banner("worker") + configure_logging() + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logging.info("Worker stopped") + except Exception as exc: + logging.critical("Worker failed: %s", exc, exc_info=True) + sys.exit(1) diff --git a/requirements.txt b/backend/requirements.txt similarity index 95% rename from requirements.txt rename to backend/requirements.txt index 55c818f..b1aacda 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ asyncpg==0.31.0 aiocryptopay==0.4.8 PyJWT[crypto]==2.12.1 Pillow==12.2.0 +redis==6.4.0 diff --git a/backend/startup_banner.py b/backend/startup_banner.py new file mode 100644 index 0000000..a7ab6da --- /dev/null +++ b/backend/startup_banner.py @@ -0,0 +1,109 @@ +import os +import sys +from urllib.parse import urlsplit + +_BANNER_TEMPLATE = """ + ~ ~ ~ r e m n a w a v e ~ ~ ~ + + ███╗ ███╗██╗███╗ ██╗██╗███████╗██╗ ██╗ ██████╗ ██████╗ + ████╗ ████║██║████╗ ██║██║██╔════╝██║ ██║██╔═══██╗██╔══██╗ + ██╔████╔██║██║██╔██╗ ██║██║███████╗███████║██║ ██║██████╔╝ + ██║╚██╔╝██║██║██║╚██╗██║██║╚════██║██╔══██║██║ ██║██╔═══╝ + ██║ ╚═╝ ██║██║██║ ╚████║██║███████║██║ ██║╚██████╔╝██║ + ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ + + container :: {service} +{details} + https://github.com/3252a8/remnawave-minishop +""" + + +def _env(name: str, default: str = "-") -> str: + return os.getenv(name) or default + + +def _bool_env(name: str) -> str: + value = _env(name, "").lower() + if value in {"1", "true", "yes", "on"}: + return "on" + if value in {"0", "false", "no", "off"}: + return "off" + return _env(name) + + +def _redis_target() -> str: + value = _env("REDIS_URL") + try: + parsed = urlsplit(value) + except ValueError: + return "-" + if not parsed.hostname: + return "-" + port = f":{parsed.port}" if parsed.port else "" + db = parsed.path.lstrip("/") or "0" + return f"{parsed.hostname}{port}/{db}" + + +def _postgres_target() -> str: + return f"{_env('POSTGRES_HOST')}:{_env('POSTGRES_PORT', '5432')}/{_env('POSTGRES_DB')}" + + +def _detail_line(text: str) -> str: + return f" {text[:64]}" + + +def _service_details(service: str) -> str: + image_tag = _env("IMAGE_TAG", "local") + log_level = _env("LOG_LEVEL", "INFO") + common = [f"image tag :: {image_tag}", f"log level :: {log_level}"] + if service == "backend": + lines = [ + *common, + f"webhooks :: :{_env('WEB_SERVER_PORT', '8080')}", + f"webapp api :: {_bool_env('WEBAPP_ENABLED')} / :{_env('WEBAPP_SERVER_PORT', '8081')}", + f"postgres :: {_postgres_target()}", + f"redis :: {_redis_target()}", + ] + elif service == "worker": + panel_sync_interval = _env("WORKER_PANEL_SYNC_INTERVAL_SECONDS") + panel_sync_interval = f"{panel_sync_interval}s" if panel_sync_interval != "-" else "-" + lines = [ + *common, + f"queue concurrency :: {_env('WEBHOOK_QUEUE_CONCURRENCY', '1')}", + f"panel sync interval :: {panel_sync_interval}", + f"tariffs config :: {_env('TARIFFS_CONFIG_PATH', '-')}", + f"postgres :: {_postgres_target()}", + f"redis :: {_redis_target()}", + ] + elif service == "migrate": + lines = [ + *common, + "mode :: one-shot migrations", + f"postgres :: {_postgres_target()}", + "data dir :: /app/data", + ] + else: + lines = common + return "\n".join(_detail_line(line) for line in lines) + + +def print_startup_banner(service: str) -> None: + normalized_service = service.lower() + banner = ( + _BANNER_TEMPLATE.format( + service=normalized_service.upper(), + details=_service_details(normalized_service), + ) + + "\n" + ) + try: + sys.stdout.write(banner) + sys.stdout.flush() + except UnicodeEncodeError: + try: + sys.stdout.buffer.write(banner.encode("utf-8")) + sys.stdout.buffer.flush() + except Exception: + pass + except Exception: + pass diff --git a/bot/app/web/admin_api_impl/sync.py b/bot/app/web/admin_api_impl/sync.py deleted file mode 100644 index ec62cac..0000000 --- a/bot/app/web/admin_api_impl/sync.py +++ /dev/null @@ -1,23 +0,0 @@ -# ruff: noqa: F401,F403,F405,I001 -from ._runtime import * # noqa: F403,F405 - - -async def admin_sync_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - panel_service = request.app.get("panel_service") - if panel_service is None: - return _error(503, "panel_unavailable") - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - i18n = request.app.get("i18n") - - from bot.handlers.admin.sync_admin import perform_sync - - async with async_session_factory() as session: - result = await perform_sync( - panel_service=panel_service, - session=session, - settings=settings, - i18n_instance=i18n, - ) - return _ok({"result": result or {}}) diff --git a/bot/app/web/frontend/src/main.js b/bot/app/web/frontend/src/main.js deleted file mode 100644 index 4eb14c2..0000000 --- a/bot/app/web/frontend/src/main.js +++ /dev/null @@ -1,10 +0,0 @@ -import { mount } from "svelte"; - -import App from "./App.svelte"; -import "./styles.css"; - -const target = document.getElementById("app"); - -if (target) { - mount(App, { target }); -} diff --git a/deploy/compose/docker-compose-caddy.yml b/deploy/compose/docker-compose-caddy.yml new file mode 100644 index 0000000..d1c64fe --- /dev/null +++ b/deploy/compose/docker-compose-caddy.yml @@ -0,0 +1,157 @@ +services: + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + env_file: + - ${APP_ENV_FILE:-../../.env} + volumes: + - remnawave-minishop-db-data:/var/lib/postgresql/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - remnawave-minishop-redis-data:/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + backend: + container_name: remnawave-minishop-backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + worker: + container_name: remnawave-minishop-worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + + frontend: + container_name: remnawave-minishop-frontend + image: ghcr.io/3252a8/remnawave-minishop-frontend:${IMAGE_TAG:-latest} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + FRONTEND_PORT: ${FRONTEND_PORT:-8082} + FRONTEND_PUBLIC: via caddy + networks: + - remnawave-network + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + + caddy: + image: caddy:2-alpine + container_name: remnawave-minishop-caddy + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_healthy + ports: + - "${HTTP_BIND:-0.0.0.0:80}:80" + - "${HTTPS_BIND:-0.0.0.0:443}:443" + volumes: + - ../docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - remnawave-network + restart: unless-stopped + +networks: + remnawave-network: + +volumes: + remnawave-minishop-db-data: + name: remnawave-minishop-db-data + remnawave-minishop-redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data + caddy-data: + name: remnawave-minishop-caddy-data + caddy-config: + name: remnawave-minishop-caddy-config diff --git a/deploy/compose/docker-compose-remote-server.yml b/deploy/compose/docker-compose-remote-server.yml new file mode 100644 index 0000000..a07e2bf --- /dev/null +++ b/deploy/compose/docker-compose-remote-server.yml @@ -0,0 +1,136 @@ +services: + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + env_file: + - ${APP_ENV_FILE:-../../.env} + volumes: + - remnawave-minishop-db-data:/var/lib/postgresql/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - remnawave-minishop-redis-data:/data + networks: + - remnawave-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + backend: + container_name: remnawave-minishop-backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + volumes: + - shop-data:/app/data + networks: + - remnawave-network + ports: + - "127.0.0.1:${WEB_SERVER_PORT:-8080}:8080" + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + worker: + container_name: remnawave-minishop-worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} + env_file: + - ${APP_ENV_FILE:-../../.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + volumes: + - shop-data:/app/data + networks: + - remnawave-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + + frontend: + container_name: remnawave-minishop-frontend + image: ghcr.io/3252a8/remnawave-minishop-frontend:${IMAGE_TAG:-latest} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + FRONTEND_PORT: ${FRONTEND_PORT:-8082} + FRONTEND_PUBLIC: 127.0.0.1:${FRONTEND_PORT:-8082}->80 + networks: + - remnawave-network + ports: + - "127.0.0.1:${FRONTEND_PORT:-8082}:80" + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + +networks: + remnawave-network: + +volumes: + remnawave-minishop-db-data: + name: remnawave-minishop-db-data + remnawave-minishop-redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..2fdfcf9 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,82 @@ +FROM python:3.12-slim AS python-base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/backend + +WORKDIR /app + +RUN useradd -u 10001 -m appuser + +COPY backend/requirements.txt backend/requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-cache-dir -r backend/requirements.txt + +COPY backend ./backend +COPY locales ./locales +COPY data ./data +RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data + +USER appuser + + +FROM python-base AS backend + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-backend" \ + org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \ + org.opencontainers.image.licenses="MIT" + +EXPOSE 8080 8081 +CMD ["python", "backend/main_backend.py"] + + +FROM python-base AS worker + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-worker" \ + org.opencontainers.image.description="Remnawave Minishop background worker (scheduled jobs, async tasks)." \ + org.opencontainers.image.licenses="MIT" + +CMD ["python", "backend/main_worker.py"] + + +FROM node:22-slim AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY frontend ./ +COPY backend/bot/app/web/templates ../backend/bot/app/web/templates + +RUN npm run build:webapp + + +FROM nginx:1.27-alpine AS frontend + +LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \ + org.opencontainers.image.title="remnawave-minishop-frontend" \ + org.opencontainers.image.description="Remnawave Minishop frontend (nginx serving the subscription Mini App)." \ + org.opencontainers.image.licenses="MIT" + +COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh +COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ + +RUN sed -i \ + -e '/WEBAPP_I18N_SCRIPT/d' \ + -e '/WEBAPP_CONFIG_SCRIPT/d' \ + -e '/WEBAPP_JS_SCRIPT/c\ ' \ + -e '/WEBAPP_DEV_MOCK_START/d' \ + -e '/WEBAPP_DEV_MOCK_END/d' \ + -e '/subscription_webapp.js" defer/d' \ + /usr/share/nginx/html/index.html \ + && chmod +x /docker-entrypoint.d/00-startup-banner.sh + +EXPOSE 80 diff --git a/deploy/docker/caddy/Caddyfile b/deploy/docker/caddy/Caddyfile new file mode 100644 index 0000000..fb9721b --- /dev/null +++ b/deploy/docker/caddy/Caddyfile @@ -0,0 +1,11 @@ +{$PUBLIC_HOST:localhost} { + encode zstd gzip + + @webhooks path /tg/webhook /webhook/* + reverse_proxy @webhooks backend:8080 + + @backend_health path /health /healthz + reverse_proxy @backend_health backend:8080 + + reverse_proxy frontend:80 +} diff --git a/deploy/docker/frontend/00-startup-banner.sh b/deploy/docker/frontend/00-startup-banner.sh new file mode 100644 index 0000000..4cc58f9 --- /dev/null +++ b/deploy/docker/frontend/00-startup-banner.sh @@ -0,0 +1,23 @@ +#!/bin/sh +PUBLISHED="${FRONTEND_PUBLIC:-127.0.0.1:${FRONTEND_PORT:-8082}->80}" + +cat <80 depends_on: - remnawave-minishop-db: + backend: condition: service_healthy + networks: + - remnawave-shop ports: - - 127.0.0.1:8080:8080 - - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} - # Tariffs 2.0 configuration. Mount the data directory to persist - # changes made via the admin panel. - # volumes: - # - ./data:/app/data + - "127.0.0.1:${FRONTEND_PORT:-8082}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health | grep -q ok"] + interval: 30s + timeout: 5s + retries: 5 + + backend: + container_name: remnawave-minishop-backend + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: backend + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + WEBAPP_ENABLED: "true" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + ports: + - "127.0.0.1:${WEB_SERVER_PORT:-8080}:8080" healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""] interval: 30s @@ -25,27 +59,96 @@ services: retries: 5 start_period: 30s - remnawave-minishop-db: - image: postgres:17 - container_name: remnawave-minishop-db - hostname: remnawave-minishop-db - env_file: - - .env - volumes: - - remnawave-minishop-db-data:/var/lib/postgresql/data - networks: - - remnawave-network + worker: + container_name: remnawave-minishop-worker + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: worker + image: ghcr.io/3252a8/remnawave-minishop-worker:${IMAGE_TAG:-latest} restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + + migrate: + container_name: remnawave-minishop-migrate + image: ghcr.io/3252a8/remnawave-minishop-backend:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: deploy/docker/Dockerfile + target: backend + command: ["python", "backend/main_migrate.py"] + env_file: + - ${APP_ENV_FILE:-.env} + environment: + IMAGE_TAG: ${IMAGE_TAG:-latest} + POSTGRES_HOST: postgres + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - shop-data:/app/data + networks: + - remnawave-shop + + postgres: + image: postgres:17 + container_name: remnawave-minishop-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - remnawave-shop healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s timeout: 5s retries: 20 + redis: + image: redis:7-alpine + container_name: remnawave-minishop-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + networks: + - remnawave-shop + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + networks: - remnawave-network: - external: true + remnawave-shop: volumes: - remnawave-minishop-db-data: + postgres-data: name: remnawave-minishop-db-data + redis-data: + name: remnawave-minishop-redis-data + shop-data: + name: remnawave-minishop-shop-data diff --git a/docs/admin.md b/docs/admin.md index 9ccee4e..4645470 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -77,4 +77,4 @@ Отключенный тариф исчезает с витрины, но активные подписки с его `tariff_key` продолжают существовать. Удаление тарифа безопасно только если не осталось активных подписок, которым нужна докупка, продление или смена с этого тарифа. -Для Docker важно, чтобы путь `TARIFFS_CONFIG_PATH` был доступен на запись контейнеру. В dev-compose каталог `./data` монтируется в `/app/data`, поэтому админка может сохранять `data/tariffs.json`. +Для Docker важно, чтобы путь `TARIFFS_CONFIG_PATH` был доступен на запись контейнеру. Если каталог `./data` смонтирован в `/app/data`, админка может сохранять `data/tariffs.json`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..53a3dc2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Project Architecture + +The repository is split by runtime responsibility: + +```text +backend/ Python application code + bot/ Telegram bot, aiohttp APIs, webhooks, services + config/ Pydantic settings and tariff/theme config loaders + db/ SQLAlchemy models, DAL, migrations + main_backend.py aiohttp backend entrypoint + main_worker.py background worker entrypoint + main_migrate.py one-shot migration entrypoint + requirements.txt Python runtime dependencies + +frontend/ Svelte/Vite Mini App and admin UI + src/ Svelte source code + scripts/ frontend build helpers + package.json Node scripts and dependencies + +deploy/ + docker/ Dockerfile, nginx and caddy runtime config + compose/ legacy/alternate compose examples + +data/ runtime data mounted in containers +locales/ bot and Web App translations +tests/ Python test suite +``` + +The default `docker-compose.yml` stays in the repository root so `docker compose up` remains the +simple production path. It builds three application images from `deploy/docker/Dockerfile`: + +- `backend`: aiohttp APIs and webhooks only. +- `worker`: tariff traffic worker, panel sync, webhook queue consumers. +- `frontend`: static Svelte assets served by nginx. + +The `migrate` service is a one-shot container based on the backend image. It is part of the +default Compose dependency graph: Postgres and Redis become healthy, `migrate` applies +`Base.metadata.create_all` and pending `schema_migrations`, then `backend` and `worker` start +only after `migrate` exits successfully. This keeps migrations automatic for `docker compose up` +without running them inside every backend replica. + +Python imports intentionally remain `bot.*`, `config.*`, and `db.*`. Runtime containers set +`PYTHONPATH=/app/backend`; local tests use the same layout through `pytest.ini`. + +Common commands: + +```bash +docker compose up -d --build +docker compose run --rm migrate +docker compose logs -f backend worker frontend +npm run build:webapp +pytest -q +``` diff --git a/docs/configuration.md b/docs/configuration.md index 9864358..f0f7e49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,7 +96,7 @@ nano .env Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. -В штатном `docker-compose.yml` том `./data:/app/data` у сервиса приложения **закомментирован по умолчанию**. Раскомментируйте блок `volumes`, чтобы админка сохраняла `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Отдельный `docker-compose-dev.yml` в репозиторий не входит (может быть у вас локально); логика та же — монтирование `./data` в `/app/data`. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: +В штатном `docker-compose.yml` данные хранятся в named volumes. Если для локальной разработки включён bind mount `./data:/app/data`, админка сможет сохранять `data/tariffs.json`, каталог тем (`data/themes`), кеш логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`) прямо в рабочую копию. Если bind mount включён на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера: ```bash mkdir -p data/themes data/webapp-logo data/webapp-emoji @@ -119,7 +119,7 @@ docker compose up -d --build --force-recreate | Переменная | Назначение | | --- | --- | | `WEBAPP_ENABLED` | Включает Web App в том же контейнере. | -| `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Хост и порт Web App. По умолчанию порт `8081`. | +| `WEBAPP_SERVER_HOST` / `WEBAPP_SERVER_PORT` | Внутренний aiohttp server для WebApp API/auth/theme assets. По умолчанию порт `8081`; статический frontend отдается отдельным nginx image. | | `SUBSCRIPTION_MINI_APP_URL` | Публичный URL Web App. | | `WEBAPP_TITLE` | Заголовок Web App. | | `WEBAPP_THEMES_DIR` | Каталог тем Web App. По умолчанию `data/themes`; внутри ожидаются папки `/theme.json` и опциональные CSS/ассеты. | diff --git a/docs/deployment.md b/docs/deployment.md index e15dc52..3a3b351 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,295 +1,251 @@ # Развертывание -Документ описывает запуск через Docker Compose, маршруты вебхуков и варианты reverse proxy. Перед запуском заполните `.env` по [configuration.md](configuration.md). +Документ описывает продакшен-запуск после разделения проекта на `backend`, `frontend` и `worker`. +Перед стартом заполните `.env` по [configuration.md](configuration.md). -## Docker Compose +## Быстрый старт -Локальная сборка: +```bash +cp .env.example .env +nano .env +docker compose up -d --build +docker compose ps +docker compose logs -f backend worker frontend +``` + +Обычный `docker compose up -d --build` поднимает: + +- `postgres` и `redis` с проверками здоровья; +- `migrate` как одноразовый сервис на backend-образе; +- `backend` только после успешных миграций; +- `worker` только после успешных миграций; +- `frontend` как отдельный nginx-образ без Python runtime. + +Для обратного прокси с Caddy используйте отдельный `deploy/compose/docker-compose-caddy.yml`. + +Миграции не запускаются внутри backend. Их выполняет отдельный сервис `migrate`, поэтому старт +приложения не создает гонки на схеме БД. + +## Миграции + +При обычном старте миграции применяются автоматически: ```bash docker compose up -d --build -docker compose logs -f remnawave-minishop ``` -Запуск из готового образа: +Для ручного повторного запуска: ```bash -IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d +docker compose run --rm migrate ``` -`docker-compose-remote-server.yml` можно использовать как шаблон и заменить `image:` на нужный образ. По умолчанию используется `ghcr.io/3252a8/remnawave-minishop:latest`. - -### Права на `./data` - -Если в Compose включен bind mount `./data:/app/data` или `./data:/app/data:rw`, каталог на хосте должен быть доступен на запись пользователю контейнера. Контейнер запускает приложение от `appuser` с UID `10001`; запуск Docker от `root` на Ubuntu не делает этот каталог writable внутри контейнера, если на хосте он принадлежит `root:root` с обычными правами `755`. - -Перед запуском или после добавления mount выполните на сервере из каталога проекта: +Проверить логи миграций: ```bash -mkdir -p data/themes data/webapp-logo data/webapp-emoji +docker compose logs migrate +``` + +`backend` и `worker` зависят от `migrate` через `service_completed_successfully`; если миграции +падают, приложение не стартует поверх неподготовленной БД. + +## Сервисы + +- `backend`: aiohttp API, Telegram webhook, платежные webhooks, panel webhooks, проверка здоровья `/healthz`. +- `worker`: TariffTrafficWorker, задачи синхронизации с панелью, обработка рассылок, потребители очереди webhooks. +- `frontend`: статические Svelte-ассеты через nginx. +- `postgres`: PostgreSQL 17. +- `redis`: Redis 7 для FSM, кеша, rate-limit, очередей и locks. + +`deploy/compose/docker-compose-caddy.yml` добавляет `caddy` как внешний HTTP/HTTPS reverse proxy. + +## Логи и проверка + +```bash +docker compose ps +docker compose logs -f backend +docker compose logs -f worker +docker compose logs -f frontend +``` + +Эндпоинты проверки здоровья: + +```bash +curl http://127.0.0.1:8080/healthz +curl http://127.0.0.1:8080/health +``` + +В обычном compose backend публикуется на `127.0.0.1:${WEB_SERVER_PORT:-8080}`, frontend на +`127.0.0.1:${FRONTEND_PORT:-8082}`. В Caddy-варианте проверяйте `HTTP_BIND`. + +## Обновление + +Локальная сборка из репозитория: + +```bash +git pull +docker compose up -d --build +docker compose logs -f migrate backend worker +``` + +Если нужно пересобрать только образы приложения: + +```bash +docker compose build frontend backend worker +docker compose up -d +``` + +## Образы GHCR + +Образы приложения называются единообразно: + +```text +ghcr.io/3252a8/remnawave-minishop-backend: +ghcr.io/3252a8/remnawave-minishop-worker: +ghcr.io/3252a8/remnawave-minishop-frontend: +``` + +Сборка образов с конкретным тегом: + +```bash +IMAGE_TAG=3.2.0 scripts/docker-build-images.sh +``` + +Публикация после `docker login ghcr.io`: + +```bash +IMAGE_TAG=3.2.0 scripts/docker-push-images.sh +``` + +Для PowerShell есть варианты `scripts/docker-build-images.ps1` и +`scripts/docker-push-images.ps1`. Если публикуете образы в другой registry, namespace или с другим +префиксом имени, переопределите `IMAGE_NAMESPACE`, `IMAGE_REGISTRY` или `IMAGE_PREFIX`. + +Если PowerShell блокирует локальные скрипты ошибкой `PSSecurityException` / Execution Policy, +запустите те же скрипты с обходом политики только для текущего процесса: + +```powershell +$env:IMAGE_TAG = "3.2.0" +docker login ghcr.io +powershell -ExecutionPolicy Bypass -File .\scripts\docker-build-images.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\docker-push-images.ps1 +``` + +Этот bypass действует только для запущенного процесса `powershell` и не меняет системную политику. + +## Масштабирование + +В текущих Compose-файлах заданы явные `container_name`, поэтому `docker compose --scale` для +`backend`, `frontend` и `worker` не используется: Docker не может создать несколько контейнеров с +одним именем. Если понадобится горизонтальное масштабирование, уберите `container_name` у +масштабируемых сервисов или перенесите конфигурацию в orchestrator. + +Состояние FSM, rate-limit и краткоживущие кеши вынесены в Redis, а tariff tick защищен Redis +distributed lock; код подготовлен к нескольким репликам, но текущие Compose-файлы ориентированы на +фиксированные имена контейнеров. + +## Данные и volumes + +Продакшен compose использует именованные volumes: + +- `postgres-data`; +- `redis-data`; +- `shop-data`; +В Caddy-варианте также используются `caddy-data` и `caddy-config`. + +`shop-data` монтируется целиком в `/app/data`; внутри него лежат тарифы, темы, логотипы и прочие +файловые данные приложения. + +Если вместо именованного volume включаете bind mount `./data:/app/data`, на сервере заранее дайте права +пользователю контейнера `10001`: + +```bash +mkdir -p data/themes data/webapp-logo data/webapp-emoji data/tariffs chown -R 10001:10001 data chmod -R u+rwX data -docker compose up -d --force-recreate remnawave-minishop +docker compose up -d --force-recreate backend worker ``` Проверка прав: ```bash -docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/themes /app/data/webapp-emoji; touch /app/data/themes/test /app/data/webapp-emoji/test && rm /app/data/themes/test /app/data/webapp-emoji/test' +docker compose exec backend sh -lc 'id; touch /app/data/themes/test && rm /app/data/themes/test' ``` -Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, темы в `/app/data/themes`, кеш логотипов в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`. - -## Обновление версии - -Образ приложения: `ghcr.io/3252a8/remnawave-minishop`. Тег задаётся переменной окружения **`IMAGE_TAG`** (в Compose подставляется как `${IMAGE_TAG:-latest}`). Для продакшена разумно закрепить **конкретный тег релиза** вместо `latest`, чтобы обновляться осознанно и иметь откат. - -**Запуск из готового образа** (`docker-compose-remote-server.yml` или свой файл с тем же шаблоном): - -1. Сделайте резервную копию базы (особенно перед крупными обновлениями) — см. раздел «Резервная копия и восстановление PostgreSQL» ниже на этой странице. -2. Укажите нужный тег, например в `.env`: `IMAGE_TAG=3.2.0` (или экспортируйте переменную перед командой). -3. Подтяните образ и пересоздайте контейнер приложения (БД при этом не удаляется, том данных сохраняется): +## Резервная копия PostgreSQL ```bash -docker compose -f docker-compose-remote-server.yml pull remnawave-minishop -docker compose -f docker-compose-remote-server.yml up -d --no-deps remnawave-minishop +docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql ``` -При необходимости перезапустите оба сервиса: `docker compose -f docker-compose-remote-server.yml up -d`. Флаг `--force-recreate` добавляют, если нужно гарантированно пересоздать контейнер при неизменённом образе. - -**Локальная сборка из репозитория** (ваш `Dockerfile`): +Восстановление в чистую БД: ```bash -git pull -docker compose up -d --build +docker compose stop backend worker +docker compose exec postgres sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"' +docker compose exec postgres sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"' +docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sql +docker compose run --rm migrate +docker compose up -d backend worker ``` -После обновления проверьте логи (`docker compose logs -f remnawave-minishop`), работу бота, вебхуков и Web App. Совместимость с панелью Remnawave — см. раздел «Совместимость» в [README.md](../README.md). +## Обратный прокси -## Резервная копия и восстановление PostgreSQL +Вариант `deploy/compose/docker-compose-caddy.yml` проксирует: -Имя контейнера БД в типичном Compose — `remnawave-minishop-db`. Учётные данные уже передаются в контейнер через `env_file: .env`, поэтому надёжнее вызывать `pg_dump` / `psql` **внутри** контейнера через `sh -c '...'`, чтобы переменные раскрылись там, а не на хосте. +- статические ассеты frontend; +- API и webhook-маршруты backend; +- эндпоинты проверки здоровья. -Если написать на хосте `pg_dump -U "$POSTGRES_USER" ...` без экспорта переменных из `.env`, подставится пустая строка: PostgreSQL тогда берёт имя пользователя ОС (часто `root`) и выдаёт `FATAL: role "root" does not exist`. В **PowerShell** `$POSTGRES_DB` из файла `.env` сам не подхватывается — пустое имя базы даёт у `dropdb` ошибку `missing required argument database name`. - -**Вариант A (рекомендуется):** переменные только внутри контейнера: - -```bash -docker exec remnawave-minishop-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql -``` - -**Вариант B:** сначала загрузить `.env` в текущую сессию на сервере, затем обычная команда (переменные раскроются на хосте): - -```bash -set -a && source .env && set +a -docker exec remnawave-minishop-db pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" > backup.sql -``` - -Такой файл удобно хранить вне сервера. При необходимости добавьте к `pg_dump` параметры сжатия или расписание через cron. - -### Восстановление из `backup.sql` - -Дамп выше — **обычный текст SQL** без удаления существующих объектов. Чтобы накатить его на **чистую** базу с тем же именем: - -1. Остановите приложение, чтобы не было записей в БД: - -```bash -docker compose -f docker-compose-remote-server.yml stop remnawave-minishop -``` - -(или ваш файл Compose без `-f`, если работаете из каталога проекта.) - -2. Удалите базу и создайте пустую с тем же именем — пользователь из `.env` в официальном образе PostgreSQL обычно суперпользователь и может это сделать: - -```bash -docker exec remnawave-minishop-db sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"' -docker exec remnawave-minishop-db sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"' -``` - -Имя базы у `dropdb` — последний аргумент; флаг **`--if-exists`** ставьте перед ним (иначе клиент может неверно разобрать командную строку). - -Если `dropdb` сообщает, что база занята, убедитесь, что остановлен сервис `remnawave-minishop` и к базе нет других подключений. - -3. Восстановите данные из файла на хосте. Переменные снова должны раскрываться **внутри** контейнера; на стороне `psql` имеет смысл включить **`ON_ERROR_STOP`**, чтобы при первой ошибке в дампе команда завершилась с ненулевым кодом. - -**Bash:** - -```bash -docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sql -``` - -**PowerShell** (перенаправление `<` в `docker exec` часто не подходит; надёжнее передать дамп в stdin через pipe; явная **UTF-8**, чтобы кириллица в комментариях/SQL не исказилась): - -```powershell -Get-Content backup.sql -Encoding utf8 | docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' -``` - -Если путь к файлу содержит пробелы, используйте кавычки: `Get-Content "E:\backups\backup.sql" -Encoding utf8 | ...` - -4. Запустите приложение снова: - -```bash -docker compose -f docker-compose-remote-server.yml start remnawave-minishop -``` - -Проверьте логи и работу бота. При ошибках импорта убедитесь, что версия приложения совместима со схемой в дампе (после обновлений бота иногда нужны шаги из changelog релиза). - -### Замечание про «накат поверх» без пересоздания БД - -Если нужно применить дамп к уже заполненной базе без `dropdb`, создавайте резервные копии с **`pg_dump --clean --if-exists`** — в файл попадут команды `DROP` перед `CREATE`, и `psql` сможет перезаписать объекты. Это разрушительно для текущих данных; перед таким сценарием сделайте отдельный свежий бэкап. - -## Порты - -| Порт | Назначение | -| --- | --- | -| `WEB_SERVER_PORT` (`8080`) | Telegram webhook, платежные вебхуки, Remnawave webhook. | -| `WEBAPP_SERVER_PORT` (`8081`) | Web App / Mini App. | - -Web App не должен проксироваться на `WEB_SERVER_PORT`. - -## Маршруты вебхуков - -Проксируйте платежные и системные вебхуки на `WEB_SERVER_PORT`: - -- `https:///webhook/yookassa` -> `http://remnawave-minishop:/webhook/yookassa`; -- `https:///webhook/freekassa` -> `http://remnawave-minishop:/webhook/freekassa`; -- `https:///webhook/platega` -> `http://remnawave-minishop:/webhook/platega`; -- `https:///webhook/severpay` -> `http://remnawave-minishop:/webhook/severpay`; -- `https:///webhook/cryptopay` -> `http://remnawave-minishop:/webhook/cryptopay`; -- `https:///webhook/panel` -> `http://remnawave-minishop:/webhook/panel`. - -Telegram webhook устанавливается приложением, если задан `WEBHOOK_BASE_URL`. Полный URL — это базовый URL **без** токена в пути, с суффиксом **`/tg/webhook`** (например `https://webhook.domain.com/tg/webhook`). Прокси должен передавать на приложение POST-запросы по этому пути на `WEB_SERVER_PORT`. - -## Nginx рядом с Remnawave - -Пример upstream и server-блока для домена вебхуков: +Для внешнего Nginx используйте DNS-имена сервисов внутри Docker network: ```nginx -upstream remnawave-minishop { - server remnawave-minishop:8080; +upstream remnawave_backend_webhooks { + server backend:8080; } -map $http_upgrade $connection_upgrade { - default upgrade; - "" close; +upstream remnawave_backend_webapp { + server backend:8081; } -server { - server_name webhook.domain.com; - listen 443 ssl; - http2 on; - - ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; - ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key"; - ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - proxy_intercept_errors on; - error_page 400 404 500 502 @redirect; - - location / { - proxy_pass http://remnawave-minishop$request_uri; - } - - location @redirect { - return 404; - } +upstream remnawave_frontend { + server frontend:80; } ``` -Для Web App используйте отдельный upstream на `WEBAPP_SERVER_PORT`; пример есть в [webapp.md](webapp.md). +Webhook и health-маршруты должны идти в `backend:8080`. Статические ассеты Mini App отдавайте через +`frontend:80`; frontend nginx уже проксирует `/api/*`, `/auth/*` и ассеты тем/логотипов в +`backend:8081`. -## SSL для домена вебхуков +## Newt -Пример выпуска сертификата через `acme.sh`: +Если `newt` запущен в одной Compose network с приложением, указывайте внутренние имена сервисов, +а не порты на хосте. + +Если используете Caddy-вариант и хотите публиковать стек одной точкой через Newt/Pangolin, укажите: + +```text +http://caddy:80 +``` + +Если публикуете через Newt/Pangolin без Caddy, настройте отдельные ресурсы: + +```text +Mini App / frontend: http://frontend:80 +Webhooks / backend: http://backend:8080 +``` + +Такой вариант нормальный: Caddy не обязателен. Он нужен только если вы хотите заранее объединить +frontend и backend-маршруты в один внутренний upstream. + +`backend:8081` является внутренним WebApp API/auth-сервером для frontend nginx; обычно его не нужно +указывать в Newt напрямую. + +## Переменный env-файл + +По умолчанию compose читает `.env`. Для smoke-тестов или отдельного окружения можно подставить +другой файл: ```bash -sudo apt-get install cron socat -curl https://get.acme.sh | sh -s email=EMAIL -source ~/.bashrc -ufw allow 80/tcp -ufw reload - -acme.sh --set-default-ca --server letsencrypt -acme.sh --issue --standalone -d 'webhook.domain.com' \ - --key-file /opt/remnawave/nginx/webhook_privkey.key \ - --fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem +APP_ENV_FILE=.env.staging docker compose --env-file .env.staging up -d --build ``` - -Если Nginx панели Remnawave запускается в Docker, добавьте сертификаты в `volumes` сервиса Nginx: - -```yaml -services: - remnawave-nginx: - volumes: - - ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro - - ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro -``` - -После изменения конфигурации перезапустите Nginx: - -```bash -cd /opt/remnawave/nginx -docker compose down -docker compose up -d -docker compose logs -f -t -``` - -## Caddy - -Для схемы с Caddy используйте `docker-compose-caddy.yml` и `Caddyfile`. Caddy публикует наружу `80` и `443`, выпускает TLS-сертификаты и проксирует вебхуки и Web App на разные внутренние порты. - -Пример `Caddyfile`: - -```caddyfile -webhook.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080} -} - -app.domain.com { - encode zstd gzip - reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081} -} -``` - -В `.env` укажите: - -```env -WEBHOOK_BASE_URL=https://webhook.domain.com -SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ -``` - -Запуск: - -```bash -docker compose -f docker-compose-caddy.yml up -d --build -``` - -В BotFather укажите домен Mini App через настройки домена, чтобы он совпадал с `SUBSCRIPTION_MINI_APP_URL`. - -## Проверка - -```bash -docker compose ps -docker compose logs -f remnawave-minishop -``` - -Проверьте: - -- бот отвечает в Telegram; -- Telegram webhook установлен без ошибок в логах; -- платежные вебхуки доходят до приложения; -- Remnawave webhook проходит проверку `PANEL_WEBHOOK_SECRET`; -- Web App открывается по домену из `SUBSCRIPTION_MINI_APP_URL`; -- в BotFather разрешены URL Web App и callback. diff --git a/docs/migration-to-minishop.md b/docs/migration-to-minishop.md index 77bd822..7043d57 100644 --- a/docs/migration-to-minishop.md +++ b/docs/migration-to-minishop.md @@ -1,5 +1,10 @@ # Миграция с `remnawave-tg-shop` на `remnawave-minishop` +> Примечание для новой Docker-архитектуры: основной production stack теперь состоит из +> `frontend`, `backend`, `worker`, `migrate`, `postgres` и `redis`. После переноса данных +> запускайте корневой `docker-compose.yml`; миграции применит one-shot сервис `migrate`, а логи +> приложения смотрите через `docker compose logs -f backend worker frontend`. + Начиная с этой версии контейнеры и тома названы `remnawave-minishop*` вместо `remnawave-tg-shop*`. Старый и новый стеки используют **разные имена томов**, поэтому простой `docker compose up -d` после `git pull` создаст пустую БД. Эта инструкция описывает, как перенести данные. Есть два пути: @@ -9,7 +14,7 @@ В обоих случаях: - старые тома **не удаляются** автоматически — это безопасный бэкап на случай отката; -- сертификаты Caddy (если используется `docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit. +- сертификаты Caddy (если используется `deploy/compose/docker-compose-caddy.yml`) тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться в rate limit. ## Автоматический способ (через скрипт) @@ -42,7 +47,7 @@ bash scripts/migrate_to_minishop.sh ```bash # Caddy-вариант из raw -COMPOSE_FILE=docker-compose-caddy.yml \ +COMPOSE_FILE=deploy/compose/docker-compose-caddy.yml \ bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh) # С переключением origin на форк 3252a8 @@ -99,10 +104,10 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ docker compose up --no-start --build # Или Caddy-вариант - docker compose -f docker-compose-caddy.yml up --no-start --build + docker compose -f deploy/compose/docker-compose-caddy.yml up --no-start # Или готовый образ - docker compose -f docker-compose-remote-server.yml up --no-start + docker compose -f deploy/compose/docker-compose-remote-server.yml up --no-start ``` 4. **Перенесите том БД в новое имя:** @@ -130,16 +135,16 @@ docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/ ```bash docker compose up -d # или - docker compose -f docker-compose-caddy.yml up -d --build + docker compose -f deploy/compose/docker-compose-caddy.yml up -d # или - docker compose -f docker-compose-remote-server.yml up -d + docker compose -f deploy/compose/docker-compose-remote-server.yml up -d ``` 7. **Проверьте:** ```bash docker compose ps - docker compose logs -f remnawave-minishop + docker compose logs -f backend worker frontend ``` 8. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек стабилен: diff --git a/docs/webapp-themes.md b/docs/webapp-themes.md index 2df8e37..c499496 100644 --- a/docs/webapp-themes.md +++ b/docs/webapp-themes.md @@ -22,7 +22,7 @@ Web App поддерживает файловые темы, предпросмо - иконки и изображения, если CSS ссылается на ассеты темы; - стили только пользовательской части, только админки или обеих частей сразу. -Готовые темы лежат в `bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. +Готовые темы лежат в `backend/bot/app/web/themes`: `dark`, `light`, `windows95`, `ascii`. При первом запуске они копируются в `WEBAPP_THEMES_DIR`, по умолчанию `data/themes`. ## Где живут темы @@ -274,7 +274,7 @@ CSS можно писать для пользовательской части Для обычной брендовой темы чаще всего удобнее начать с `dark` или `light`. Для глубокого CSS-скина можно взять `ascii` или `windows95` как пример того, насколько далеко можно уйти от стандартного вида. ```bash - cp bot/app/web/themes/dark/theme.json data/themes/neon/theme.json + cp backend/bot/app/web/themes/dark/theme.json data/themes/neon/theme.json ``` 4. Отредактируйте `theme.json`. @@ -328,7 +328,7 @@ CSS можно писать для пользовательской части 12. Зафиксируйте тему. - Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. + Для темы, которая должна ехать вместе с проектом, добавьте ее в репозиторий в `backend/bot/app/web/themes` и при необходимости расширьте `DEFAULT_THEME_KEYS` в `backend/config/webapp_themes_config.py`. Для приватной инсталляции достаточно хранить ее в `data/themes`. ## Насколько глубоко можно менять вид diff --git a/docs/webapp.md b/docs/webapp.md index d0878f3..e2193ac 100644 --- a/docs/webapp.md +++ b/docs/webapp.md @@ -1,6 +1,6 @@ # Web App / Mini App -Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Порт `WEB_SERVER_PORT` остается для Telegram, платежных и Remnawave вебхуков. +Web App собирается в отдельный `frontend` image и отдается через nginx. Static/Mini App запросы идут в `frontend:80`; frontend nginx проксирует `/api/*`, `/auth/*` и theme/logo assets в backend WebApp server на `backend:8081`. Telegram, payment и panel webhook routes остаются на backend webhook server `backend:8080`. ## Что показывает Web App @@ -89,8 +89,16 @@ Email-вход работает через одноразовый код: Web App должен проксироваться отдельно от вебхуков: ```nginx -upstream remnawave-minishop-webapp { - server remnawave-minishop:8081; +upstream remnawave_frontend { + server frontend:80; +} + +upstream remnawave_backend_webapp { + server backend:8081; +} + +upstream remnawave_backend_webhooks { + server backend:8080; } server { @@ -102,7 +110,25 @@ server { ssl_certificate_key "/etc/nginx/ssl/app_privkey.key"; location / { - proxy_pass http://remnawave-minishop-webapp; + proxy_pass http://remnawave_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/(api|auth)/ { + proxy_pass http://remnawave_backend_webapp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /webhook/ { + proxy_pass http://remnawave_backend_webhooks; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -112,12 +138,16 @@ server { } ``` -В `docker-compose.yml` порт Web App публикуется отдельно: +В default `docker-compose.yml` наружу публикуются `frontend` и webhook/backend port, а внутри Docker network сервисы доступны друг другу по service DNS names: ```yaml -ports: - - 127.0.0.1:8080:8080 - - 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081} +services: + frontend: + expose: + - "80" + backend: + expose: + - "8080" ``` ## Реферальные ссылки diff --git a/.prettierignore b/frontend/.prettierignore similarity index 100% rename from .prettierignore rename to frontend/.prettierignore diff --git a/.prettierrc.json b/frontend/.prettierrc.json similarity index 100% rename from .prettierrc.json rename to frontend/.prettierrc.json diff --git a/components.json b/frontend/components.json similarity index 85% rename from components.json rename to frontend/components.json index 246776b..62c457a 100644 --- a/components.json +++ b/frontend/components.json @@ -3,7 +3,7 @@ "style": "new-york", "tailwind": { "config": "", - "css": "bot/app/web/frontend/src/styles.css", + "css": "src/styles.css", "baseColor": "slate" }, "aliases": { diff --git a/eslint.config.mjs b/frontend/eslint.config.mjs similarity index 79% rename from eslint.config.mjs rename to frontend/eslint.config.mjs index a6f01e9..6a9bafc 100644 --- a/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,12 +6,12 @@ import globals from "globals"; /** @type {import("eslint").Linter.Config[]} */ export default [ { - ignores: ["**/node_modules/**", "bot/app/web/templates/**"], + ignores: ["**/node_modules/**", "../backend/bot/app/web/templates/**"], }, js.configs.recommended, ...svelte.configs["flat/base"], { - files: ["bot/app/web/frontend/**/*.{js,svelte}", "scripts/**/*.mjs"], + files: ["src/**/*.{js,svelte}", "scripts/**/*.mjs"], rules: { "no-unused-vars": [ "warn", @@ -25,13 +25,13 @@ export default [ }, }, { - files: ["bot/app/web/frontend/**/*.svelte"], + files: ["src/**/*.svelte"], rules: { "no-useless-assignment": "off", }, }, { - files: ["bot/app/web/frontend/**/*.{js,svelte}"], + files: ["src/**/*.{js,svelte}"], languageOptions: { globals: { ...globals.browser, diff --git a/bot/app/web/frontend/jsconfig.json b/frontend/jsconfig.json similarity index 100% rename from bot/app/web/frontend/jsconfig.json rename to frontend/jsconfig.json diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5c2eeff --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "private": true, + "scripts": { + "build:webapp:svelte": "vite build --config ./vite.config.mjs", + "build:webapp:css": "npm run build:webapp:svelte", + "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", + "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", + "lint": "eslint src scripts", + "lint:fix": "npm run lint:js:fix", + "lint:js:fix": "eslint src scripts --fix", + "format": "prettier --write \"src/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", + "format:check": "prettier --check \"src/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", + "check": "npm run lint && npm run format:check", + "fix": "npm run format && npm run lint:js:fix" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@internationalized/date": "^3.12.1", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/cli": "4.3.0", + "@tailwindcss/vite": "^4.3.0", + "bits-ui": "^2.18.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "esbuild": "^0.28.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.0", + "globals": "^17.6.0", + "lucide-svelte": "^1.0.1", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.55.5", + "svelte-eslint-parser": "^1.4.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "4.3.0", + "uplot": "^1.6.32", + "vite": "^8.0.12" + } +} diff --git a/scripts/build_subscription_webapp_js.mjs b/frontend/scripts/build_subscription_webapp_js.mjs similarity index 93% rename from scripts/build_subscription_webapp_js.mjs rename to frontend/scripts/build_subscription_webapp_js.mjs index 2affc29..6bcd671 100644 --- a/scripts/build_subscription_webapp_js.mjs +++ b/frontend/scripts/build_subscription_webapp_js.mjs @@ -7,8 +7,16 @@ import { fileURLToPath } from "node:url"; import { transform } from "esbuild"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const sourcePath = path.join(repoRoot, "bot", "app", "web", "templates", "subscription_webapp.js"); +const repoRoot = path.resolve(__dirname, "..", ".."); +const sourcePath = path.join( + repoRoot, + "backend", + "bot", + "app", + "web", + "templates", + "subscription_webapp.js" +); function normalizeLineEndings(value) { return value.replace(/\r\n/g, "\n"); diff --git a/bot/app/web/frontend/src/App.svelte b/frontend/src/App.svelte similarity index 100% rename from bot/app/web/frontend/src/App.svelte rename to frontend/src/App.svelte diff --git a/bot/app/web/frontend/src/PreviewBoard.svelte b/frontend/src/PreviewBoard.svelte similarity index 100% rename from bot/app/web/frontend/src/PreviewBoard.svelte rename to frontend/src/PreviewBoard.svelte diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/AdminPanel.svelte rename to frontend/src/admin/AdminPanel.svelte diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/frontend/src/admin/sections/AdsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/AdsSection.svelte rename to frontend/src/admin/sections/AdsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte b/frontend/src/admin/sections/AppearanceSection.svelte similarity index 97% rename from bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte rename to frontend/src/admin/sections/AppearanceSection.svelte index a6766c1..b856842 100644 --- a/bot/app/web/frontend/src/admin/sections/AppearanceSection.svelte +++ b/frontend/src/admin/sections/AppearanceSection.svelte @@ -225,11 +225,11 @@ clearPendingObjectUrl(); return; } - settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_URL", uploadedUrl); if (uploaded?.faviconUrl) { - settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); } - settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); + settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false); if (logoFileInput) logoFileInput.value = ""; }); } @@ -240,11 +240,11 @@ if (!uploadedUrl) return; setPendingLogoPreview(uploadedUrl); logoSourceUrl = ""; - settingsStore.markDirty("WEBAPP_LOGO_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_URL", uploadedUrl); if (uploaded?.faviconUrl) { - settingsStore.markDirty("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); + settingsStore.setFieldValue("WEBAPP_LOGO_FAVICON_URL", uploaded.faviconUrl); } - settingsStore.markDirty("WEBAPP_LOGO_USE_EMOJI", false); + settingsStore.setFieldValue("WEBAPP_LOGO_USE_EMOJI", false); }); } @@ -262,8 +262,9 @@ clearPendingFaviconObjectUrl(); return; } - settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); - setCustomFavicon(true); + settingsStore.setFieldValue("WEBAPP_FAVICON_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_FAVICON_USE_CUSTOM", true); + faviconUseCustomDraft = true; if (faviconFileInput) faviconFileInput.value = ""; }); } @@ -274,8 +275,9 @@ if (!uploadedUrl) return; setPendingFaviconPreview(uploadedUrl); faviconSourceUrl = ""; - settingsStore.markDirty("WEBAPP_FAVICON_URL", uploadedUrl); - setCustomFavicon(true); + settingsStore.setFieldValue("WEBAPP_FAVICON_URL", uploadedUrl); + settingsStore.setFieldValue("WEBAPP_FAVICON_USE_CUSTOM", true); + faviconUseCustomDraft = true; }); } diff --git a/bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte b/frontend/src/admin/sections/BroadcastSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/BroadcastSection.svelte rename to frontend/src/admin/sections/BroadcastSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/LogsSection.svelte b/frontend/src/admin/sections/LogsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/LogsSection.svelte rename to frontend/src/admin/sections/LogsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte b/frontend/src/admin/sections/PaymentsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/PaymentsSection.svelte rename to frontend/src/admin/sections/PaymentsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/PromosSection.svelte b/frontend/src/admin/sections/PromosSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/PromosSection.svelte rename to frontend/src/admin/sections/PromosSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/SettingsSection.svelte rename to frontend/src/admin/sections/SettingsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/StatsSection.svelte rename to frontend/src/admin/sections/StatsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/TariffEditorModal.svelte rename to frontend/src/admin/sections/TariffEditorModal.svelte diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/frontend/src/admin/sections/TariffsSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/TariffsSection.svelte rename to frontend/src/admin/sections/TariffsSection.svelte diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte rename to frontend/src/admin/sections/UserDetailModal.svelte diff --git a/bot/app/web/frontend/src/admin/sections/UsersSection.svelte b/frontend/src/admin/sections/UsersSection.svelte similarity index 100% rename from bot/app/web/frontend/src/admin/sections/UsersSection.svelte rename to frontend/src/admin/sections/UsersSection.svelte diff --git a/bot/app/web/frontend/src/lib/admin/format.js b/frontend/src/lib/admin/format.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/format.js rename to frontend/src/lib/admin/format.js diff --git a/bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js b/frontend/src/lib/admin/revenueSeriesAgg.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js rename to frontend/src/lib/admin/revenueSeriesAgg.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/adsStore.js b/frontend/src/lib/admin/stores/adsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/adsStore.js rename to frontend/src/lib/admin/stores/adsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/broadcastStore.js rename to frontend/src/lib/admin/stores/broadcastStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/logsStore.js b/frontend/src/lib/admin/stores/logsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/logsStore.js rename to frontend/src/lib/admin/stores/logsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js b/frontend/src/lib/admin/stores/paymentsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/paymentsStore.js rename to frontend/src/lib/admin/stores/paymentsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/promosStore.js b/frontend/src/lib/admin/stores/promosStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/promosStore.js rename to frontend/src/lib/admin/stores/promosStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/settingsStore.js b/frontend/src/lib/admin/stores/settingsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/settingsStore.js rename to frontend/src/lib/admin/stores/settingsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/statsStore.js b/frontend/src/lib/admin/stores/statsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/statsStore.js rename to frontend/src/lib/admin/stores/statsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js b/frontend/src/lib/admin/stores/tariffsStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/tariffsStore.js rename to frontend/src/lib/admin/stores/tariffsStore.js diff --git a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js b/frontend/src/lib/admin/stores/themesStore.js similarity index 94% rename from bot/app/web/frontend/src/lib/admin/stores/themesStore.js rename to frontend/src/lib/admin/stores/themesStore.js index 9d07793..25b7b41 100644 --- a/bot/app/web/frontend/src/lib/admin/stores/themesStore.js +++ b/frontend/src/lib/admin/stores/themesStore.js @@ -73,7 +73,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_logo_uploaded_pending", {}, - "Логотип загружен. Сохраните изменения, чтобы применить его." + "Логотип загружен и применен." ) ); return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; @@ -103,7 +103,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_logo_uploaded_pending", {}, - "Логотип загружен. Сохраните изменения, чтобы применить его." + "Логотип загружен и применен." ) ); return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" }; @@ -134,7 +134,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_favicon_uploaded_pending", {}, - "Favicon загружена. Сохраните изменения, чтобы применить ее." + "Favicon загружена и применена." ) ); return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; @@ -164,7 +164,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) { at( "appearance_favicon_uploaded_pending", {}, - "Favicon загружена. Сохраните изменения, чтобы применить ее." + "Favicon загружена и применена." ) ); return { faviconUrl: data.favicon_url || "", variants: data.variants || {} }; diff --git a/bot/app/web/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/stores/usersStore.js rename to frontend/src/lib/admin/stores/usersStore.js diff --git a/bot/app/web/frontend/src/lib/admin/tariffDraft.js b/frontend/src/lib/admin/tariffDraft.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/tariffDraft.js rename to frontend/src/lib/admin/tariffDraft.js diff --git a/bot/app/web/frontend/src/lib/admin/users.js b/frontend/src/lib/admin/users.js similarity index 100% rename from bot/app/web/frontend/src/lib/admin/users.js rename to frontend/src/lib/admin/users.js diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminBadge.svelte b/frontend/src/lib/components/patterns/admin/AdminBadge.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminBadge.svelte rename to frontend/src/lib/components/patterns/admin/AdminBadge.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte b/frontend/src/lib/components/patterns/admin/AdminButton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte rename to frontend/src/lib/components/patterns/admin/AdminButton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte b/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte rename to frontend/src/lib/components/patterns/admin/AdminDashboardGrid.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte b/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte rename to frontend/src/lib/components/patterns/admin/AdminDashboardStack.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte b/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte rename to frontend/src/lib/components/patterns/admin/AdminEmptyState.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte b/frontend/src/lib/components/patterns/admin/AdminField.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte rename to frontend/src/lib/components/patterns/admin/AdminField.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte b/frontend/src/lib/components/patterns/admin/AdminPagination.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte rename to frontend/src/lib/components/patterns/admin/AdminPagination.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte rename to frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte b/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte rename to frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte b/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte rename to frontend/src/lib/components/patterns/admin/AdminSectionHeader.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte b/frontend/src/lib/components/patterns/admin/AdminSelect.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte rename to frontend/src/lib/components/patterns/admin/AdminSelect.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte b/frontend/src/lib/components/patterns/admin/AdminTable.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte rename to frontend/src/lib/components/patterns/admin/AdminTable.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte b/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte rename to frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte b/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte rename to frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js b/frontend/src/lib/components/patterns/admin/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/admin/index.js rename to frontend/src/lib/components/patterns/admin/index.js diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte b/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte rename to frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte b/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte rename to frontend/src/lib/components/patterns/webapp/EmptyCard.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte b/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte rename to frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte b/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte rename to frontend/src/lib/components/patterns/webapp/LinearProgress.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte rename to frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte b/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte rename to frontend/src/lib/components/patterns/webapp/StatusMessage.svelte diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/index.js b/frontend/src/lib/components/patterns/webapp/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/patterns/webapp/index.js rename to frontend/src/lib/components/patterns/webapp/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/badge.svelte b/frontend/src/lib/components/ui/badge.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/badge.svelte rename to frontend/src/lib/components/ui/badge.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/button.svelte b/frontend/src/lib/components/ui/button.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/button.svelte rename to frontend/src/lib/components/ui/button.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card.svelte b/frontend/src/lib/components/ui/card.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card.svelte rename to frontend/src/lib/components/ui/card.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-action.svelte rename to frontend/src/lib/components/ui/card/card-action.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-content.svelte rename to frontend/src/lib/components/ui/card/card-content.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-description.svelte rename to frontend/src/lib/components/ui/card/card-description.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-footer.svelte rename to frontend/src/lib/components/ui/card/card-footer.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-header.svelte rename to frontend/src/lib/components/ui/card/card-header.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card-title.svelte rename to frontend/src/lib/components/ui/card/card-title.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/card.svelte rename to frontend/src/lib/components/ui/card/card.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/card/index.js b/frontend/src/lib/components/ui/card/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/card/index.js rename to frontend/src/lib/components/ui/card/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/frontend/src/lib/components/ui/dialog.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/dialog.svelte rename to frontend/src/lib/components/ui/dialog.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/icons.js rename to frontend/src/lib/components/ui/icons.js diff --git a/bot/app/web/frontend/src/lib/components/ui/index.js b/frontend/src/lib/components/ui/index.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/index.js rename to frontend/src/lib/components/ui/index.js diff --git a/bot/app/web/frontend/src/lib/components/ui/input.svelte b/frontend/src/lib/components/ui/input.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/input.svelte rename to frontend/src/lib/components/ui/input.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/primitives.js b/frontend/src/lib/components/ui/primitives.js similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/primitives.js rename to frontend/src/lib/components/ui/primitives.js diff --git a/bot/app/web/frontend/src/lib/components/ui/skeleton.svelte b/frontend/src/lib/components/ui/skeleton.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/skeleton.svelte rename to frontend/src/lib/components/ui/skeleton.svelte diff --git a/bot/app/web/frontend/src/lib/components/ui/spinner.svelte b/frontend/src/lib/components/ui/spinner.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/components/ui/spinner.svelte rename to frontend/src/lib/components/ui/spinner.svelte diff --git a/bot/app/web/frontend/src/lib/utils.js b/frontend/src/lib/utils.js similarity index 100% rename from bot/app/web/frontend/src/lib/utils.js rename to frontend/src/lib/utils.js diff --git a/bot/app/web/frontend/src/lib/webapp/BrandMark.svelte b/frontend/src/lib/webapp/BrandMark.svelte similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/BrandMark.svelte rename to frontend/src/lib/webapp/BrandMark.svelte diff --git a/bot/app/web/frontend/src/lib/webapp/authHelpers.js b/frontend/src/lib/webapp/authHelpers.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/authHelpers.js rename to frontend/src/lib/webapp/authHelpers.js diff --git a/bot/app/web/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/billingActions.js rename to frontend/src/lib/webapp/billingActions.js diff --git a/bot/app/web/frontend/src/lib/webapp/billingOptionCache.js b/frontend/src/lib/webapp/billingOptionCache.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/billingOptionCache.js rename to frontend/src/lib/webapp/billingOptionCache.js diff --git a/bot/app/web/frontend/src/lib/webapp/browser.js b/frontend/src/lib/webapp/browser.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/browser.js rename to frontend/src/lib/webapp/browser.js diff --git a/bot/app/web/frontend/src/lib/webapp/constants.js b/frontend/src/lib/webapp/constants.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/constants.js rename to frontend/src/lib/webapp/constants.js diff --git a/bot/app/web/frontend/src/lib/webapp/devicesLabels.js b/frontend/src/lib/webapp/devicesLabels.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/devicesLabels.js rename to frontend/src/lib/webapp/devicesLabels.js diff --git a/bot/app/web/frontend/src/lib/webapp/formatters.js b/frontend/src/lib/webapp/formatters.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/formatters.js rename to frontend/src/lib/webapp/formatters.js diff --git a/bot/app/web/frontend/src/lib/webapp/gravatar.js b/frontend/src/lib/webapp/gravatar.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/gravatar.js rename to frontend/src/lib/webapp/gravatar.js diff --git a/bot/app/web/frontend/src/lib/webapp/i18n.js b/frontend/src/lib/webapp/i18n.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/i18n.js rename to frontend/src/lib/webapp/i18n.js diff --git a/bot/app/web/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/mockApi.js rename to frontend/src/lib/webapp/mockApi.js diff --git a/bot/app/web/frontend/src/lib/webapp/plurals.js b/frontend/src/lib/webapp/plurals.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/plurals.js rename to frontend/src/lib/webapp/plurals.js diff --git a/bot/app/web/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/previewMock.js rename to frontend/src/lib/webapp/previewMock.js diff --git a/bot/app/web/frontend/src/lib/webapp/publicApi.js b/frontend/src/lib/webapp/publicApi.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/publicApi.js rename to frontend/src/lib/webapp/publicApi.js diff --git a/bot/app/web/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/routes.js rename to frontend/src/lib/webapp/routes.js diff --git a/bot/app/web/frontend/src/lib/webapp/session.js b/frontend/src/lib/webapp/session.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/session.js rename to frontend/src/lib/webapp/session.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/accountStore.js rename to frontend/src/lib/webapp/stores/accountStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/authStore.js b/frontend/src/lib/webapp/stores/authStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/authStore.js rename to frontend/src/lib/webapp/stores/authStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/billingStore.js rename to frontend/src/lib/webapp/stores/billingStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/stores/devicesStore.js b/frontend/src/lib/webapp/stores/devicesStore.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/stores/devicesStore.js rename to frontend/src/lib/webapp/stores/devicesStore.js diff --git a/bot/app/web/frontend/src/lib/webapp/tariffs.js b/frontend/src/lib/webapp/tariffs.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/tariffs.js rename to frontend/src/lib/webapp/tariffs.js diff --git a/bot/app/web/frontend/src/lib/webapp/telegramSdk.js b/frontend/src/lib/webapp/telegramSdk.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/telegramSdk.js rename to frontend/src/lib/webapp/telegramSdk.js diff --git a/bot/app/web/frontend/src/lib/webapp/themeStyle.js b/frontend/src/lib/webapp/themeStyle.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/themeStyle.js rename to frontend/src/lib/webapp/themeStyle.js diff --git a/bot/app/web/frontend/src/lib/webapp/traffic.js b/frontend/src/lib/webapp/traffic.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/traffic.js rename to frontend/src/lib/webapp/traffic.js diff --git a/bot/app/web/frontend/src/lib/webapp/webappBoot.js b/frontend/src/lib/webapp/webappBoot.js similarity index 100% rename from bot/app/web/frontend/src/lib/webapp/webappBoot.js rename to frontend/src/lib/webapp/webappBoot.js diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..e06f2b0 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,36 @@ +import { mount } from "svelte"; + +import App from "./App.svelte"; +import "./styles.css"; + +async function loadBootstrap() { + if (document.getElementById("webapp-config")) return; + try { + const response = await fetch("/api/bootstrap", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!response.ok) return; + const payload = await response.json(); + for (const [id, value] of [ + ["webapp-config", payload.config], + ["i18n", payload.i18n], + ]) { + const script = document.createElement("script"); + script.id = id; + script.type = "application/json"; + script.textContent = JSON.stringify(value || {}); + document.head.appendChild(script); + } + } catch (_error) { + void _error; + } +} + +const target = document.getElementById("app"); + +if (target) { + loadBootstrap().finally(() => { + mount(App, { target }); + }); +} diff --git a/bot/app/web/frontend/src/preview/BackTitle.svelte b/frontend/src/preview/BackTitle.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/BackTitle.svelte rename to frontend/src/preview/BackTitle.svelte diff --git a/bot/app/web/frontend/src/preview/PhoneFrame.svelte b/frontend/src/preview/PhoneFrame.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PhoneFrame.svelte rename to frontend/src/preview/PhoneFrame.svelte diff --git a/bot/app/web/frontend/src/preview/PreviewMethods.svelte b/frontend/src/preview/PreviewMethods.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PreviewMethods.svelte rename to frontend/src/preview/PreviewMethods.svelte diff --git a/bot/app/web/frontend/src/preview/PreviewNav.svelte b/frontend/src/preview/PreviewNav.svelte similarity index 100% rename from bot/app/web/frontend/src/preview/PreviewNav.svelte rename to frontend/src/preview/PreviewNav.svelte diff --git a/bot/app/web/frontend/src/styles.css b/frontend/src/styles.css similarity index 100% rename from bot/app/web/frontend/src/styles.css rename to frontend/src/styles.css diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/frontend/src/styles/admin-controls.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin-controls.css rename to frontend/src/styles/admin-controls.css diff --git a/bot/app/web/frontend/src/styles/admin-dialogs.css b/frontend/src/styles/admin-dialogs.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin-dialogs.css rename to frontend/src/styles/admin-dialogs.css diff --git a/bot/app/web/frontend/src/styles/admin.css b/frontend/src/styles/admin.css similarity index 100% rename from bot/app/web/frontend/src/styles/admin.css rename to frontend/src/styles/admin.css diff --git a/bot/app/web/frontend/src/styles/base.css b/frontend/src/styles/base.css similarity index 100% rename from bot/app/web/frontend/src/styles/base.css rename to frontend/src/styles/base.css diff --git a/bot/app/web/frontend/src/styles/components.css b/frontend/src/styles/components.css similarity index 100% rename from bot/app/web/frontend/src/styles/components.css rename to frontend/src/styles/components.css diff --git a/bot/app/web/frontend/src/styles/controls.css b/frontend/src/styles/controls.css similarity index 100% rename from bot/app/web/frontend/src/styles/controls.css rename to frontend/src/styles/controls.css diff --git a/bot/app/web/frontend/src/styles/dialogs.css b/frontend/src/styles/dialogs.css similarity index 100% rename from bot/app/web/frontend/src/styles/dialogs.css rename to frontend/src/styles/dialogs.css diff --git a/bot/app/web/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css similarity index 100% rename from bot/app/web/frontend/src/styles/tailwind.css rename to frontend/src/styles/tailwind.css diff --git a/bot/app/web/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css similarity index 100% rename from bot/app/web/frontend/src/styles/webapp.css rename to frontend/src/styles/webapp.css diff --git a/bot/app/web/frontend/src/webapp/BottomNav.svelte b/frontend/src/webapp/BottomNav.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/BottomNav.svelte rename to frontend/src/webapp/BottomNav.svelte diff --git a/bot/app/web/frontend/src/webapp/PaymentDialogs.svelte b/frontend/src/webapp/PaymentDialogs.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/PaymentDialogs.svelte rename to frontend/src/webapp/PaymentDialogs.svelte diff --git a/bot/app/web/frontend/src/webapp/TariffDialogs.svelte b/frontend/src/webapp/TariffDialogs.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/TariffDialogs.svelte rename to frontend/src/webapp/TariffDialogs.svelte diff --git a/bot/app/web/frontend/src/webapp/WebAppShell.svelte b/frontend/src/webapp/WebAppShell.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/WebAppShell.svelte rename to frontend/src/webapp/WebAppShell.svelte diff --git a/bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte b/frontend/src/webapp/auth/AuthScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/auth/AuthScreen.svelte rename to frontend/src/webapp/auth/AuthScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte b/frontend/src/webapp/screens/DevicesScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/DevicesScreen.svelte rename to frontend/src/webapp/screens/DevicesScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/HomeScreen.svelte rename to frontend/src/webapp/screens/HomeScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte b/frontend/src/webapp/screens/InviteScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/InviteScreen.svelte rename to frontend/src/webapp/screens/InviteScreen.svelte diff --git a/bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte b/frontend/src/webapp/screens/SettingsScreen.svelte similarity index 100% rename from bot/app/web/frontend/src/webapp/screens/SettingsScreen.svelte rename to frontend/src/webapp/screens/SettingsScreen.svelte diff --git a/bot/app/web/frontend/vite.config.mjs b/frontend/vite.config.mjs similarity index 93% rename from bot/app/web/frontend/vite.config.mjs rename to frontend/vite.config.mjs index 69aa3b6..f087fc6 100644 --- a/bot/app/web/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -6,7 +6,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const templateDir = path.resolve(__dirname, "../templates"); +const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates"); export default defineConfig({ resolve: { diff --git a/locales/en.json b/locales/en.json index 6812dc6..a0ab36e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1235,6 +1235,8 @@ "admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.", "admin_themes_saved": "Themes saved", "admin_themes_save_failed": "Failed to save themes", + "appearance_logo_uploaded_pending": "Logo uploaded and applied.", + "appearance_favicon_uploaded_pending": "Favicon uploaded and applied.", "admin_no_description": "No description", "admin_tariff_model_traffic": "Traffic", "admin_tariff_model_periods": "Periods", diff --git a/locales/ru.json b/locales/ru.json index 113ee5a..ed230ae 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1235,6 +1235,8 @@ "admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.", "admin_themes_saved": "Темы сохранены", "admin_themes_save_failed": "Не удалось сохранить темы", + "appearance_logo_uploaded_pending": "Логотип загружен и применен.", + "appearance_favicon_uploaded_pending": "Favicon загружена и применена.", "admin_no_description": "Без описания", "admin_tariff_model_traffic": "Трафик", "admin_tariff_model_periods": "Периоды", diff --git a/main.py b/main.py deleted file mode 100644 index 1791056..0000000 --- a/main.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -import logging -import os -import sys - -from dotenv import load_dotenv - -from bot.main_bot import run_bot -from config.settings import get_settings -from db.database_setup import init_db, init_db_connection - -_STARTUP_BANNER = r""" - ~ ~ ~ r e m n a w a v e ~ ~ ~ - - ███╗ ███╗██╗███╗ ██╗██╗███████╗██╗ ██╗ ██████╗ ██████╗ - ████╗ ████║██║████╗ ██║██║██╔════╝██║ ██║██╔═══██╗██╔══██╗ - ██╔████╔██║██║██╔██╗ ██║██║███████╗███████║██║ ██║██████╔╝ - ██║╚██╔╝██║██║██║╚██╗██║██║╚════██║██╔══██║██║ ██║██╔═══╝ - ██║ ╚═╝ ██║██║██║ ╚████║██║███████║██║ ██║╚██████╔╝██║ - ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ - - https://github.com/3252a8/remnawave-minishop -""" - - -def _print_startup_banner() -> None: - # Bypass the logging formatter so the ASCII art renders without per-line - # timestamp/level prefixes mangling the box characters. - try: - sys.stdout.write(_STARTUP_BANNER + "\n") - sys.stdout.flush() - except Exception: - # Banner is purely cosmetic; never fail startup over it. - pass - - -def _resolve_log_level(value: str) -> int: - return getattr(logging, value.upper(), logging.INFO) - - -async def main(): - settings = get_settings() - - session_factory = init_db_connection(settings) - if not session_factory: - logging.critical("Failed to initialize DB connection and session factory. Exiting.") - return - - await init_db(settings, session_factory) - - await run_bot(settings) - - -if __name__ == "__main__": - _print_startup_banner() - load_dotenv() - logging.basicConfig( - level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), - stream=sys.stdout, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - try: - asyncio.run(main()) - except (KeyboardInterrupt, SystemExit): - logging.info("Bot stopped manually") - except Exception as e_global: - logging.critical(f"Global unhandled exception in main: {e_global}", exc_info=True) - sys.exit(1) diff --git a/package.json b/package.json index 1b5a7c5..581c0f4 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,17 @@ { "private": true, "scripts": { - "build:webapp:svelte": "vite build --config ./bot/app/web/frontend/vite.config.mjs", - "build:webapp:css": "npm run build:webapp:svelte", - "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", - "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", - "lint": "npm run lint:py && npm run lint:js", + "build:webapp": "npm --prefix frontend run build:webapp", "lint:py": "python -m ruff check .", - "lint:js": "eslint bot/app/web/frontend scripts", - "lint:fix": "npm run lint:py:fix && npm run lint:js:fix", - "lint:py:fix": "python -m ruff check --fix .", - "lint:js:fix": "eslint bot/app/web/frontend scripts --fix", - "format": "npm run format:py && npm run format:js", + "lint:js": "npm --prefix frontend run lint", + "lint": "npm run lint:py && npm run lint:js", "format:py": "python -m ruff format .", - "format:js": "prettier --write \"bot/app/web/frontend/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", - "format:check": "npm run format:check:py && npm run format:check:js", + "format:js": "npm --prefix frontend run format", + "format": "npm run format:py && npm run format:js", "format:check:py": "python -m ruff format --check .", - "format:check:js": "prettier --check \"bot/app/web/frontend/**/*.{js,svelte}\" \"scripts/**/*.mjs\"", - "check": "npm run lint && npm run format:check", - "fix": "npm run format && npm run lint:fix" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@internationalized/date": "^3.12.1", - "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@tailwindcss/cli": "4.3.0", - "@tailwindcss/vite": "^4.3.0", - "bits-ui": "^2.18.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "esbuild": "^0.28.0", - "eslint": "^10.3.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.14.0", - "globals": "^17.6.0", - "lucide-svelte": "^1.0.1", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.4.0", - "svelte": "^5.55.5", - "svelte-eslint-parser": "^1.4.1", - "tailwind-merge": "^3.6.0", - "tailwindcss": "4.3.0", - "uplot": "^1.6.32", - "vite": "^8.0.12" + "format:check:js": "npm --prefix frontend run format:check", + "format:check": "npm run format:check:py && npm run format:check:js", + "test": "pytest -q", + "check": "npm run lint && npm test && npm run build:webapp" } } diff --git a/pyproject.toml b/pyproject.toml index d15daf2..2941ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ exclude = [ ".pytest_cache", "__pycache__", "node_modules", - "bot/app/web/templates", + "backend/bot/app/web/templates", ] [tool.ruff.lint] @@ -22,7 +22,7 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["bot", "config", "db", "tests"] +known-first-party = ["app_logging", "bot", "config", "db", "tests"] [tool.ruff.format] quote-style = "double" diff --git a/pytest.ini b/pytest.ini index a635c5c..013b45f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -pythonpath = . +pythonpath = backend . diff --git a/requirements-dev.txt b/requirements-dev.txt index f079b95..e0f35a7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ # Dev-only tooling (install: pip install -r requirements-dev.txt) --r requirements.txt +-r backend/requirements.txt ruff>=0.8.0 diff --git a/scripts/check-all.ps1 b/scripts/check-all.ps1 index f17bd8a..5b247d8 100644 --- a/scripts/check-all.ps1 +++ b/scripts/check-all.ps1 @@ -1,4 +1,4 @@ -# Запуск из корня репозитория: .\scripts\check-all.ps1 +# Run from repository root: .\scripts\check-all.ps1 $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root @@ -6,9 +6,9 @@ Set-Location $root Write-Host "Python: pip install -r requirements-dev.txt" -ForegroundColor Cyan python -m pip install -q -r requirements-dev.txt -if (-not (Test-Path (Join-Path $root "node_modules"))) { - Write-Host "npm install (нет node_modules)" -ForegroundColor Cyan - npm install +if (-not (Test-Path (Join-Path $root "frontend/node_modules"))) { + Write-Host "npm install --prefix frontend" -ForegroundColor Cyan + npm --prefix frontend install } Write-Host "npm run check" -ForegroundColor Cyan diff --git a/scripts/check-all.sh b/scripts/check-all.sh index 473b971..ec5392f 100644 --- a/scripts/check-all.sh +++ b/scripts/check-all.sh @@ -6,9 +6,9 @@ cd "$ROOT" echo "Python: pip install -r requirements-dev.txt" python -m pip install -q -r requirements-dev.txt -if [[ ! -d node_modules ]]; then - echo "npm install (no node_modules)" - npm install +if [[ ! -d frontend/node_modules ]]; then + echo "npm install --prefix frontend" + npm --prefix frontend install fi echo "npm run check" diff --git a/scripts/docker-build-images.ps1 b/scripts/docker-build-images.ps1 new file mode 100644 index 0000000..e4baa86 --- /dev/null +++ b/scripts/docker-build-images.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference = "Stop" + +$imageRegistry = if ($env:IMAGE_REGISTRY) { $env:IMAGE_REGISTRY } else { "ghcr.io" } +$imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "3252a8" } +$imageTag = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "local" } +$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" } +$dockerfile = if ($env:DOCKERFILE) { $env:DOCKERFILE } else { "deploy/docker/Dockerfile" } + +function Build-Image { + param([string]$Target) + $image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag" + Write-Host "Building $image" -ForegroundColor Cyan + docker build -f $dockerfile --target $Target -t $image . +} + +Build-Image backend +Build-Image worker +Build-Image frontend diff --git a/scripts/docker-build-images.sh b/scripts/docker-build-images.sh new file mode 100644 index 0000000..3c1e315 --- /dev/null +++ b/scripts/docker-build-images.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io}" +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}" +IMAGE_TAG="${IMAGE_TAG:-local}" +IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}" +DOCKERFILE="${DOCKERFILE:-deploy/docker/Dockerfile}" + +build_image() { + local target="$1" + local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG" + echo "Building $image" + docker build -f "$DOCKERFILE" --target "$target" -t "$image" . +} + +build_image backend +build_image worker +build_image frontend diff --git a/scripts/docker-push-images.ps1 b/scripts/docker-push-images.ps1 new file mode 100644 index 0000000..af0c416 --- /dev/null +++ b/scripts/docker-push-images.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = "Stop" + +if (-not $env:IMAGE_TAG) { + throw "Set IMAGE_TAG to the release tag you want to push" +} + +$imageRegistry = if ($env:IMAGE_REGISTRY) { $env:IMAGE_REGISTRY } else { "ghcr.io" } +$imageNamespace = if ($env:IMAGE_NAMESPACE) { $env:IMAGE_NAMESPACE } else { "3252a8" } +$imageTag = $env:IMAGE_TAG +$imagePrefix = if ($env:IMAGE_PREFIX) { $env:IMAGE_PREFIX } else { "remnawave-minishop" } + +function Push-Image { + param([string]$Target) + $image = "$imageRegistry/$imageNamespace/$imagePrefix-$Target`:$imageTag" + Write-Host "Pushing $image" -ForegroundColor Cyan + docker push $image +} + +Push-Image backend +Push-Image worker +Push-Image frontend diff --git a/scripts/docker-push-images.sh b/scripts/docker-push-images.sh new file mode 100644 index 0000000..be5b13b --- /dev/null +++ b/scripts/docker-push-images.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REGISTRY="${IMAGE_REGISTRY:-ghcr.io}" +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-3252a8}" +IMAGE_TAG="${IMAGE_TAG:?Set IMAGE_TAG to the release tag you want to push}" +IMAGE_PREFIX="${IMAGE_PREFIX:-remnawave-minishop}" + +push_image() { + local target="$1" + local image="$IMAGE_REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX-$target:$IMAGE_TAG" + echo "Pushing $image" + docker push "$image" +} + +push_image backend +push_image worker +push_image frontend diff --git a/scripts/send_premium_traffic_warning_test.py b/scripts/send_premium_traffic_warning_test.py index 33e1ff8..bdc0b62 100644 --- a/scripts/send_premium_traffic_warning_test.py +++ b/scripts/send_premium_traffic_warning_test.py @@ -11,6 +11,9 @@ No default Telegram ID is embedded in this script. import asyncio import os import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo diff --git a/tests/test_settings.py b/tests/test_settings.py index d3ec935..c093149 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,8 +1,12 @@ import json +import tempfile import unittest +from pathlib import Path +from unittest.mock import patch from pydantic import ValidationError +from bot.services import settings_override_service from config.settings import Settings @@ -67,9 +71,6 @@ class SettingsTests(unittest.TestCase): self.assertTrue(settings.traffic_sale_mode) def test_existing_tariffs_config_disables_legacy_traffic_mode(self): - import tempfile - from pathlib import Path - with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "tariffs.json" path.write_text( @@ -106,6 +107,40 @@ class SettingsTests(unittest.TestCase): self.assertIsNotNone(settings.tariffs_config) self.assertFalse(settings.traffic_sale_mode) + def test_appearance_backup_roundtrip_preserves_logo_theme_and_favicon_settings(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + ) + settings.WEBAPP_LOGO_URL = "/webapp-uploaded-logo/logo-1111111111111111.png" + settings.WEBAPP_LOGO_USE_EMOJI = False + settings.WEBAPP_LOGO_FAVICON_URL = "/webapp-favicon/aaaaaaaaaaaaaaaa/icon-180.png" + settings.WEBAPP_FAVICON_USE_CUSTOM = True + settings.WEBAPP_FAVICON_URL = "/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png" + settings.WEBAPP_PRIMARY_COLOR = "#123456" + + with tempfile.TemporaryDirectory() as tmpdir: + backup_path = Path(tmpdir) / "appearance-settings.json" + with patch.object( + settings_override_service, + "APPEARANCE_OVERRIDES_BACKUP_PATH", + backup_path, + ): + settings_override_service.write_appearance_backup(settings) + restored = settings_override_service._read_appearance_backup() + + self.assertEqual( + restored["WEBAPP_LOGO_URL"], + "/webapp-uploaded-logo/logo-1111111111111111.png", + ) + self.assertEqual(restored["WEBAPP_PRIMARY_COLOR"], "#123456") + self.assertEqual( + restored["WEBAPP_FAVICON_URL"], + "/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png", + ) + def test_trial_traffic_strategy_is_available(self): settings = Settings( _env_file=None, diff --git a/tests/test_startup_banner.py b/tests/test_startup_banner.py new file mode 100644 index 0000000..e37ba68 --- /dev/null +++ b/tests/test_startup_banner.py @@ -0,0 +1,36 @@ +import io +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from startup_banner import print_startup_banner + + +class StartupBannerTests(unittest.TestCase): + def test_startup_banner_marks_services_without_mojibake(self): + for service in ("frontend", "backend", "worker", "migrate"): + with self.subTest(service=service): + buffer = io.StringIO() + + with ( + patch.dict( + os.environ, + { + "IMAGE_TAG": "test-tag", + "POSTGRES_HOST": "postgres", + "POSTGRES_DB": "postgres", + "REDIS_URL": "redis://redis:6379/0", + }, + clear=False, + ), + redirect_stdout(buffer), + ): + print_startup_banner(service) + + output = buffer.getvalue() + self.assertIn(f"container :: {service.upper()}", output) + self.assertIn("image tag :: test-tag", output) + self.assertIn("remnawave-minishop", output) + self.assertIn("███", output) + self.assertNotIn("в", output) diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index c331aac..52cc953 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -3,11 +3,13 @@ import tempfile import unittest from datetime import datetime, timezone from pathlib import Path -from unittest.mock import AsyncMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService from config.settings import Settings +from db.dal.subscription_dal import _subscription_model_payload GIB = 1024**3 @@ -187,6 +189,59 @@ class SubscriptionServiceCalculationTests(unittest.TestCase): class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCase): + async def test_activate_trial_keeps_panel_strategy_out_of_local_subscription_payload(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=3, + TRIAL_TRAFFIC_LIMIT_GB=5, + TRIAL_TRAFFIC_STRATEGY="WEEK", + USER_SQUAD_UUIDS="trial-squad", + ) + service = _make_service(settings) + service.has_had_any_subscription = AsyncMock(return_value=False) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "panel-sub", "short", True) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"subscriptionUrl": "https://example.test/sub", "shortUuid": "short"} + ) + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="trial-user", + first_name="Trial", + last_name="User", + ) + + with ( + patch( + "bot.services.subscription_service_impl.trial.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.deactivate_other_active_subscriptions", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription", + AsyncMock(), + ) as upsert_subscription, + ): + result = await service.activate_trial_subscription(session, user_id=42) + + self.assertTrue(result["activated"]) + sub_payload = upsert_subscription.await_args.args[1] + self.assertNotIn("traffic_limit_strategy", sub_payload) + self.assertEqual(sub_payload["traffic_limit_bytes"], 5 * GIB) + + panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["trafficLimitStrategy"], "WEEK") + async def test_activate_subscription_dispatches_traffic_sale_mode(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) @@ -288,5 +343,21 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas self.assertEqual(kwargs["payment_db_id"], 12) +class SubscriptionDalPayloadTests(unittest.TestCase): + def test_subscription_model_payload_drops_panel_only_keys(self): + payload = _subscription_model_payload( + { + "user_id": 42, + "panel_user_uuid": "panel-user", + "panel_subscription_uuid": "panel-sub", + "end_date": datetime(2026, 1, 1, tzinfo=timezone.utc), + "traffic_limit_strategy": "WEEK", + } + ) + + self.assertEqual(payload["user_id"], 42) + self.assertNotIn("traffic_limit_strategy", payload) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 664ae12..b87bbf8 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -7,7 +7,7 @@ import unittest from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PIL import Image @@ -86,6 +86,8 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertNotIn("https://telegram.org/js/telegram-web-app.js", html) self.assertNotIn("https://fonts.googleapis.com", html) + self.assertNotIn('id="logo-preload"', html) + self.assertNotIn('href=""', html) self.assertLess(html.index("/subscription_webapp.css"), html.index("WEBAPP_JS_SCRIPT")) def test_https_webapp_logo_uses_same_origin_proxy(self): @@ -106,6 +108,23 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "/webapp-uploaded-logo/logo-abcdef1234567890.png", ) + async def test_webapp_logo_route_serves_configured_uploaded_logo(self): + settings = SimpleNamespace( + WEBAPP_LOGO_USE_EMOJI=False, + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-1111111111111111.png", + ) + request = SimpleNamespace(app={"settings": settings}) + + with tempfile.TemporaryDirectory() as tmpdir: + logo_dir = Path(tmpdir) + (logo_dir / "logo-1111111111111111.png").write_bytes(b"logo") + with patch.object(webapp_assets, "WEBAPP_UPLOADED_LOGO_DIR", logo_dir): + response = await webapp_assets.webapp_logo_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(response.content_type, "image/png") + self.assertEqual(response.body, b"logo") + def test_webapp_logo_is_hidden_when_emoji_logo_is_enabled(self): settings = SimpleNamespace( WEBAPP_LOGO_USE_EMOJI=True, @@ -172,6 +191,88 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertTrue((Path(tmpdir) / digest / "apple-touch-icon.png").exists()) self.assertTrue((Path(tmpdir) / digest / "favicon.ico").exists()) + def test_prune_unused_appearance_assets_keeps_only_referenced_logo_and_favicons(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + uploads = root / "uploads" + favicons = root / "favicons" + emoji = root / "emoji" + uploads.mkdir() + favicons.mkdir() + emoji.mkdir() + (uploads / "logo-1111111111111111.png").write_bytes(b"keep") + (uploads / "logo-2222222222222222.png").write_bytes(b"remove") + (favicons / "aaaaaaaaaaaaaaaa").mkdir() + (favicons / "bbbbbbbbbbbbbbbb").mkdir() + (favicons / "cccccccccccccccc").mkdir() + (favicons / "aaaaaaaaaaaaaaaa" / "icon-180.png").write_bytes(b"keep") + (favicons / "bbbbbbbbbbbbbbbb" / "icon-180.png").write_bytes(b"keep") + (favicons / "cccccccccccccccc" / "icon-180.png").write_bytes(b"remove") + (emoji / "1f929.512.gif").write_bytes(b"keep") + (emoji / "1f929.512.webp").write_bytes(b"keep") + (emoji / "1f525.512.gif").write_bytes(b"remove") + settings = SimpleNamespace( + WEBAPP_LOGO_URL="/webapp-uploaded-logo/logo-1111111111111111.png", + WEBAPP_FAVICON_URL="/webapp-favicon/aaaaaaaaaaaaaaaa/icon-180.png", + WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/bbbbbbbbbbbbbbbb/icon-180.png", + WEBAPP_LOGO_USE_EMOJI=True, + WEBAPP_LOGO_EMOJI="🤩", + WEBAPP_LOGO_EMOJI_FONT="noto-color-animated", + ) + + with ( + patch.object(admin_themes, "WEBAPP_UPLOADED_LOGO_DIR", uploads), + patch.object(admin_themes, "WEBAPP_FAVICON_DIR", favicons), + patch.object(admin_themes, "WEBAPP_EMOJI_CACHE_DIR", emoji), + ): + admin_themes.prune_unused_appearance_assets(settings) + + self.assertTrue((uploads / "logo-1111111111111111.png").exists()) + self.assertFalse((uploads / "logo-2222222222222222.png").exists()) + self.assertTrue((favicons / "aaaaaaaaaaaaaaaa").exists()) + self.assertTrue((favicons / "bbbbbbbbbbbbbbbb").exists()) + self.assertFalse((favicons / "cccccccccccccccc").exists()) + self.assertTrue((emoji / "1f929.512.gif").exists()) + self.assertTrue((emoji / "1f929.512.webp").exists()) + self.assertFalse((emoji / "1f525.512.gif").exists()) + + async def test_persist_appearance_upload_writes_overrides_and_clears_caches(self): + settings = SimpleNamespace() + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": object(), + "webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}}, + "webapp_logo_cache": ("url", b"body", "image/png"), + } + ) + updates = { + "WEBAPP_LOGO_URL": "/webapp-uploaded-logo/logo-1111111111111111.png", + "WEBAPP_LOGO_USE_EMOJI": False, + } + + with ( + patch.object( + admin_themes, + "update_overrides", + AsyncMock(return_value={"ok": True}), + ) as update_mock, + patch.object(admin_themes, "prune_unused_appearance_assets") as prune_mock, + ): + persisted = await admin_themes._persist_appearance_upload(request, updates, 42) + + self.assertTrue(persisted) + update_mock.assert_awaited_once_with( + settings, + request.app["async_session_factory"], + updates=updates, + deletes=[], + actor_id=42, + ) + self.assertEqual(request.app["webapp_settings_cache"], {"ts": 0.0, "data": {}}) + self.assertIsNone(request.app["webapp_logo_cache"]) + prune_mock.assert_called_once_with(settings) + def test_initial_theme_head_markup_includes_css_and_tokens(self): cfg = builtin_webapp_themes_config("#123456") theme = cfg.theme_by_key("light") From aaa8e957f6d9c4f77f9914784e77d86c1ce29c74 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 00:30:54 +0300 Subject: [PATCH 19/45] fix: serve hashed minified webapp bundle from frontend image --- deploy/docker/Dockerfile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 2fdfcf9..a2f0291 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -69,14 +69,17 @@ COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_web COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ -RUN sed -i \ +RUN set -eu; \ + HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ + JS_NAME="${HASHED:-subscription_webapp.js}"; \ + sed -i \ -e '/WEBAPP_I18N_SCRIPT/d' \ -e '/WEBAPP_CONFIG_SCRIPT/d' \ - -e '/WEBAPP_JS_SCRIPT/c\ ' \ + -e "/WEBAPP_JS_SCRIPT/c\\ " \ -e '/WEBAPP_DEV_MOCK_START/d' \ -e '/WEBAPP_DEV_MOCK_END/d' \ -e '/subscription_webapp.js" defer/d' \ - /usr/share/nginx/html/index.html \ - && chmod +x /docker-entrypoint.d/00-startup-banner.sh + /usr/share/nginx/html/index.html; \ + chmod +x /docker-entrypoint.d/00-startup-banner.sh EXPOSE 80 From 99732211b7d9069c208fd581f8810ef3d36a5e76 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 00:30:59 +0300 Subject: [PATCH 20/45] docs: correct backend path prefix for admin manifest references --- docs/admin.md | 2 +- docs/configuration.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/admin.md b/docs/admin.md index 4645470..51ef001 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -33,7 +33,7 @@ ## Настройки -Раздел **Система -> Настройки** позволяет менять приложение без редактирования `.env`, но только в пределах allowlist из `bot/app/web/admin_settings_manifest.py`. Даже администратор не может менять через UI произвольные атрибуты `Settings`. +Раздел **Система -> Настройки** позволяет менять приложение без редактирования `.env`, но только в пределах allowlist из `backend/bot/app/web/admin_settings_manifest.py`. Даже администратор не может менять через UI произвольные атрибуты `Settings`. Изменения сохраняются как overrides в базе данных и применяются поверх `.env` без перезапуска. Если значение сброшено, снова используется значение из `.env`. После сохранения публичный кеш настроек Web App сбрасывается, поэтому пользователи видят изменения при следующих запросах. diff --git a/docs/configuration.md b/docs/configuration.md index f0f7e49..ff20bf3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ docker compose up -d --build --force-recreate Переопределения из веб-админки сохраняются в БД и применяются поверх `.env` без перезапуска. Для платежных методов кнопка отображается только если соответствующий `*_ENABLED=true` и сервис настроен. -Редактор тарифов в админке сохраняет не override в БД, а сам JSON-файл `TARIFFS_CONFIG_PATH`. Редактор настроек админки, наоборот, работает через allowlist из `bot/app/web/admin_settings_manifest.py` и сохраняет overrides в БД. Через него можно менять только заявленные в manifest параметры приложения; остальные параметры остаются в `.env`. Подробнее: [admin.md](admin.md). +Редактор тарифов в админке сохраняет не override в БД, а сам JSON-файл `TARIFFS_CONFIG_PATH`. Редактор настроек админки, наоборот, работает через allowlist из `backend/bot/app/web/admin_settings_manifest.py` и сохраняет overrides в БД. Через него можно менять только заявленные в manifest параметры приложения; остальные параметры остаются в `.env`. Подробнее: [admin.md](admin.md). ## Web App и email-вход @@ -229,7 +229,7 @@ docker compose up -d --build --force-recreate | `LOG_SUSPICIOUS_ACTIVITY` | Уведомлять о подозрительных попытках. | | `LOG_ADMIN_ACTIONS` | Писать в журнал админки действия пользователей из `ADMIN_IDS`. | -Часть этих переключателей доступна для правки через Web App (allowlist в `bot/app/web/admin_settings_manifest.py`), см. [admin.md](admin.md). +Часть этих переключателей доступна для правки через Web App (allowlist в `backend/bot/app/web/admin_settings_manifest.py`), см. [admin.md](admin.md). ## Миниатюры inline-режима From fc7f97c136be053a9acf98df551a5cccf6adb775 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 00:31:08 +0300 Subject: [PATCH 21/45] refactor: drop unused legacy run_bot flags and dead backend/main.py --- backend/bot/main_bot.py | 52 +++++++---------------------------------- backend/main.py | 36 ---------------------------- backend/main_backend.py | 2 +- 3 files changed, 9 insertions(+), 81 deletions(-) delete mode 100644 backend/main.py diff --git a/backend/bot/main_bot.py b/backend/bot/main_bot.py index 8cf1f81..06c67f4 100644 --- a/backend/bot/main_bot.py +++ b/backend/bot/main_bot.py @@ -15,7 +15,6 @@ from bot.middlewares.i18n import JsonI18n from bot.routers import build_root_router from bot.services.panel_api_service import PanelApiService from bot.services.settings_override_service import load_overrides_from_db -from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager from config.settings import Settings from db.database_setup import init_db_connection @@ -223,12 +222,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): logging.info("SHUTDOWN: Bot on_shutdown_configured completed.") -async def run_bot( - settings_param: Settings, - *, - run_web: bool = True, - run_tariff_worker: bool = False, -): +async def run_bot(settings_param: Settings): local_async_session_factory = init_db_connection(settings_param) if local_async_session_factory is None: logging.critical("Failed to initialize database connection and session factory. Exiting.") @@ -263,17 +257,6 @@ async def run_bot( dp[key] = service dp["panel_service"] = services["panel_service"] dp["async_session_factory"] = local_async_session_factory - tariff_worker = None - if run_tariff_worker: - tariff_worker = TariffTrafficWorker( - settings_param, - local_async_session_factory, - services["panel_service"], - services["subscription_service"], - bot, - i18n_instance, - ) - dp["tariff_worker"] = tariff_worker # Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed) async def _on_startup_wrapper(): @@ -287,36 +270,21 @@ async def run_bot( await register_all_routers(dp, settings_param) - tg_webhook_base = settings_param.WEBHOOK_BASE_URL - - # Webhook mode is now required - exit if not configured - if not tg_webhook_base: + if not settings_param.WEBHOOK_BASE_URL: logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.") await dp.emit_shutdown() raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.") - logging.info("--- Bot Run Mode Decision ---") - logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED") - logging.info(f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'") - logging.info("Decision: Run AIOHTTP server: ENABLED (required for webhooks)") - logging.info("--- End Bot Run Mode Decision ---") + logging.info( + "Starting AIOHTTP server: webhook_base=%s yookassa_path=%s", + settings_param.WEBHOOK_BASE_URL, + settings_param.yookassa_webhook_path, + ) - web_app_runner = None - main_tasks = [] - - # Only run AIOHTTP server for webhook mode async def web_server_task(): await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory) - if run_web: - main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) - if run_tariff_worker and tariff_worker and settings_param.tariffs_config: - main_tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) - - # Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here. - - logging.info("Starting bot runtime...") - logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") + main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")] try: await asyncio.gather(*main_tasks) @@ -337,10 +305,6 @@ async def run_bot( exc_info=True, ) - if web_app_runner: - await web_app_runner.cleanup() - logging.info("AIOHTTP AppRunner cleaned up.") - await dp.emit_shutdown() logging.info("Dispatcher shutdown sequence emitted.") diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index b8f2676..0000000 --- a/backend/main.py +++ /dev/null @@ -1,36 +0,0 @@ -import asyncio -import logging -import os -import sys - -from dotenv import load_dotenv -from startup_banner import print_startup_banner - -from bot.main_bot import run_bot -from config.settings import get_settings - - -def _resolve_log_level(value: str) -> int: - return getattr(logging, value.upper(), logging.INFO) - - -async def main(): - settings = get_settings() - await run_bot(settings, run_web=True, run_tariff_worker=False) - - -if __name__ == "__main__": - load_dotenv() - print_startup_banner("bot") - logging.basicConfig( - level=_resolve_log_level(os.getenv("LOG_LEVEL", "INFO")), - stream=sys.stdout, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - try: - asyncio.run(main()) - except (KeyboardInterrupt, SystemExit): - logging.info("Bot stopped manually") - except Exception as e_global: - logging.critical(f"Global unhandled exception in main: {e_global}", exc_info=True) - sys.exit(1) diff --git a/backend/main_backend.py b/backend/main_backend.py index 9916f79..80ba517 100644 --- a/backend/main_backend.py +++ b/backend/main_backend.py @@ -12,7 +12,7 @@ from config.settings import get_settings async def main() -> None: settings = get_settings() - await run_bot(settings, run_web=True, run_tariff_worker=False) + await run_bot(settings) if __name__ == "__main__": From 1e8ffa15d0c74758f3ce095ba7cd86f454159bdc Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 00:31:12 +0300 Subject: [PATCH 22/45] chore: add tests for redis infra, webhook queue and split-arch entrypoints --- tests/test_admin_sync_queue.py | 121 +++++++++++++ tests/test_app_logging.py | 75 ++++++++ tests/test_panel_webhook_routing.py | 141 +++++++++++++++ tests/test_startup_banner.py | 102 ++++++++--- tests/test_webhook_queue.py | 255 ++++++++++++++++++++++++++++ 5 files changed, 675 insertions(+), 19 deletions(-) create mode 100644 tests/test_admin_sync_queue.py create mode 100644 tests/test_app_logging.py create mode 100644 tests/test_panel_webhook_routing.py create mode 100644 tests/test_webhook_queue.py diff --git a/tests/test_admin_sync_queue.py b/tests/test_admin_sync_queue.py new file mode 100644 index 0000000..323f009 --- /dev/null +++ b/tests/test_admin_sync_queue.py @@ -0,0 +1,121 @@ +"""Verifies that the admin sync endpoint hands work to the worker via Redis. + +After the container split, /api/admin/sync no longer runs ``perform_sync`` +in-process; it enqueues a ``panel_sync`` event onto the webhook queue and +returns a fast ack. These tests pin that contract. +""" + +import json +import unittest +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import patch + +from aiohttp import web + +# Importing the facade populates each admin_api_impl submodule's globals with +# helpers like ``_require_admin_user_id`` and ``_ok``/``_error``. Without this +# side effect, ``sync_module._require_admin_user_id`` does not exist yet. +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.admin_api_impl import sync as sync_module + + +class _FakeRequest: + """The shape ``admin_sync_route`` reads from the aiohttp request.""" + + def __init__(self, settings: SimpleNamespace, admin_telegram_id: int = 42) -> None: + self.app = {"settings": settings} + self._store = {"admin_telegram_id": admin_telegram_id} + + def get(self, key: str, default: Any = None) -> Any: + return self._store.get(key, default) + + +def _make_settings() -> SimpleNamespace: + return SimpleNamespace( + ADMIN_IDS=[42], + REDIS_KEY_PREFIX="shop", + WEBHOOK_QUEUE_NAME="webhook-events", + REDIS_URL="redis://r:6379/0", + ) + + +def _parse(response: web.Response) -> dict: + return json.loads(response.body.decode()) + + +def _patch_admin_auth(monkeypatch_target: Any) -> None: + """``_require_admin_user_id`` looks up session state we don't have here.""" + monkeypatch_target.side_effect = lambda request: int(request.get("admin_telegram_id")) + + +class AdminSyncQueueTests(unittest.IsolatedAsyncioTestCase): + async def test_returns_queued_when_redis_accepts_event(self): + recorded: List[dict] = [] + + async def fake_enqueue(settings, provider, payload, *, event_id=None): + recorded.append( + { + "settings": settings, + "provider": provider, + "payload": payload, + "event_id": event_id, + } + ) + return True + + with ( + patch.object(sync_module, "enqueue_webhook_event", fake_enqueue), + patch.object(sync_module, "_require_admin_user_id") as auth, + ): + _patch_admin_auth(auth) + response = await sync_module.admin_sync_route(_FakeRequest(_make_settings())) + + self.assertEqual(response.status, 200) + body = _parse(response) + self.assertTrue(body["ok"]) + self.assertEqual(body["result"], {"status": "queued"}) + + self.assertEqual(len(recorded), 1) + entry = recorded[0] + self.assertEqual(entry["provider"], "panel_sync") + self.assertEqual(entry["payload"], {"requested_by": 42}) + # event_id=None lets Redis enqueue every admin request (no dedupe key). + self.assertIsNone(entry["event_id"]) + + async def test_returns_503_when_queue_is_unavailable(self): + async def fake_enqueue(settings, provider, payload, *, event_id=None): + return False + + with ( + patch.object(sync_module, "enqueue_webhook_event", fake_enqueue), + patch.object(sync_module, "_require_admin_user_id") as auth, + ): + _patch_admin_auth(auth) + response = await sync_module.admin_sync_route(_FakeRequest(_make_settings())) + + self.assertEqual(response.status, 503) + body = _parse(response) + self.assertFalse(body["ok"]) + self.assertEqual(body["error"], "queue_unavailable") + + async def test_non_admin_is_rejected_before_enqueueing(self): + async def fake_enqueue(*args, **kwargs): + raise AssertionError("enqueue must not be called for non-admin requests") + + def deny(_request): + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "forbidden"}), + content_type="application/json", + ) + + with ( + patch.object(sync_module, "enqueue_webhook_event", fake_enqueue), + patch.object(sync_module, "_require_admin_user_id", side_effect=deny), + ): + with self.assertRaises(web.HTTPForbidden): + await sync_module.admin_sync_route(_FakeRequest(_make_settings())) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_app_logging.py b/tests/test_app_logging.py new file mode 100644 index 0000000..fc9a1da --- /dev/null +++ b/tests/test_app_logging.py @@ -0,0 +1,75 @@ +import io +import logging +import os +import sys +import unittest +from unittest.mock import patch + +import app_logging + + +def _reset_root_logger() -> None: + root = logging.getLogger() + for handler in list(root.handlers): + root.removeHandler(handler) + handler.close() + root.setLevel(logging.WARNING) + + +class ConfigureLoggingTests(unittest.TestCase): + def setUp(self) -> None: + _reset_root_logger() + self.addCleanup(_reset_root_logger) + + def test_default_level_is_info(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("LOG_LEVEL", None) + app_logging.configure_logging() + self.assertEqual(logging.getLogger().level, logging.INFO) + + def test_log_level_env_is_honored(self): + with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}, clear=False): + app_logging.configure_logging() + self.assertEqual(logging.getLogger().level, logging.DEBUG) + + def test_invalid_log_level_falls_back_to_info(self): + with patch.dict(os.environ, {"LOG_LEVEL": "TOTALLY_BOGUS"}, clear=False): + app_logging.configure_logging() + self.assertEqual(logging.getLogger().level, logging.INFO) + + def test_handler_writes_to_stdout_with_expected_format(self): + # basicConfig is a no-op if root already has handlers — start clean. + with patch.dict(os.environ, {"LOG_LEVEL": "INFO"}, clear=False): + app_logging.configure_logging() + + # First handler is the one we installed (basicConfig adds at most one). + handlers = logging.getLogger().handlers + self.assertTrue(handlers, "configure_logging() must install a handler") + handler = handlers[0] + self.assertIs(handler.stream, sys.stdout) + + # Confirm the format string structure by emitting a record into a buffer. + formatter = handler.formatter + record = logging.LogRecord( + name="test", level=logging.INFO, pathname=__file__, lineno=1, + msg="hello", args=(), exc_info=None, + ) + rendered = formatter.format(record) if formatter else "" + # Format is "%(asctime)s - %(name)s - %(levelname)s - %(message)s". + self.assertIn(" - test - INFO - hello", rendered) + + def test_configure_logging_does_not_swallow_subsequent_logs(self): + buffer = io.StringIO() + # Reset state, then replace stdout briefly so we can capture handler output. + with patch.dict(os.environ, {"LOG_LEVEL": "INFO"}, clear=False): + with patch.object(sys, "stdout", buffer): + app_logging.configure_logging() + logging.getLogger("test.module").info("payload-marker") + for handler in logging.getLogger().handlers: + handler.flush() + self.assertIn("payload-marker", buffer.getvalue()) + self.assertIn("INFO", buffer.getvalue()) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_panel_webhook_routing.py b/tests/test_panel_webhook_routing.py new file mode 100644 index 0000000..5b12f27 --- /dev/null +++ b/tests/test_panel_webhook_routing.py @@ -0,0 +1,141 @@ +"""Behaviour of ``PanelWebhookService.handle_webhook``. + +The service must: + * reject requests without the configured shared secret or a missing/bad + signature header — otherwise an attacker could forge panel events; + * acknowledge an event with HTTP 200 once it is on the queue (the worker + container does the heavy lifting); + * fall back to in-process background dispatch when Redis is unreachable so + a single-node deploy still processes events. +""" + +import asyncio +import hashlib +import hmac +import json +import unittest +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import patch + +from bot.services import panel_webhook_service as pws + +SECRET = "panel-shared-secret" + + +def _sign(body: bytes) -> str: + return hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + + +def _make_service(): + settings = SimpleNamespace( + PANEL_WEBHOOK_SECRET=SECRET, + SUBSCRIPTION_NOTIFICATIONS_ENABLED=True, + SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3, + SUBSCRIPTION_NOTIFY_ON_EXPIRE=True, + SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True, + DEFAULT_LANGUAGE="ru", + email_auth_configured=False, + ) + # Build minimally with object() placeholders — handle_webhook does not + # touch the bot/i18n/db/panel collaborators on its enqueue path. + return pws.PanelWebhookService( + bot=object(), + settings=settings, + i18n=object(), + async_session_factory=object(), + panel_service=object(), + ) + + +class HandleWebhookSecurityTests(unittest.IsolatedAsyncioTestCase): + async def test_unauthorized_when_secret_not_configured(self): + service = _make_service() + service.settings.PANEL_WEBHOOK_SECRET = "" + response = await service.handle_webhook(b"{}", _sign(b"{}")) + self.assertEqual(response.status, 401) + + async def test_unauthorized_when_signature_header_missing(self): + service = _make_service() + response = await service.handle_webhook(b'{"name":"user.expired"}', None) + self.assertEqual(response.status, 401) + + async def test_unauthorized_on_signature_mismatch(self): + service = _make_service() + body = b'{"name":"user.expired"}' + response = await service.handle_webhook(body, "deadbeef") + self.assertEqual(response.status, 401) + + async def test_bad_request_for_invalid_json(self): + service = _make_service() + body = b"not-json" + response = await service.handle_webhook(body, _sign(body)) + self.assertEqual(response.status, 400) + + async def test_ok_no_event_when_name_missing(self): + service = _make_service() + body = json.dumps({"payload": {"telegramId": 1}}).encode() + response = await service.handle_webhook(body, _sign(body)) + self.assertEqual(response.status, 200) + self.assertEqual(response.text, "ok_no_event") + + +class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase): + async def test_enqueues_to_redis_and_returns_ok(self): + service = _make_service() + captured: List[dict] = [] + + async def fake_enqueue(settings, provider, payload, *, event_id=None): + captured.append( + {"provider": provider, "payload": payload, "event_id": event_id} + ) + return True + + body = json.dumps( + { + "name": "user.expires_in_24_hours", + "payload": {"telegramId": 99, "uuid": "abc"}, + } + ).encode() + + with patch.object(pws, "enqueue_webhook_event", fake_enqueue): + response = await service.handle_webhook(body, _sign(body)) + + self.assertEqual(response.status, 200) + self.assertEqual(response.text, "ok") + self.assertEqual(len(captured), 1) + entry = captured[0] + self.assertEqual(entry["provider"], "panel") + self.assertEqual(entry["payload"]["event"], "user.expires_in_24_hours") + self.assertEqual(entry["payload"]["user"], {"telegramId": 99, "uuid": "abc"}) + # event_id combines event name with the strongest available identifier. + self.assertEqual(entry["event_id"], "user.expires_in_24_hours:99") + + async def test_falls_back_to_background_task_when_redis_unavailable(self): + service = _make_service() + background_seen: List[Any] = [] + + async def fake_enqueue(*args, **kwargs): + return False + + async def fake_handle_event(event_name, user_payload): + background_seen.append((event_name, user_payload)) + + body = json.dumps( + {"name": "user.expired", "payload": {"telegramId": 7}} + ).encode() + + with ( + patch.object(pws, "enqueue_webhook_event", fake_enqueue), + patch.object(service, "handle_event", fake_handle_event), + ): + response = await service.handle_webhook(body, _sign(body)) + # Yield to the event loop so the scheduled background task runs. + await asyncio.sleep(0) + + self.assertEqual(response.status, 200) + self.assertEqual(background_seen, [("user.expired", {"telegramId": 7})]) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_startup_banner.py b/tests/test_startup_banner.py index e37ba68..fd1d980 100644 --- a/tests/test_startup_banner.py +++ b/tests/test_startup_banner.py @@ -4,33 +4,97 @@ import unittest from contextlib import redirect_stdout from unittest.mock import patch -from startup_banner import print_startup_banner +from startup_banner import _redis_target, print_startup_banner + + +def _render(service: str, env: dict) -> str: + buffer = io.StringIO() + with patch.dict(os.environ, env, clear=False), redirect_stdout(buffer): + print_startup_banner(service) + return buffer.getvalue() class StartupBannerTests(unittest.TestCase): def test_startup_banner_marks_services_without_mojibake(self): for service in ("frontend", "backend", "worker", "migrate"): with self.subTest(service=service): - buffer = io.StringIO() - - with ( - patch.dict( - os.environ, - { - "IMAGE_TAG": "test-tag", - "POSTGRES_HOST": "postgres", - "POSTGRES_DB": "postgres", - "REDIS_URL": "redis://redis:6379/0", - }, - clear=False, - ), - redirect_stdout(buffer), - ): - print_startup_banner(service) - - output = buffer.getvalue() + output = _render( + service, + { + "IMAGE_TAG": "test-tag", + "POSTGRES_HOST": "postgres", + "POSTGRES_DB": "postgres", + "REDIS_URL": "redis://redis:6379/0", + }, + ) self.assertIn(f"container :: {service.upper()}", output) self.assertIn("image tag :: test-tag", output) self.assertIn("remnawave-minishop", output) self.assertIn("███", output) self.assertNotIn("в", output) + + +class StartupBannerServiceDetailsTests(unittest.TestCase): + def test_backend_lists_ports_postgres_and_redis(self): + output = _render( + "backend", + { + "IMAGE_TAG": "v1", + "WEB_SERVER_PORT": "8090", + "WEBAPP_SERVER_PORT": "8091", + "WEBAPP_ENABLED": "True", + "POSTGRES_HOST": "pg", + "POSTGRES_PORT": "5440", + "POSTGRES_DB": "shop", + "REDIS_URL": "redis://r:6380/2", + }, + ) + self.assertIn("webhooks :: :8090", output) + self.assertIn("webapp api :: on / :8091", output) + self.assertIn("postgres :: pg:5440/shop", output) + self.assertIn("redis :: r:6380/2", output) + + def test_worker_lists_queue_and_panel_sync_interval(self): + output = _render( + "worker", + { + "IMAGE_TAG": "v1", + "WEBHOOK_QUEUE_CONCURRENCY": "8", + "WORKER_PANEL_SYNC_INTERVAL_SECONDS": "600", + "TARIFFS_CONFIG_PATH": "data/tariffs.json", + "POSTGRES_HOST": "pg", + "POSTGRES_DB": "shop", + "REDIS_URL": "redis://r:6379/0", + }, + ) + self.assertIn("queue concurrency :: 8", output) + self.assertIn("panel sync interval :: 600s", output) + self.assertIn("tariffs config :: data/tariffs.json", output) + + def test_migrate_shows_one_shot_mode_and_data_dir(self): + output = _render( + "migrate", + { + "IMAGE_TAG": "v1", + "POSTGRES_HOST": "pg", + "POSTGRES_DB": "shop", + }, + ) + self.assertIn("mode :: one-shot migrations", output) + self.assertIn("data dir :: /app/data", output) + + +class StartupBannerRedisTargetTests(unittest.TestCase): + def test_redis_target_renders_host_port_db(self): + with patch.dict(os.environ, {"REDIS_URL": "redis://r:6379/3"}, clear=False): + self.assertEqual(_redis_target(), "r:6379/3") + + def test_redis_target_defaults_db_to_zero(self): + with patch.dict(os.environ, {"REDIS_URL": "redis://r"}, clear=False): + self.assertEqual(_redis_target(), "r/0") + + def test_redis_target_when_url_missing(self): + env = dict(os.environ) + env.pop("REDIS_URL", None) + with patch.dict(os.environ, env, clear=True): + self.assertEqual(_redis_target(), "-") diff --git a/tests/test_webhook_queue.py b/tests/test_webhook_queue.py new file mode 100644 index 0000000..8f23c76 --- /dev/null +++ b/tests/test_webhook_queue.py @@ -0,0 +1,255 @@ +import asyncio +import json +import time +import unittest +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Tuple +from unittest.mock import patch + +from bot.infra import redis as redis_infra +from bot.infra import webhook_queue + + +class FakeRedis: + """Minimal async stand-in for redis.asyncio.Redis used by tests.""" + + def __init__(self) -> None: + self._kv: Dict[str, str] = {} + self._ttl: Dict[str, float] = {} + self._lists: Dict[str, List[str]] = {} + + async def set( + self, + key: str, + value: str, + *, + nx: bool = False, + ex: Optional[int] = None, + ) -> bool: + self._expire_if_due(key) + if nx and key in self._kv: + return False + self._kv[key] = value + if ex is not None: + self._ttl[key] = time.monotonic() + ex + else: + self._ttl.pop(key, None) + return True + + async def get(self, key: str) -> Optional[str]: + self._expire_if_due(key) + return self._kv.get(key) + + async def delete(self, *keys: str) -> int: + removed = 0 + for key in keys: + if key in self._kv: + self._kv.pop(key, None) + self._ttl.pop(key, None) + removed += 1 + if key in self._lists: + self._lists.pop(key, None) + removed += 1 + return removed + + async def lpush(self, key: str, *values: str) -> int: + bucket = self._lists.setdefault(key, []) + for value in values: + bucket.insert(0, value) + return len(bucket) + + async def brpop( + self, key: str, timeout: int = 0 + ) -> Optional[Tuple[str, str]]: + bucket = self._lists.get(key) + if bucket: + return key, bucket.pop() + # In real Redis, brpop blocks. Tests never use the timeout path. + return None + + async def llen(self, key: str) -> int: + return len(self._lists.get(key, [])) + + async def eval(self, script: str, numkeys: int, *args: Any) -> int: + # The only Lua used by the code base releases redis_lock atomically. + keys = args[:numkeys] + argv = args[numkeys:] + if keys and argv and self._kv.get(keys[0]) == argv[0]: + self._kv.pop(keys[0], None) + self._ttl.pop(keys[0], None) + return 1 + return 0 + + async def aclose(self) -> None: # pragma: no cover - close path + self._kv.clear() + self._ttl.clear() + self._lists.clear() + + def _expire_if_due(self, key: str) -> None: + expires_at = self._ttl.get(key) + if expires_at is not None and time.monotonic() >= expires_at: + self._kv.pop(key, None) + self._ttl.pop(key, None) + + +def _make_settings(**overrides: Any) -> SimpleNamespace: + base: Dict[str, Any] = { + "REDIS_URL": "redis://redis:6379/0", + "REDIS_KEY_PREFIX": "remnawave-tg-shop", + "WEBHOOK_QUEUE_NAME": "webhook-events", + } + base.update(overrides) + return SimpleNamespace(**base) + + +class RedisKeyTests(unittest.TestCase): + def test_redis_key_joins_prefix_and_parts(self): + settings = _make_settings(REDIS_KEY_PREFIX="shop") + self.assertEqual(redis_infra.redis_key(settings, "queue", "events"), "shop:queue:events") + + def test_redis_key_filters_empty_or_colon_only_parts(self): + settings = _make_settings(REDIS_KEY_PREFIX="shop") + # Empty / colon-only parts are dropped; numeric and string parts coexist. + self.assertEqual( + redis_infra.redis_key(settings, "lock", "", ":", "panel-sync", 42), + "shop:lock:panel-sync:42", + ) + + def test_redis_key_strips_leading_trailing_colons(self): + settings = _make_settings(REDIS_KEY_PREFIX=":shop:") + self.assertEqual( + redis_infra.redis_key(settings, ":webhook:", "seen"), + "shop:webhook:seen", + ) + + +class WebhookQueueTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.fake = FakeRedis() + + async def fake_get_redis(_settings): + return self.fake + + self._patcher = patch.object(webhook_queue, "get_redis", fake_get_redis) + self._patcher.start() + self.addCleanup(self._patcher.stop) + + async def test_enqueue_returns_false_when_redis_unavailable(self): + async def no_redis(_settings): + return None + + with patch.object(webhook_queue, "get_redis", no_redis): + ok = await webhook_queue.enqueue_webhook_event( + _make_settings(), "yookassa", {"id": "p_1"} + ) + self.assertFalse(ok) + + async def test_enqueue_writes_payload_and_increments_depth(self): + settings = _make_settings() + ok = await webhook_queue.enqueue_webhook_event( + settings, + "yookassa", + {"id": "p_42", "amount": "100"}, + event_id="payment.succeeded:p_42", + ) + self.assertTrue(ok) + + depth = await webhook_queue.webhook_queue_depth(settings) + self.assertEqual(depth, 1) + + popped = await webhook_queue.pop_webhook_event(settings) + assert popped is not None + self.assertEqual(popped["provider"], "yookassa") + self.assertEqual(popped["event_id"], "payment.succeeded:p_42") + self.assertEqual(popped["payload"], {"id": "p_42", "amount": "100"}) + self.assertIsInstance(popped["enqueued_at"], (int, float)) + + async def test_enqueue_dedupes_repeated_event_ids(self): + settings = _make_settings() + first = await webhook_queue.enqueue_webhook_event( + settings, + "panel", + {"event": "user.expired", "user": {"telegramId": 1}}, + event_id="user.expired:1", + ) + second = await webhook_queue.enqueue_webhook_event( + settings, + "panel", + {"event": "user.expired", "user": {"telegramId": 1}}, + event_id="user.expired:1", + ) + self.assertTrue(first) + # Dedupe path is treated as a success — the event was already accepted. + self.assertTrue(second) + self.assertEqual(await webhook_queue.webhook_queue_depth(settings), 1) + + async def test_pop_returns_none_when_queue_empty(self): + settings = _make_settings() + self.assertIsNone(await webhook_queue.pop_webhook_event(settings)) + + async def test_pop_skips_invalid_payload(self): + settings = _make_settings() + await self.fake.lpush(webhook_queue.webhook_queue_key(settings), "not-json") + result = await webhook_queue.pop_webhook_event(settings) + self.assertIsNone(result) + + async def test_queue_key_uses_prefix_and_queue_name(self): + settings = _make_settings(REDIS_KEY_PREFIX="shop", WEBHOOK_QUEUE_NAME="custom-q") + self.assertEqual(webhook_queue.webhook_queue_key(settings), "shop:queue:custom-q") + + async def test_enqueue_serializes_unicode_without_escaping(self): + settings = _make_settings() + await webhook_queue.enqueue_webhook_event( + settings, "panel", {"name": "Юзер", "id": 7}, event_id="u:7" + ) + # Raw payload in Redis preserves Cyrillic without \uXXXX escapes. + raw = self.fake._lists[webhook_queue.webhook_queue_key(settings)][0] + self.assertIn("Юзер", raw) + parsed = json.loads(raw) + self.assertEqual(parsed["payload"]["name"], "Юзер") + + +class RedisLockTests(unittest.IsolatedAsyncioTestCase): + async def test_lock_is_noop_when_redis_unavailable(self): + async def no_redis(_settings): + return None + + with patch.object(redis_infra, "get_redis", no_redis): + async with redis_infra.redis_lock( + _make_settings(), "panel-sync", ttl_seconds=30 + ) as acquired: + self.assertTrue(acquired) + + async def test_lock_excludes_concurrent_holders_and_releases_on_exit(self): + fake = FakeRedis() + + async def fake_get_redis(_settings): + return fake + + with patch.object(redis_infra, "get_redis", fake_get_redis): + settings = _make_settings() + async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as first: + self.assertTrue(first) + async with redis_infra.redis_lock( + settings, "panel-sync", ttl_seconds=30 + ) as second: + self.assertFalse(second) + # After exit, the lock is released and can be re-acquired. + async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as again: + self.assertTrue(again) + + +class SleepOrStopTests(unittest.IsolatedAsyncioTestCase): + async def test_returns_promptly_when_event_set(self): + event = asyncio.Event() + event.set() + await redis_infra.sleep_or_stop(event, seconds=5) # would hang on a bug + + async def test_times_out_when_event_not_set(self): + event = asyncio.Event() + # Tiny timeout keeps the test fast; the function should not raise on timeout. + await redis_infra.sleep_or_stop(event, seconds=0.01) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 071b9f2b25b1f271114cb6d9f8f05876b4f56a46 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 09:46:48 +0300 Subject: [PATCH 23/45] fix: add missing _PROVIDER_LABELS to PaymentContextMixin --- .../services/subscription_service_impl/payments.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index fc817c2..d9f2df6 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -3,6 +3,19 @@ from ._runtime import * # noqa: F403,F405 class PaymentContextMixin: + # Human-readable provider names rendered in payment-success emails. + # Keys are the lowercased value persisted in ``subscriptions.provider`` + # (see the call sites in lifecycle.py / traffic.py); missing keys produce + # no row in the email rather than raising. + _PROVIDER_LABELS = { + "yookassa": "YooKassa", + "freekassa": "FreeKassa", + "platega": "Platega", + "severpay": "SeverPay", + "cryptopay": "Crypto Pay", + "telegram_stars": "Telegram Stars", + } + async def _record_payment_context( self, session: AsyncSession, From 8382fa92327797fc76d598f04dabea64ea3fc7dd Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 09:46:52 +0300 Subject: [PATCH 24/45] chore: cover provider label regression in payment success email path --- tests/test_provider_labels.py | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_provider_labels.py diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py new file mode 100644 index 0000000..ffdd5b8 --- /dev/null +++ b/tests/test_provider_labels.py @@ -0,0 +1,194 @@ +"""Regression test for the YooKassa webhook 500 caused by a missing class attr. + +A real production crash looked like:: + + YooKassa Webhook Parsed: Event='payment.succeeded' + AttributeError: 'SubscriptionService' object has no attribute '_PROVIDER_LABELS' + +The fix wires ``_PROVIDER_LABELS`` onto ``PaymentContextMixin`` so the +payment-success email lookup can never blow up the webhook handler again. +These tests pin the contract end-to-end. +""" + +import unittest +from types import SimpleNamespace +from typing import List +from unittest.mock import patch + +from bot.services.subscription_service_impl import payments as payments_module +from bot.services.subscription_service_impl.payments import PaymentContextMixin + +# Provider strings that flow into ``Subscription.provider`` — every value +# present at a real call site must be representable here. +_PROVIDERS_FROM_CALLERS = { + "yookassa", + "freekassa", + "platega", + "severpay", + "cryptopay", + "telegram_stars", +} + + +class ProviderLabelsTests(unittest.TestCase): + def test_attribute_is_defined_on_mixin(self): + # The crash was specifically ``AttributeError`` — guard against that. + self.assertTrue(hasattr(PaymentContextMixin, "_PROVIDER_LABELS")) + self.assertIsInstance(PaymentContextMixin._PROVIDER_LABELS, dict) + + def test_every_runtime_provider_has_a_label(self): + labels = PaymentContextMixin._PROVIDER_LABELS + for provider in _PROVIDERS_FROM_CALLERS: + with self.subTest(provider=provider): + self.assertIn(provider, labels) + self.assertTrue(labels[provider]) # non-empty display text + + def test_labels_are_keyed_by_lowercase_string(self): + # Lookup site does ``self._PROVIDER_LABELS.get((provider or '').lower())`` + # so keys must already be lowercase to ever resolve. + for key in PaymentContextMixin._PROVIDER_LABELS: + self.assertEqual(key, key.lower()) + + +class _FakeUser(SimpleNamespace): + pass + + +class _FakeEmailService: + instances: List["_FakeEmailService"] = [] + + def __init__(self, settings): + self.settings = settings + self.sent: List[dict] = [] + _FakeEmailService.instances.append(self) + + async def send_rendered_email(self, *, email, content): + self.sent.append({"email": email, "content": content}) + + +class SendPaymentSuccessEmailTests(unittest.IsolatedAsyncioTestCase): + """Exercise the exact code path that hit the AttributeError in prod.""" + + def setUp(self) -> None: + _FakeEmailService.instances = [] + + async def _invoke(self, provider: str) -> dict: + captured: dict = {} + + def fake_render(settings, **kwargs): + captured.update(kwargs) + return SimpleNamespace(subject="s", text="t", html="

h

") + + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/", + ) + user = _FakeUser( + user_id=42, email="buyer@example.com", language_code="en" + ) + + with ( + patch.object(payments_module, "render_payment_success", fake_render), + patch.object(payments_module, "EmailAuthService", _FakeEmailService), + ): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=3, + traffic_gb=None, + payment_amount=450.0, + end_date=None, + provider=provider, + ) + return captured + + async def test_yookassa_resolves_to_human_label(self): + kwargs = await self._invoke("yookassa") + self.assertEqual(kwargs.get("provider_label"), "YooKassa") + self.assertEqual(len(_FakeEmailService.instances), 1) + self.assertEqual(len(_FakeEmailService.instances[0].sent), 1) + + async def test_unknown_provider_renders_as_none_without_raising(self): + # The email template treats ``None`` as "skip the provider row" — we + # need that fallback because new providers may ship before labels do. + kwargs = await self._invoke("paypal_someday") + self.assertIsNone(kwargs.get("provider_label")) + + async def test_provider_lookup_is_case_insensitive(self): + kwargs = await self._invoke("YooKassa") + self.assertEqual(kwargs.get("provider_label"), "YooKassa") + + async def test_skips_email_when_smtp_unconfigured(self): + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace(email_auth_configured=False) + user = _FakeUser(user_id=1, email="x@y.z", language_code="en") + + with patch.object(payments_module, "EmailAuthService", _FakeEmailService): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + self.assertEqual(_FakeEmailService.instances, []) + + async def test_skips_email_when_user_has_no_email(self): + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="", + ) + user = _FakeUser(user_id=1, email=" ", language_code="en") + + with patch.object(payments_module, "EmailAuthService", _FakeEmailService): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + self.assertEqual(_FakeEmailService.instances, []) + + async def test_render_exceptions_are_swallowed(self): + # The mail path must never block payment processing — this is exactly + # the contract that the original crash violated for a different reason. + def boom(*args, **kwargs): + raise RuntimeError("smtp on fire") + + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="", + ) + user = _FakeUser(user_id=1, email="x@y.z", language_code="en") + + with patch.object(payments_module, "render_payment_success", boom): + # No assertion needed — completion without raising IS the assertion. + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From d4b7da3a545b659c04d1734173bbfab934a04abf Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 09:58:39 +0300 Subject: [PATCH 25/45] fix: read yookassa_service directly in auto-renew instead of broken local import --- .../services/subscription_service_impl/renewal.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/bot/services/subscription_service_impl/renewal.py b/backend/bot/services/subscription_service_impl/renewal.py index d8aed1e..8d9cc5b 100644 --- a/backend/bot/services/subscription_service_impl/renewal.py +++ b/backend/bot/services/subscription_service_impl/renewal.py @@ -30,12 +30,12 @@ class RenewalMixin: logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") return False - try: - from .yookassa_service import YooKassaService # local import to avoid cycles - - yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] - except Exception: - yk = None # type: ignore + # ``yookassa_service`` is wired onto the subscription service via + # ``build_core_services`` (setattr). Read it directly — the previous + # ``from .yookassa_service import …`` pointed at a non-existent module + # inside this package, the ImportError got swallowed, and every + # auto-renew silently returned False. + yk = getattr(self, "yookassa_service", None) if not yk or not getattr(yk, "configured", False): logging.warning("YooKassa unavailable for auto-renew") return False From 6800734136089e9c0b0b706dbc34b8a0fe1348c6 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 09:58:45 +0300 Subject: [PATCH 26/45] chore: cover auto-renew wiring contract end-to-end --- tests/test_auto_renew_wiring.py | 283 ++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 tests/test_auto_renew_wiring.py diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py new file mode 100644 index 0000000..a875e1a --- /dev/null +++ b/tests/test_auto_renew_wiring.py @@ -0,0 +1,283 @@ +"""Regression tests for the auto-renew code path in ``RenewalMixin``. + +Prior to the fix, ``charge_subscription_renewal`` did:: + + try: + from .yookassa_service import YooKassaService # local import to avoid cycles + yk: YooKassaService = self.yookassa_service + except Exception: + yk = None + +There is no ``yookassa_service`` module inside ``subscription_service_impl`` +(the real one lives in ``bot.services.yookassa_service``), so the import +always raised ``ModuleNotFoundError``, ``yk`` became ``None``, and every +auto-renew silently logged ``YooKassa unavailable for auto-renew`` and +returned False — even though ``build_core_services`` had wired a real +``yookassa_service`` onto the subscription service via ``setattr``. + +These tests pin the working contract end-to-end so the regression cannot +return. +""" + +import unittest +from types import SimpleNamespace +from typing import Any, Dict, List, Optional +from unittest.mock import patch + +from bot.services.subscription_service_impl.renewal import RenewalMixin + + +class _FakeYooKassaService: + """Stand-in for the real ``YooKassaService``.""" + + def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None: + self.configured = configured + self.calls: List[Dict[str, Any]] = [] + self._response = response if response is not None else { + "id": "pay-1", "status": "pending" + } + + async def create_payment(self, **kwargs): + self.calls.append(kwargs) + return self._response + + +class _FakePaymentMethod: + def __init__(self, pm_id: str = "pm-42") -> None: + self.provider_payment_method_id = pm_id + + +class _FakeSub(SimpleNamespace): + pass + + +def _make_mixin(*, yk: Optional[_FakeYooKassaService], price_for_months: Optional[float] = 100.0): + mixin = RenewalMixin() + mixin.settings = SimpleNamespace( + traffic_sale_mode=False, + yookassa_autopayments_active=True, + subscription_options={1: price_for_months} if price_for_months else {}, + ) + if yk is not None: + mixin.yookassa_service = yk # type: ignore[attr-defined] + return mixin + + +async def _stub_default_pm(session, user_id): + return _FakePaymentMethod() + + +async def _no_default_pm(session, user_id): + return None + + +class ChargeRenewalShortCircuitTests(unittest.IsolatedAsyncioTestCase): + """Negative paths that should return *without* hitting YooKassa.""" + + async def test_skips_when_traffic_sale_mode_enabled(self): + mixin = _make_mixin(yk=None) + mixin.settings.traffic_sale_mode = True + ok = await mixin.charge_subscription_renewal(session=None, sub=_FakeSub()) + self.assertTrue(ok) + + async def test_skips_when_auto_renew_disabled(self): + mixin = _make_mixin(yk=None) + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=False), + ) + self.assertTrue(ok) + + async def test_skips_when_autopayments_globally_disabled(self): + mixin = _make_mixin(yk=None) + mixin.settings.yookassa_autopayments_active = False + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=True, provider="yookassa"), + ) + self.assertTrue(ok) + + async def test_skips_for_non_yookassa_provider(self): + mixin = _make_mixin(yk=None) + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=True, provider="freekassa"), + ) + self.assertTrue(ok) + + +class ChargeRenewalFailureTests(unittest.IsolatedAsyncioTestCase): + async def test_returns_false_when_no_saved_payment_method(self): + mixin = _make_mixin(yk=_FakeYooKassaService()) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _no_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_service_missing(self): + # Reproduces the historic bug: build_core_services did not attach + # ``yookassa_service`` for some reason → auto-renew must report False. + mixin = _make_mixin(yk=None) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_not_configured(self): + mixin = _make_mixin(yk=_FakeYooKassaService(configured=False)) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_legacy_price_for_months_missing(self): + mixin = _make_mixin(yk=_FakeYooKassaService(), price_for_months=None) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_response_unrecognized_status(self): + yk = _FakeYooKassaService(response={"id": "p", "status": "failed"}) + mixin = _make_mixin(yk=yk) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + +class ChargeRenewalHappyPathTests(unittest.IsolatedAsyncioTestCase): + async def test_initiates_payment_with_saved_method(self): + yk = _FakeYooKassaService(response={"id": "auto-pay-7", "status": "pending"}) + mixin = _make_mixin(yk=yk, price_for_months=399.0) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=77, + subscription_id=555, + duration_months=1, + ), + ) + + self.assertTrue(ok) + self.assertEqual(len(yk.calls), 1) + call = yk.calls[0] + self.assertEqual(call["amount"], 399.0) + self.assertEqual(call["currency"], "RUB") + self.assertEqual(call["payment_method_id"], "pm-42") + self.assertEqual(call["save_payment_method"], False) + self.assertEqual(call["capture"], True) + meta = call["metadata"] + self.assertEqual(meta["user_id"], "77") + self.assertEqual(meta["auto_renew_for_subscription_id"], "555") + self.assertEqual(meta["subscription_months"], "1") + + async def test_accepts_waiting_for_capture_status(self): + yk = _FakeYooKassaService(response={"id": "p", "status": "waiting_for_capture"}) + mixin = _make_mixin(yk=yk) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=2, + duration_months=1, + ), + ) + + self.assertTrue(ok) + + async def test_defaults_to_one_month_when_duration_missing(self): + yk = _FakeYooKassaService() + mixin = _make_mixin(yk=yk, price_for_months=99.0) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=2, + duration_months=None, + ), + ) + + self.assertTrue(ok) + self.assertEqual(yk.calls[0]["metadata"]["subscription_months"], "1") + self.assertEqual(yk.calls[0]["amount"], 99.0) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From eb37ed4c74ac2d41ae9fe3a16f328edd35408a65 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:03:05 +0300 Subject: [PATCH 27/45] refactor: stop swallowing critical service wiring failures --- backend/bot/app/factories/build_services.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/bot/app/factories/build_services.py b/backend/bot/app/factories/build_services.py index ed9363e..6d7f4a3 100644 --- a/backend/bot/app/factories/build_services.py +++ b/backend/bot/app/factories/build_services.py @@ -81,14 +81,12 @@ def build_core_services( api_url=settings.LKNPD_API_URL, ) - # Wire services that depend on each other - try: - # Attach YooKassa to subscription service for auto-renew charges - setattr(subscription_service, "yookassa_service", yookassa_service) - # Allow panel webhook to trigger renewals through subscription service - setattr(panel_webhook_service, "subscription_service", subscription_service) - except Exception: - pass + # Wire services that depend on each other. These attachments are critical + # for auto-renew (subscription_service.yookassa_service) and for the panel + # webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors — + # silent wiring failures previously caused auto-renew to disappear. + subscription_service.yookassa_service = yookassa_service + panel_webhook_service.subscription_service = subscription_service return { "panel_service": panel_service, From d2581b1c52d955542ce123fefc9736c4b4697ba2 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:03:09 +0300 Subject: [PATCH 28/45] chore: pin yookassa and subscription wiring in build_core_services --- tests/test_build_services_wiring.py | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/test_build_services_wiring.py diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py new file mode 100644 index 0000000..3e194c6 --- /dev/null +++ b/tests/test_build_services_wiring.py @@ -0,0 +1,139 @@ +"""Pins the cross-service wiring that ``build_core_services`` performs. + +Two non-obvious attachments are critical and have already been the cause of +silent regressions: + +* ``subscription_service.yookassa_service`` powers auto-renew. Without it the + charge step logs ``YooKassa unavailable for auto-renew`` and gives up. +* ``panel_webhook_service.subscription_service`` lets the 24h-before-expiry + panel webhook trigger an auto-renew through the subscription service. + +These tests catch any future regression that drops either attachment, or +silently swallows the wiring step. +""" + +import json +import tempfile +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from bot.app.factories.build_services import build_core_services +from bot.services.panel_webhook_service import PanelWebhookService +from bot.services.subscription_service import SubscriptionService +from bot.services.yookassa_service import YooKassaService +from config.settings import Settings + + +def _make_settings(tmpdir: str, **overrides: Any) -> Settings: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Base"}, + "squad_uuids": ["main"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 100}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "enabled": True, + } + ], + } + ), + encoding="utf-8", + ) + values: dict[str, Any] = { + "_env_file": None, + "BOT_TOKEN": "token", + "POSTGRES_USER": "u", + "POSTGRES_PASSWORD": "p", + "TARIFFS_CONFIG_PATH": str(config_path), + } + values.update(overrides) + return Settings(**values) + + +class BuildServicesWiringTests(unittest.TestCase): + def test_subscription_service_receives_yookassa_handle(self): + """Auto-renew reads ``self.yookassa_service`` directly. If this + attachment ever disappears, every YooKassa auto-renew will silently + return False without anyone noticing until a charge is missed.""" + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + services = build_core_services( + settings=settings, + bot=MagicMock(), + async_session_factory=MagicMock(), + i18n=MagicMock(), + bot_username_for_default_return="testbot", + ) + + subscription = services["subscription_service"] + yookassa = services["yookassa_service"] + self.assertIsInstance(subscription, SubscriptionService) + self.assertIsInstance(yookassa, YooKassaService) + # Identity check: the wired attribute must be the *same* instance. + self.assertIs(getattr(subscription, "yookassa_service", None), yookassa) + + def test_panel_webhook_service_can_reach_subscription_service(self): + """The 24h pre-expiry handler in panel_webhook_service.handle_event + calls ``getattr(self, 'subscription_service', None)`` and skips the + auto-renew nudge if it returns None. Pin the back-reference.""" + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + services = build_core_services( + settings=settings, + bot=MagicMock(), + async_session_factory=MagicMock(), + i18n=MagicMock(), + bot_username_for_default_return="testbot", + ) + + panel_webhook = services["panel_webhook_service"] + subscription = services["subscription_service"] + self.assertIsInstance(panel_webhook, PanelWebhookService) + self.assertIs( + getattr(panel_webhook, "subscription_service", None), subscription + ) + + def test_factory_returns_every_documented_service(self): + """Guards against silently dropping a service from the bundle. The + web layer reads these keys off ``request.app`` directly — a missing + key turns into a runtime KeyError at request time.""" + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + services = build_core_services( + settings=settings, + bot=MagicMock(), + async_session_factory=MagicMock(), + i18n=MagicMock(), + bot_username_for_default_return="testbot", + ) + + expected_keys = { + "panel_service", + "subscription_service", + "referral_service", + "promo_code_service", + "stars_service", + "cryptopay_service", + "freekassa_service", + "panel_webhook_service", + "yookassa_service", + "lknpd_service", + "platega_service", + "severpay_service", + } + self.assertEqual(set(services), expected_keys) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From dc05595b9b8c109f71e0ff725e2e311c4748cdc9 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:09:41 +0300 Subject: [PATCH 29/45] chore: attach orphaned type:ignore comment to its statement --- backend/bot/app/web/webapp/application.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/bot/app/web/webapp/application.py b/backend/bot/app/web/webapp/application.py index 150f865..df988af 100644 --- a/backend/bot/app/web/webapp/application.py +++ b/backend/bot/app/web/webapp/application.py @@ -52,8 +52,7 @@ def create_subscription_webapp_application( if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] app[key] = dp.workflow_data[key] # type: ignore[index] - # type: ignore[attr-defined] - if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: + if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined] app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index] setup_subscription_webapp_routes(app) From 7d1c9ee3731461af10e004a2def688f9dd37714a Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:22:48 +0300 Subject: [PATCH 30/45] fix: surface panel update failures in topup, premium-topup and tariff switch --- .../subscription_service_impl/lifecycle.py | 14 ++++++++++- .../subscription_service_impl/traffic.py | 23 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 3f8102e..e4154ad 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -118,9 +118,21 @@ class SubscriptionLifecycleMixin: include_premium=not bool(updated.premium_is_limited), ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # The tariff row is already swapped locally; if the panel rejects + # the squad/limit update the user sees the new tariff in the app + # but stays on the old squads on Remnawave. Surface the failure + # so the caller can roll back. + logging.warning( + "Panel user details update FAILED for tariff switch user %s -> %s. Response: %s", + user_id, + target.key, + updated_panel, + ) + return None if converted_bytes: await tariff_dal.create_traffic_topup( session, diff --git a/backend/bot/services/subscription_service_impl/traffic.py b/backend/bot/services/subscription_service_impl/traffic.py index 13cd3d1..ed5d88e 100644 --- a/backend/bot/services/subscription_service_impl/traffic.py +++ b/backend/bot/services/subscription_service_impl/traffic.py @@ -249,9 +249,18 @@ class TrafficMixin: include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), ) panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # Otherwise the user pays for top-up bytes that are recorded locally + # but never reach Remnawave — they cannot actually use the traffic. + logging.warning( + "Panel user details update FAILED for traffic top-up user %s. Response: %s", + user_id, + updated_panel, + ) + return None await tariff_dal.create_traffic_topup( session, subscription_id=sub.subscription_id, @@ -347,9 +356,19 @@ class TrafficMixin: include_premium=not premium_is_limited, ), } - await self.panel_service.update_user_details_on_panel( + updated_panel = await self.panel_service.update_user_details_on_panel( db_user.panel_user_uuid, panel_payload ) + if not updated_panel or updated_panel.get("error"): + # Otherwise the user pays for premium top-up but the panel never + # re-grants premium squad access (the most common case here is + # transitioning from premium_is_limited=True back to False). + logging.warning( + "Panel user details update FAILED for premium top-up user %s. Response: %s", + user_id, + updated_panel, + ) + return None await tariff_dal.create_traffic_topup( session, subscription_id=sub.subscription_id, From 6886a90ee423179a28fbf0a636a869e9081d8250 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:22:53 +0300 Subject: [PATCH 31/45] chore: cover topup, premium-topup and tariff-switch panel failure paths --- tests/test_topup_panel_failures.py | 347 +++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 tests/test_topup_panel_failures.py diff --git a/tests/test_topup_panel_failures.py b/tests/test_topup_panel_failures.py new file mode 100644 index 0000000..069dd8c --- /dev/null +++ b/tests/test_topup_panel_failures.py @@ -0,0 +1,347 @@ +"""Regression tests for silent panel-update failures in top-up / tariff-switch flows. + +Three sibling methods — ``activate_topup``, ``activate_premium_topup``, and +``switch_tariff_without_payment`` — used to ignore the result of +``panel_service.update_user_details_on_panel``. When the panel update failed, +the local DB still recorded the top-up / tariff change and the function +reported success. The user paid, saw the new traffic / tariff in the app, +but Remnawave never received the change, so the new entitlement was unusable. + +Other activation paths (``activate_subscription``, ``_activate_traffic_package``, +``activate_hwid_device_topup``) all check the panel response. These tests pin +the same contract on the three previously-broken paths. +""" + +import json +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings + +GIB = 1024**3 + + +def _tariffs_config_payload() -> dict: + return { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Base"}, + "squad_uuids": ["main-squad"], + "premium_squad_uuids": ["premium-squad"], + "premium_monthly_gb": 25, + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150, "3": 400}, + "prices_stars": {"1": 0}, + "enabled_periods": [1, 3], + "hwid_device_limit": 3, + "topup_packages": { + "rub": [{"gb": 10, "price": 50}], + "stars": [], + }, + "premium_topup_packages": { + "rub": [{"gb": 10, "price": 100}], + "stars": [], + }, + "enabled": True, + }, + { + "key": "premium", + "names": {"en": "Premium"}, + "descriptions": {"en": "Premium plan"}, + "squad_uuids": ["main-squad", "premium-extra"], + "premium_squad_uuids": ["premium-squad"], + "premium_monthly_gb": 50, + "billing_model": "period", + "monthly_gb": 200, + "prices_rub": {"1": 300, "3": 800}, + "prices_stars": {"1": 0}, + "enabled_periods": [1, 3], + "enabled": True, + }, + ], + } + + +def _make_settings(tmpdir: str, **overrides: Any) -> Settings: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(_tariffs_config_payload()), encoding="utf-8") + values: dict[str, Any] = { + "_env_file": None, + "BOT_TOKEN": "token", + "POSTGRES_USER": "u", + "POSTGRES_PASSWORD": "p", + "TARIFFS_CONFIG_PATH": str(config_path), + } + values.update(overrides) + return Settings(**values) + + +def _make_service(settings: Settings) -> SubscriptionService: + panel_service = AsyncMock(spec=PanelApiService) + return SubscriptionService(settings, panel_service) + + +def _make_sub(**overrides): + base = dict( + subscription_id=11, + user_id=42, + panel_user_uuid="panel-uuid", + panel_subscription_uuid="panel-sub", + tariff_key="standard", + traffic_limit_bytes=100 * GIB, + traffic_used_bytes=10 * GIB, + topup_balance_bytes=0, + tier_baseline_bytes=100 * GIB, + regular_bonus_bytes=0, + regular_unlimited_override=False, + extra_hwid_devices=0, + hwid_device_limit=3, + premium_baseline_bytes=25 * GIB, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_used_bytes=0, + premium_bonus_bytes=0, + premium_unlimited_override=False, + premium_is_limited=False, + premium_period_start_at=None, + effective_monthly_price_rub=150, + end_date=datetime(2099, 1, 1, tzinfo=timezone.utc), + is_active=True, + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def _make_user(): + return SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="u", + first_name="U", + last_name="L", + language_code="en", + panel_user_uuid="panel-uuid", + ) + + +class ActivateTopupPanelFailureTests(unittest.IsolatedAsyncioTestCase): + """Regular (non-premium) period top-up. The bug was traffic.py:252 ignoring panel result.""" + + async def _run(self, *, panel_response): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + service = _make_service(settings) + sub = _make_sub() + user = _make_user() + + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value=panel_response + ) + updated_sub = SimpleNamespace(end_date=sub.end_date, subscription_id=11) + + with ( + patch( + "bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id", + AsyncMock(return_value=SimpleNamespace()), + ), + patch( + "bot.services.subscription_service_impl.traffic.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.traffic.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.traffic.subscription_dal.update_subscription", + AsyncMock(return_value=updated_sub), + ), + patch( + "bot.services.subscription_service_impl.traffic.tariff_dal.create_traffic_topup", + AsyncMock(), + ) as create_topup, + ): + result = await service.activate_topup( + session=AsyncMock(), + user_id=42, + tariff_key="standard", + traffic_gb=10, + payment_amount=50, + payment_db_id=1, + ) + return result, create_topup + + async def test_returns_none_when_panel_returns_none(self): + result, create_topup = await self._run(panel_response=None) + self.assertIsNone(result) + # The local audit row must NOT be written when the panel rejected the change. + create_topup.assert_not_awaited() + + async def test_returns_none_when_panel_returns_error_dict(self): + result, create_topup = await self._run(panel_response={"error": True}) + self.assertIsNone(result) + create_topup.assert_not_awaited() + + async def test_returns_payload_when_panel_succeeds(self): + result, create_topup = await self._run(panel_response={"ok": True, "uuid": "panel-uuid"}) + self.assertIsNotNone(result) + self.assertEqual(result["tariff_key"], "standard") + self.assertEqual(result["topup_balance_bytes"], 10 * GIB) + create_topup.assert_awaited_once() + + +class ActivatePremiumTopupPanelFailureTests(unittest.IsolatedAsyncioTestCase): + """Premium top-up. The bug was traffic.py:350 ignoring panel result — meant a user could + pay to lift the premium quota but the panel never re-granted premium squad access.""" + + async def _run(self, *, panel_response): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + service = _make_service(settings) + # Start with premium_is_limited=True so the recompute path is exercised. + sub = _make_sub( + premium_topup_balance_bytes=0, + premium_used_bytes=30 * GIB, # already over the 25 GB baseline + premium_is_limited=True, + ) + user = _make_user() + + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value=panel_response + ) + + with ( + patch( + "bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id", + AsyncMock(return_value=SimpleNamespace()), + ), + patch( + "bot.services.subscription_service_impl.traffic.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.traffic.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.traffic.subscription_dal.update_subscription", + AsyncMock(), + ), + patch( + "bot.services.subscription_service_impl.traffic.tariff_dal.create_traffic_topup", + AsyncMock(), + ) as create_topup, + ): + result = await service.activate_premium_topup( + session=AsyncMock(), + user_id=42, + tariff_key="standard", + traffic_gb=10, + payment_amount=100, + payment_db_id=2, + ) + return result, create_topup + + async def test_returns_none_when_panel_returns_none(self): + result, create_topup = await self._run(panel_response=None) + self.assertIsNone(result) + create_topup.assert_not_awaited() + + async def test_returns_none_when_panel_returns_error_dict(self): + result, create_topup = await self._run(panel_response={"error": True}) + self.assertIsNone(result) + create_topup.assert_not_awaited() + + async def test_returns_payload_when_panel_succeeds(self): + result, create_topup = await self._run(panel_response={"ok": True, "uuid": "panel-uuid"}) + self.assertIsNotNone(result) + self.assertEqual(result["tariff_key"], "standard") + create_topup.assert_awaited_once() + + +class SwitchTariffPanelFailureTests(unittest.IsolatedAsyncioTestCase): + """Free tariff switch. The bug was lifecycle.py:121 ignoring panel result — the tariff_key + flipped in the local DB while the panel kept the user on the old squad set.""" + + async def _run(self, *, panel_response, mode="recalc_days"): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir) + service = _make_service(settings) + sub = _make_sub( + tariff_key="standard", + end_date=datetime.now(timezone.utc) + timedelta(days=20), + ) + user = _make_user() + updated = _make_sub( + tariff_key="premium", + end_date=datetime.now(timezone.utc) + timedelta(days=10), + premium_is_limited=False, + effective_monthly_price_rub=300, + ) + + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value=panel_response + ) + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription", + AsyncMock(return_value=updated), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_tariff_change", + AsyncMock(), + ) as create_change, + patch( + "bot.services.subscription_service_impl.lifecycle.tariff_dal.create_traffic_topup", + AsyncMock(), + ), + ): + result = await service.switch_tariff_without_payment( + session=AsyncMock(), + user_id=42, + target_tariff_key="premium", + mode=mode, + ) + return result, create_change + + async def test_returns_none_when_panel_returns_none(self): + result, create_change = await self._run(panel_response=None) + self.assertIsNone(result) + # tariff_changes audit row must NOT be inserted on panel failure. + create_change.assert_not_awaited() + + async def test_returns_none_when_panel_returns_error_dict(self): + result, create_change = await self._run(panel_response={"error": True}) + self.assertIsNone(result) + create_change.assert_not_awaited() + + async def test_returns_payload_when_panel_succeeds(self): + result, create_change = await self._run(panel_response={"ok": True}) + self.assertIsNotNone(result) + self.assertEqual(result["tariff_key"], "premium") + create_change.assert_awaited_once() + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 365c6c7858baaf5f0ba6491f1c18d092a4c537be Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:22:57 +0300 Subject: [PATCH 32/45] chore: cover HWID device topup gating, audit and panel failure paths --- tests/test_hwid_device_topup.py | 266 ++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 tests/test_hwid_device_topup.py diff --git a/tests/test_hwid_device_topup.py b/tests/test_hwid_device_topup.py new file mode 100644 index 0000000..74c0f91 --- /dev/null +++ b/tests/test_hwid_device_topup.py @@ -0,0 +1,266 @@ +"""Behavioural tests for ``HwidDeviceMixin.activate_hwid_device_topup``. + +The HWID device top-up flow already checks the panel update result correctly +(unlike the regular/premium traffic top-ups, which were the subject of a +recent fix). These tests pin that: + +* an unknown package count for the tariff is rejected (anti-tamper); +* unlimited subscribers (``hwid_device_limit == 0``) are a no-op that still + returns a meaningful payload; +* the panel call uses ``hwidDeviceLimit = base + extra``, not just ``base``; +* a panel failure returns ``None`` and DOES NOT write the audit row; +* the happy path persists ``extra_hwid_devices = old + purchased`` and + records the device purchase. +""" + +import json +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings + + +def _tariffs_config_payload(**overrides: Any) -> dict: + tariff = { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Base"}, + "squad_uuids": ["main-squad"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 100}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "hwid_device_limit": 3, + "hwid_device_packages": { + "rub": [{"count": 1, "price": 50}, {"count": 3, "price": 120}], + "stars": [], + }, + "enabled": True, + } + tariff.update(overrides) + return {"default_tariff": "standard", "tariffs": [tariff]} + + +def _make_settings(tmpdir: str, payload: dict, **overrides: Any) -> Settings: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + values: dict[str, Any] = { + "_env_file": None, + "BOT_TOKEN": "token", + "POSTGRES_USER": "u", + "POSTGRES_PASSWORD": "p", + "TARIFFS_CONFIG_PATH": str(config_path), + } + values.update(overrides) + return Settings(**values) + + +def _make_service(settings: Settings) -> SubscriptionService: + return SubscriptionService(settings, AsyncMock(spec=PanelApiService)) + + +def _make_sub(*, hwid_device_limit=3, extra_hwid_devices=0): + return SimpleNamespace( + subscription_id=11, + user_id=42, + panel_user_uuid="panel-uuid", + panel_subscription_uuid="panel-sub", + tariff_key="standard", + end_date=datetime(2099, 1, 1, tzinfo=timezone.utc), + hwid_device_limit=hwid_device_limit, + extra_hwid_devices=extra_hwid_devices, + ) + + +def _make_user(): + return SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="u", + first_name="U", + last_name="L", + language_code="en", + panel_user_uuid="panel-uuid", + ) + + +class HwidDeviceTopupInputTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_non_positive_device_count(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + for bad in (0, -1, "abc"): + with self.subTest(bad=bad): + result = await service.activate_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=bad, + payment_amount=50, + payment_db_id=1, + ) + self.assertIsNone(result) + + async def test_rejects_count_not_in_tariff_packages(self): + # Tariff only offers 1-device and 3-device packages; 2 should be rejected. + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + sub = _make_sub() + user = _make_user() + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + ): + result = await service.activate_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=2, + payment_amount=70, + payment_db_id=1, + ) + self.assertIsNone(result) + + +class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase): + async def test_unlimited_subscriber_returns_noop_payload(self): + # hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip. + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + sub = _make_sub(hwid_device_limit=0, extra_hwid_devices=0) + user = _make_user() + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase", + AsyncMock(), + ) as create_purchase, + ): + result = await service.activate_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=1, + payment_amount=50, + payment_db_id=1, + ) + self.assertEqual(result["hwid_device_limit"], 0) + self.assertEqual(result["purchased_hwid_devices"], 0) + # Unlimited subscriber: no audit row, no panel touch. + create_purchase.assert_not_awaited() + + async def test_panel_payload_uses_effective_limit_with_extras(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + sub = _make_sub(hwid_device_limit=3, extra_hwid_devices=2) + user = _make_user() + updated_sub = SimpleNamespace( + subscription_id=11, + end_date=sub.end_date, + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"ok": True} + ) + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.update_subscription", + AsyncMock(return_value=updated_sub), + ), + patch( + "bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id", + AsyncMock(return_value=SimpleNamespace()), + ), + patch( + "bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase", + AsyncMock(), + ), + ): + result = await service.activate_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=1, + payment_amount=50, + payment_db_id=1, + ) + + # Caller-facing payload: base=3, new extras = 2 + 1 purchased = 3 → effective = 6. + self.assertEqual(result["hwid_device_limit"], 6) + self.assertEqual(result["extra_hwid_devices"], 3) + self.assertEqual(result["purchased_hwid_devices"], 1) + # Panel must see the full effective limit, not just the base. + panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] + self.assertEqual(panel_payload["hwidDeviceLimit"], 6) + + async def test_panel_failure_returns_none_and_skips_audit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(tmpdir, _tariffs_config_payload()) + service = _make_service(settings) + sub = _make_sub() + user = _make_user() + updated_sub = SimpleNamespace(subscription_id=11, end_date=sub.end_date) + service.panel_service.update_user_details_on_panel = AsyncMock(return_value=None) + with ( + patch( + "bot.services.subscription_service_impl.devices.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + patch( + "bot.services.subscription_service_impl.devices.subscription_dal.update_subscription", + AsyncMock(return_value=updated_sub), + ), + patch( + "bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id", + AsyncMock(return_value=SimpleNamespace()), + ), + patch( + "bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase", + AsyncMock(), + ) as create_purchase, + ): + result = await service.activate_hwid_device_topup( + session=AsyncMock(), + user_id=42, + device_count=1, + payment_amount=50, + payment_db_id=1, + ) + self.assertIsNone(result) + create_purchase.assert_not_awaited() + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From cb5d59571e7f85a4249ec88512bfbb8661d4b86c Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 10:23:01 +0300 Subject: [PATCH 33/45] chore: cover referral bonus skip-paths, inviter and referee award flows --- tests/test_referral_bonuses.py | 427 +++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 tests/test_referral_bonuses.py diff --git a/tests/test_referral_bonuses.py b/tests/test_referral_bonuses.py new file mode 100644 index 0000000..76ae3d2 --- /dev/null +++ b/tests/test_referral_bonuses.py @@ -0,0 +1,427 @@ +"""Behavioural tests for ``ReferralService.apply_referral_bonuses_for_payment``. + +This is the entry point that every payment provider calls after a successful +``subscription`` purchase. The contract has several non-obvious branches that +have been wrong before in this project: + +* skip when the referee has no inviter recorded; +* skip when ``REFERRAL_ONE_BONUS_PER_REFEREE`` is set and the referee already + has prior succeeded payments; +* skip when the referee was already active at payment time and the caller + asked to skip that case; +* award both the inviter bonus (extend existing or create new bonus sub) + and the referee bonus (extend their freshly activated subscription). + +The bot/i18n/panel collaborators are stubbed because we are testing branching +and DB intent, not network I/O. +""" + +import unittest +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any, Optional +from unittest.mock import AsyncMock, patch + +from bot.services.referral_service import ReferralService + + +def _make_settings(**overrides: Any) -> SimpleNamespace: + base = dict( + DEFAULT_LANGUAGE="en", + REFERRAL_ONE_BONUS_PER_REFEREE=True, + user_traffic_limit_bytes=0, + referral_bonus_inviter={1: 7, 3: 21, 6: 45, 12: 90}, + referral_bonus_referee={1: 3, 3: 10, 6: 21, 12: 45}, + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def _make_user(user_id: int, *, referred_by_id: Optional[int] = None, **fields): + base = dict( + user_id=user_id, + first_name=f"User{user_id}", + language_code="en", + referred_by_id=referred_by_id, + email=None, + panel_user_uuid=f"panel-{user_id}", + ) + base.update(fields) + return SimpleNamespace(**base) + + +def _make_service(*, settings, subscription_service): + bot = AsyncMock() + bot.send_message = AsyncMock() + i18n = SimpleNamespace(gettext=lambda lang, key, **kw: f"{key}({lang})") + return ReferralService(settings, subscription_service, bot, i18n), bot + + +class SkipPathTests(unittest.IsolatedAsyncioTestCase): + async def test_no_inviter_returns_empty_payload(self): + settings = _make_settings() + subscription_service = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + # Referred_by_id is None → bail out immediately. + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock(return_value=_make_user(42, referred_by_id=None)), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + ) + self.assertEqual( + result, + {"referee_bonus_applied_days": None, "referee_new_end_date": None}, + ) + subscription_service.extend_active_subscription_days.assert_not_called() + + async def test_referee_with_prior_successful_payment_is_skipped(self): + # REFERRAL_ONE_BONUS_PER_REFEREE protects against duplicate awarding + # when the same referee buys multiple subscriptions. + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=True) + subscription_service = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + with ( + patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ), + patch( + "bot.services.referral_service.payment_dal.count_user_succeeded_payments", + AsyncMock(return_value=2), + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + current_payment_db_id=99, + ) + self.assertEqual( + result, + {"referee_bonus_applied_days": None, "referee_new_end_date": None}, + ) + subscription_service.extend_active_subscription_days.assert_not_called() + + async def test_referee_already_active_is_skipped_when_caller_asks(self): + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=True) + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + skip_if_active_before_payment=True, + ) + self.assertEqual( + result, + {"referee_bonus_applied_days": None, "referee_new_end_date": None}, + ) + subscription_service.extend_active_subscription_days.assert_not_called() + + +class InviterBonusTests(unittest.IsolatedAsyncioTestCase): + """The inviter bonus has two sub-paths: extend their active sub or create + a fresh bonus sub when they had none. Pin both.""" + + async def test_extends_inviter_active_subscription(self): + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + # _get_or_create_panel_user_link_details returns (uuid, sub_link_id, short, created) + subscription_service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("inviter-panel", "inviter-sub", "short", False) + ) + new_end = datetime(2026, 1, 1, tzinfo=timezone.utc) + subscription_service.extend_active_subscription_days = AsyncMock(return_value=new_end) + service, bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + skip_if_active_before_payment=False, + ) + + self.assertTrue(result["inviter_bonus_applied_flag"]) + # Inviter extension call must use the configured 1-month inviter bonus (7 days here). + subscription_service.extend_active_subscription_days.assert_any_await( + session=unittest.mock.ANY, + user_id=1, + bonus_days=7, + reason=unittest.mock.ANY, + ) + bot.send_message.assert_any_await(1, unittest.mock.ANY) + + async def test_creates_new_bonus_sub_when_inviter_has_no_active_sub(self): + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + subscription_service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("inviter-panel", "inviter-sub", "short", False) + ) + # Inviter has no active sub → extend returns None for inviter. + # extend_active_subscription_days is called twice (inviter + referee). Use a side_effect: + # First call (inviter) → None, second call (referee) → new datetime. + referee_new_end = datetime(2026, 2, 1, tzinfo=timezone.utc) + subscription_service.extend_active_subscription_days = AsyncMock( + side_effect=[None, referee_new_end] + ) + subscription_service.panel_service = AsyncMock() + subscription_service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"ok": True} + ) + + service, bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with ( + patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ), + patch( + "bot.services.referral_service.subscription_dal.deactivate_other_active_subscriptions", + AsyncMock(), + ), + patch( + "bot.services.referral_service.subscription_dal.upsert_subscription", + AsyncMock(return_value=SimpleNamespace(subscription_id=999)), + ) as upsert, + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + skip_if_active_before_payment=False, + ) + + self.assertTrue(result["inviter_bonus_applied_flag"]) + # A new bonus subscription should have been upserted for the inviter. + upsert.assert_awaited_once() + payload = upsert.await_args.args[1] + self.assertEqual(payload["user_id"], 1) + self.assertEqual(payload["panel_user_uuid"], "inviter-panel") + self.assertEqual(payload["duration_months"], 0) + self.assertEqual(payload["status_from_panel"], "ACTIVE_BONUS") + # The panel must have been told the new expiry & ACTIVE status. + panel_update = subscription_service.panel_service.update_user_details_on_panel + panel_update.assert_awaited_once() + pn_payload = panel_update.await_args.args[1] + self.assertEqual(pn_payload["status"], "ACTIVE") + self.assertTrue(pn_payload["expireAt"].endswith("Z")) + + +class RefereeBonusTests(unittest.IsolatedAsyncioTestCase): + async def test_applies_referee_bonus_via_extend_active_subscription(self): + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + # Inviter bonus is 0 for this test (skip inviter branch), referee gets 10 days. + subscription_service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("inviter-panel", "inviter-sub", "short", False) + ) + referee_new_end = datetime(2026, 3, 1, tzinfo=timezone.utc) + subscription_service.extend_active_subscription_days = AsyncMock( + return_value=referee_new_end + ) + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ): + # 3-month plan → 10-day referee bonus, 21-day inviter bonus. + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=3, + skip_if_active_before_payment=False, + ) + + self.assertEqual(result["referee_bonus_applied_days"], 10) + self.assertEqual(result["referee_new_end_date"], referee_new_end) + # Referee extension call: + referee_call = [ + call + for call in subscription_service.extend_active_subscription_days.await_args_list + if call.kwargs.get("user_id") == 42 + ] + self.assertTrue(referee_call, "referee bonus extension must be invoked") + self.assertEqual(referee_call[0].kwargs["bonus_days"], 10) + + async def test_no_referee_bonus_when_extend_returns_none(self): + settings = _make_settings( + REFERRAL_ONE_BONUS_PER_REFEREE=False, + # Skip the inviter branch entirely. + referral_bonus_inviter={}, + referral_bonus_referee={1: 3}, + ) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + subscription_service.extend_active_subscription_days = AsyncMock(return_value=None) + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=1, + skip_if_active_before_payment=False, + ) + + self.assertIsNone(result["referee_bonus_applied_days"]) + self.assertIsNone(result["referee_new_end_date"]) + + async def test_no_bonus_for_months_without_configured_value(self): + # 24-month plan has no entries in either bonus dict → nothing applies. + settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + subscription_service.extend_active_subscription_days = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: _make_user(uid, referred_by_id=1) + if uid == 42 + else _make_user(uid) + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=24, + skip_if_active_before_payment=False, + ) + + self.assertIsNone(result["referee_bonus_applied_days"]) + self.assertFalse(result["inviter_bonus_applied_flag"]) + subscription_service.extend_active_subscription_days.assert_not_called() + + +class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase): + async def test_includes_bot_username_and_referral_code(self): + settings = _make_settings() + subscription_service = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with ( + patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock(return_value=_make_user(1)), + ), + patch( + "bot.services.referral_service.user_dal.ensure_referral_code", + AsyncMock(return_value="abc123"), + ), + ): + link = await service.generate_referral_link( + session=AsyncMock(), + bot_username="my_bot", + inviter_user_id=1, + ) + self.assertEqual(link, "https://t.me/my_bot?start=ref_uabc123") + + async def test_returns_none_when_user_missing(self): + settings = _make_settings() + subscription_service = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock(return_value=None), + ): + link = await service.generate_referral_link( + session=AsyncMock(), + bot_username="my_bot", + inviter_user_id=999, + ) + self.assertIsNone(link) + + async def test_returns_none_when_referral_code_unavailable(self): + settings = _make_settings() + subscription_service = AsyncMock() + service, _bot = _make_service( + settings=settings, subscription_service=subscription_service + ) + + with ( + patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock(return_value=_make_user(1)), + ), + patch( + "bot.services.referral_service.user_dal.ensure_referral_code", + AsyncMock(return_value=None), + ), + ): + link = await service.generate_referral_link( + session=AsyncMock(), + bot_username="my_bot", + inviter_user_id=1, + ) + self.assertIsNone(link) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 91e79388d190c125a24b134745f0ced4b6cdafad Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 17:26:37 +0300 Subject: [PATCH 34/45] fix: hide device topup button when no HWID packages or unlimited devices --- backend/bot/app/web/webapp/serializers.py | 8 ++++++++ backend/bot/handlers/user/subscription/core.py | 11 ++++++++++- frontend/src/webapp/screens/DevicesScreen.svelte | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 9ae8300..bc0c158 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -193,6 +193,7 @@ def _serialize_subscription( can_topup_regular_traffic = False can_topup_premium_traffic = False can_topup_traffic = False + can_topup_devices = False if settings.tariffs_config and active.get("tariff_key"): try: tariff = settings.tariffs_config.require(str(active.get("tariff_key"))) @@ -204,10 +205,16 @@ def _serialize_subscription( and tariff.premium_topup_packages.has_any() ) can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) + # max_devices == 0 means unlimited — top-up is pointless in that case. + can_topup_devices = bool( + tariff.has_hwid_device_packages() + and _coerce_int_or_none(active.get("max_devices")) != 0 + ) except Exception: can_topup_regular_traffic = False can_topup_premium_traffic = False can_topup_traffic = False + can_topup_devices = False return { "active": seconds_left > 0, @@ -249,6 +256,7 @@ def _serialize_subscription( "can_topup_traffic": can_topup_traffic, "can_topup_regular_traffic": can_topup_regular_traffic, "can_topup_premium_traffic": can_topup_premium_traffic, + "can_topup_devices": can_topup_devices, "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None, diff --git a/backend/bot/handlers/user/subscription/core.py b/backend/bot/handlers/user/subscription/core.py index cb53d4a..c50e9b1 100644 --- a/backend/bot/handlers/user/subscription/core.py +++ b/backend/bot/handlers/user/subscription/core.py @@ -994,7 +994,16 @@ async def my_subscription_command_handler( ) ] ) - if settings.tariffs_config and local_sub and local_sub.tariff_key: + # Skip the buy-devices button entirely when the user has unlimited + # devices (max_devices == 0). Otherwise the button leads to an + # alert "hwid_devices_unlimited_no_topup" — confusing dead end. + devices_topup_allowed = ( + settings.tariffs_config + and local_sub + and local_sub.tariff_key + and max_devices_value not in (None, 0) + ) + if devices_topup_allowed: try: tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key) if ( diff --git a/frontend/src/webapp/screens/DevicesScreen.svelte b/frontend/src/webapp/screens/DevicesScreen.svelte index 63f9774..c411e7e 100644 --- a/frontend/src/webapp/screens/DevicesScreen.svelte +++ b/frontend/src/webapp/screens/DevicesScreen.svelte @@ -46,7 +46,7 @@ value={devicesPercent(devicesData)} label={t("wa_devices_title")} /> - {#if subscription?.active && subscription?.max_devices !== 0} + {#if subscription?.active && subscription?.max_devices !== 0 && subscription?.can_topup_devices} - {/if} - {#if hasMultipleTariffs && selectedTariff} -

- {t("wa_selected_tariff", { tariff: selectedTariff.title })} -

- {/if} + {:else if tariffMode} + {#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))} + + {/if} + {#if hasMultipleTariffs && selectedTariff} +

+ {t("wa_selected_tariff", { tariff: selectedTariff.title })} +

{/if} {#if selectedTariffPlans.length}
@@ -215,8 +213,13 @@ {:else} {t("wa_no_tariff_change_options")} {/if} - {/if} - {#if !tariffMode} + {:else} +
{#each plans as plan}