Merge branch 'feature/install-page' into dev

This commit is contained in:
3252a8
2026-05-23 16:16:46 +03:00
63 changed files with 9409 additions and 119 deletions
+27 -28
View File
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
async def admin_settings_get_route(request: web.Request) -> web.Response: async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -27,12 +33,25 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
override = overrides_by_key.get(key) override = overrides_by_key.get(key)
value = current_value(settings, key) value = current_value(settings, key)
is_secret = bool(field.get("secret")) is_secret = bool(field.get("secret"))
overridden = bool(override)
source = None
read_error = None
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
try:
value, source = subscription_guides_admin_config_json(settings)
overridden = source == "admin_json"
except SubscriptionGuidesConfigError as exc:
read_error = str(exc)
response_field = { response_field = {
**field, **field,
"value": "" if is_secret else value, "value": "" if is_secret else value,
"overridden": bool(override), "overridden": overridden,
"updated_at": override.get("updated_at") if override else None, "updated_at": override.get("updated_at") if override else None,
} }
if source:
response_field["source"] = source
if read_error:
response_field["read_error"] = read_error
if is_secret: if is_secret:
response_field["has_value"] = bool(value) response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip() webhook_path = str(response_field.get("webhook_path") or "").strip()
@@ -60,6 +79,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates") return _error(400, "invalid_updates")
if not isinstance(deletes, list): if not isinstance(deletes, list):
return _error(400, "invalid_deletes") return _error(400, "invalid_deletes")
if "SUBSCRIPTION_PAGE_CONFIG_JSON" in updates and not str(
updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or ""
).strip():
updates = dict(updates)
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
result = await update_overrides( result = await update_overrides(
settings, settings,
@@ -74,32 +99,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
status=400, status=400,
) )
# Bust the public webapp settings cache so users see new values immediately. await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
try:
from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches
await invalidate_all_webapp_user_caches(settings, include_devices=True)
except Exception:
logger.exception("Failed to invalidate WebApp user payload caches after settings update")
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
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
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)}) return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
async def admin_tariffs_get_route(request: web.Request) -> web.Response: async def admin_tariffs_get_route(request: web.Request) -> web.Response:
@@ -55,9 +56,6 @@ async def admin_tariffs_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write tariffs config to %s", path) logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc)) return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
+3 -10
View File
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio import asyncio
import hashlib import hashlib
@@ -213,12 +214,7 @@ async def _persist_appearance_upload(
logger.warning("Failed to persist uploaded appearance asset settings: %s", result) logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False return False
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
request.app["webapp_logo_cache"] = None
prune_unused_appearance_assets(settings)
return True return True
@@ -484,10 +480,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR) logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc)) return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache") await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok( return _ok(
{ {
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from bot.app.web.webapp.cache_helpers import (
invalidate_all_webapp_user_payloads,
reset_subscription_guides_cache,
reset_webapp_settings_cache,
)
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"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_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
{
"MY_DEVICES_SECTION_ENABLED",
"USER_HWID_DEVICE_LIMIT",
"USER_TRAFFIC_LIMIT_GB",
"USER_TRAFFIC_STRATEGY",
}
)
def changed_setting_keys(
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
) -> set[str]:
keys = {str(key) for key in (updates or {}).keys()}
keys.update(str(key) for key in (deletes or []) if key is not None)
return keys
async def refresh_webapp_runtime_after_settings_change(
request: Any,
*,
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
include_user_payloads: bool = True,
) -> None:
settings = request.app["settings"]
keys = changed_setting_keys(updates, deletes)
reset_webapp_settings_cache(request.app)
reset_subscription_guides_cache(request.app)
if include_user_payloads:
await invalidate_all_webapp_user_payloads(
settings,
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
)
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
+67 -1
View File
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True) @dataclass(frozen=True)
class SettingField: class SettingField:
key: str key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon" | "json"
section: str section: str
label: str label: str
description: str = "" description: str = ""
@@ -160,6 +160,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"), SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"), SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"),
SettingField(
"SUBSCRIPTION_GUIDES_ENABLED",
"bool",
"subscription_guides",
"Embedded install guides",
"Open install instructions inside the Web App instead of an external connect page.",
),
SettingField(
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
"bool",
"subscription_guides",
"Open install guides from bot",
(
"Use the Telegram Mini App install screen for bot connect buttons and show "
"public install guide links."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"bool",
"subscription_guides",
"Use Remnawave Panel config",
(
"Fetch Subscription Page config from Remnawave Panel by the user's "
"subscription short UUID."
),
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
"bool",
"subscription_guides",
"Enable admin JSON override",
"Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_PATH",
"string",
"subscription_guides",
"Subscription Page config path",
"Fallback path to a Remnawave Subscription Page v1 JSON config file.",
placeholder="data/subpage-config/multiapp.json",
),
SettingField(
"SUBSCRIPTION_PAGE_CONFIG_JSON",
"json",
"subscription_guides",
"Subscription Page config JSON",
(
"Optional admin JSON override. It is applied only when the JSON override "
"switch is enabled."
),
placeholder="{\n \"version\": \"1\"\n}",
),
# ─── Subscription periods & pricing ──────────────────────────── # ─── Subscription periods & pricing ────────────────────────────
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"), SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
@@ -454,6 +507,18 @@ def manifest_keys() -> List[str]:
def coerce_value(field: SettingField, raw: Any) -> Any: def coerce_value(field: SettingField, raw: Any) -> Any:
"""Coerce a value coming from JSON to the type declared by the field.""" """Coerce a value coming from JSON to the type declared by the field."""
if field.type == "json":
if raw is None:
return ""
text = raw if isinstance(raw, str) else str(raw)
text = text.strip()
if not text:
return ""
from config.subscription_guides_config import validate_subscription_guides_config_text
validate_subscription_guides_config_text(text)
return text
if raw is None or (isinstance(raw, str) and raw.strip() == ""): if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return None return None
@@ -523,6 +588,7 @@ def manifest_payload() -> List[dict]:
"notifications": 7, "notifications": 7,
"support": 8, "support": 8,
"devices": 9, "devices": 9,
"subscription_guides": 10,
} }
items: List[dict] = [] items: List[dict] = []
for field in aggregated_manifest(): for field in aggregated_manifest():
@@ -11,6 +11,7 @@ from bot.app.web.webapp import (
billing as _billing, billing as _billing,
common as _common, common as _common,
devices as _devices, devices as _devices,
guides as _guides,
payloads as _payloads, payloads as _payloads,
routes as _routes, routes as _routes,
serializers as _serializers, serializers as _serializers,
@@ -27,6 +28,7 @@ _MODULES = (
_serializers, _serializers,
_billing, _billing,
_devices, _devices,
_guides,
_support, _support,
_routes, _routes,
_application, _application,
+94 -4
View File
@@ -149,6 +149,12 @@
.theme-key-ascii .ticket-message-avatar, .theme-key-ascii .ticket-message-avatar,
.theme-key-ascii .ticket-message-bubble, .theme-key-ascii .ticket-message-bubble,
.theme-key-ascii .ticket-composer, .theme-key-ascii .ticket-composer,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
.theme-key-ascii .admin-sidebar, .theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header, .theme-key-ascii .admin-header,
.theme-key-ascii .admin-card, .theme-key-ascii .admin-card,
@@ -183,6 +189,8 @@
.theme-key-ascii .support-new-ticket-button, .theme-key-ascii .support-new-ticket-button,
.theme-key-ascii .support-select-trigger, .theme-key-ascii .support-select-trigger,
.theme-key-ascii .support-status-tabs-trigger, .theme-key-ascii .support-status-tabs-trigger,
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .admin-btn, .theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip, .theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger, .theme-key-ascii .admin-tabs-trigger,
@@ -230,6 +238,7 @@
.theme-key-ascii .support-status-tabs-trigger[data-state="active"], .theme-key-ascii .support-status-tabs-trigger[data-state="active"],
.theme-key-ascii .support-select-item[data-highlighted], .theme-key-ascii .support-select-item[data-highlighted],
.theme-key-ascii .support-select-item[data-selected], .theme-key-ascii .support-select-item[data-selected],
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .admin-nav-item.active, .theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"], .theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active { .theme-key-ascii .admin-revenue-period-btn.is-active {
@@ -259,6 +268,9 @@
.theme-key-ascii .admin-revenue-period-btn:focus-visible, .theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible, .theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible, .theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger:focus-visible,
.theme-key-ascii .install-platform-trigger[data-state="open"],
.theme-key-ascii .install-app-button:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible { .theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff; outline: 2px solid #ffffff;
outline-offset: 1px; outline-offset: 1px;
@@ -412,6 +424,7 @@
} }
body:has(.theme-key-ascii) .support-select-content, body:has(.theme-key-ascii) .support-select-content,
body:has(.theme-key-ascii) .install-platform-content,
body:has(.theme-key-ascii) .field-error-tooltip { body:has(.theme-key-ascii) .field-error-tooltip {
border: 1px solid #ffffff; border: 1px solid #ffffff;
border-radius: 0; border-radius: 0;
@@ -420,19 +433,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
box-shadow: 0 0 0 1px #ffffff; box-shadow: 0 0 0 1px #ffffff;
} }
body:has(.theme-key-ascii) .support-select-item { body:has(.theme-key-ascii) .support-select-item,
body:has(.theme-key-ascii) .install-platform-item {
border-radius: 0; border-radius: 0;
color: #ffffff; color: #ffffff;
} }
body:has(.theme-key-ascii) .support-select-item[data-highlighted], body:has(.theme-key-ascii) .support-select-item[data-highlighted],
body:has(.theme-key-ascii) .support-select-item[data-selected] { body:has(.theme-key-ascii) .support-select-item[data-selected],
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff; background: #ffffff;
color: #000000 !important; color: #000000 !important;
} }
body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg, body:has(.theme-key-ascii) .support-select-item[data-highlighted] svg,
body:has(.theme-key-ascii) .support-select-item[data-selected] svg { body:has(.theme-key-ascii) .support-select-item[data-selected] svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important; color: #000000 !important;
stroke: #000000 !important; stroke: #000000 !important;
} }
@@ -695,7 +713,8 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.theme-key-ascii .admin-btn-primary svg.lucide, .theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active 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-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide { .theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-ascii .install-app-button.active svg.lucide {
color: #000000 !important; color: #000000 !important;
stroke: #000000 !important; stroke: #000000 !important;
} }
@@ -1050,6 +1069,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.language-select-content, .language-select-item, .language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button, .language-select-trigger, .bottom-nav, .bottom-nav button,
.link-button, .link-button,
.install-platform-trigger, .install-platform-content, .install-platform-item,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-subscription-header-icon, .install-loading,
.support-overview-card, .support-list-card, .support-ticket-card, .support-overview-card, .support-list-card, .support-ticket-card,
.support-conversation-card, .support-new-ticket-button, .support-conversation-card, .support-new-ticket-button,
.support-create-panel, .support-select-trigger, .support-select-content, .support-create-panel, .support-select-trigger, .support-select-content,
@@ -1083,6 +1105,9 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
.admin-tariff-card, .admin-toolbar-card, .admin-table-card, .admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card, .admin-panel-dash-card,
.admin-select-trigger, .admin-select-content, .admin-select-trigger, .admin-select-content,
.install-platform-trigger, .install-platform-content,
.install-app-button, .install-step, .install-subscription-card,
.install-qr-wrap, .install-loading,
.admin-cn-card, .admin-cn-card,
.admin-input, .admin-textarea, .admin-btn, .admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger .admin-nav-item, .admin-tabs-trigger
@@ -1096,6 +1121,71 @@ body:has(.theme-key-ascii) .support-select-item[data-selected] svg {
border-radius: 0 !important; border-radius: 0 !important;
} }
/* ---------- Install guide theme surfaces ---------- */
.theme-key-ascii .install-platform-trigger,
.theme-key-ascii .install-app-button,
.theme-key-ascii .install-step,
.theme-key-ascii .install-subscription-card,
.theme-key-ascii .install-qr-wrap,
.theme-key-ascii .install-loading,
body:has(.theme-key-ascii) .install-platform-content {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
box-shadow: none !important;
}
.theme-key-ascii .install-platform-trigger:hover,
.theme-key-ascii .install-app-button:hover:not(:disabled) {
background: #ffffff !important;
color: #000000 !important;
transform: none !important;
}
.theme-key-ascii .install-app-button.active,
.theme-key-ascii .install-app-button.active:hover:not(:disabled),
body:has(.theme-key-ascii) .install-platform-item[data-highlighted],
body:has(.theme-key-ascii) .install-platform-item[data-selected] {
background: #ffffff !important;
color: #000000 !important;
border-color: #ffffff !important;
}
.theme-key-ascii .install-app-button.active svg,
body:has(.theme-key-ascii) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
color: #000000 !important;
stroke: #000000 !important;
}
.theme-key-ascii .install-step:hover,
.theme-key-ascii .install-subscription-card:hover {
transform: none !important;
box-shadow: none !important;
}
.theme-key-ascii .install-step-icon,
.theme-key-ascii .install-subscription-header-icon {
border: 1px solid currentColor !important;
background: #000000 !important;
color: #ffffff !important;
}
.theme-key-ascii .install-qr-divider {
color: #ffffff !important;
opacity: 0.72;
}
.theme-key-ascii .install-feature-star.attention-dot {
background: #ffffff !important;
animation: ascii-caret 1s steps(1) infinite !important;
}
.theme-key-ascii .install-loading .ui-spinner {
color: #ffffff;
}
/* ============================================================ /* ============================================================
* Console-style tables: cell borders, header underline, * Console-style tables: cell borders, header underline,
* row separator using dashed line. * row separator using dashed line.
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 3, "assets_version": 4,
"tokens": { "tokens": {
"color_scheme": "dark", "color_scheme": "dark",
"style_preset": "ascii" "style_preset": "ascii"
@@ -129,3 +129,57 @@
.theme-key-light .bonus-card-head > svg { .theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000); color: color-mix(in srgb, var(--accent) 50%, #000000);
} }
/* Install guide theme surfaces */
.theme-key-light .install-platform-trigger,
.theme-key-light .install-app-button,
.theme-key-light .install-step,
.theme-key-light .install-subscription-card,
.theme-key-light .install-qr-wrap {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
}
.theme-key-light .install-app-button.active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.theme-key-light .install-platform-trigger:focus-visible,
.theme-key-light .install-platform-trigger[data-state="open"],
.theme-key-light .install-app-button:focus-visible {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
body:has(.theme-key-light) .install-platform-content {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.14);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
}
body:has(.theme-key-light) .install-platform-item[data-highlighted],
body:has(.theme-key-light) .install-platform-item[data-selected] {
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
}
.theme-key-light .install-step-icon,
.theme-key-light .install-subscription-header-icon {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
.theme-key-light .install-qr-divider {
color: rgba(15, 23, 42, 0.24);
}
.theme-key-light .install-feature-star.attention-dot {
background: #f59e0b;
}
.theme-key-light .install-loading .ui-spinner {
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true, "use_primary_accent": true,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 2, "assets_version": 3,
"tokens": { "tokens": {
"color_scheme": "light" "color_scheme": "light"
} }
+127 -6
View File
@@ -138,12 +138,15 @@
.theme-key-windows95 svg.lucide-megaphone, .theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square, .theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-message-square-plus, .theme-key-windows95 svg.lucide-message-square-plus,
.theme-key-windows95 svg.lucide-monitor,
.theme-key-windows95 svg.lucide-paintbrush, .theme-key-windows95 svg.lucide-paintbrush,
.theme-key-windows95 svg.lucide-plus, .theme-key-windows95 svg.lucide-plus,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-refresh-cw, .theme-key-windows95 svg.lucide-refresh-cw,
.theme-key-windows95 svg.lucide-save, .theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search, .theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send, .theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-share-2,
.theme-key-windows95 svg.lucide-settings, .theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield, .theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders, .theme-key-windows95 svg.lucide-sliders,
@@ -273,6 +276,10 @@
--win95-button-icon: var(--win95-icon-send); --win95-button-icon: var(--win95-icon-send);
} }
.theme-key-windows95 svg.lucide-monitor {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-paintbrush { .theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush); --win95-button-icon: var(--win95-icon-paintbrush);
} }
@@ -281,6 +288,10 @@
--win95-button-icon: var(--win95-icon-folder); --win95-button-icon: var(--win95-icon-folder);
} }
.theme-key-windows95 svg.lucide-qr-code {
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-refresh-cw { .theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh); --win95-button-icon: var(--win95-icon-refresh);
} }
@@ -293,6 +304,10 @@
--win95-button-icon: var(--win95-icon-search); --win95-button-icon: var(--win95-icon-search);
} }
.theme-key-windows95 svg.lucide-share-2 {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-settings { .theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings); --win95-button-icon: var(--win95-icon-settings);
} }
@@ -371,12 +386,15 @@
svg.lucide-megaphone, svg.lucide-megaphone,
svg.lucide-message-square, svg.lucide-message-square,
svg.lucide-message-square-plus, svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush, svg.lucide-paintbrush,
svg.lucide-plus, svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw, svg.lucide-refresh-cw,
svg.lucide-save, svg.lucide-save,
svg.lucide-search, svg.lucide-search,
svg.lucide-send, svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings, svg.lucide-settings,
svg.lucide-shield, svg.lucide-shield,
svg.lucide-sliders, svg.lucide-sliders,
@@ -425,12 +443,15 @@
svg.lucide-megaphone, svg.lucide-megaphone,
svg.lucide-message-square, svg.lucide-message-square,
svg.lucide-message-square-plus, svg.lucide-message-square-plus,
svg.lucide-monitor,
svg.lucide-paintbrush, svg.lucide-paintbrush,
svg.lucide-plus, svg.lucide-plus,
svg.lucide-qr-code,
svg.lucide-refresh-cw, svg.lucide-refresh-cw,
svg.lucide-save, svg.lucide-save,
svg.lucide-search, svg.lucide-search,
svg.lucide-send, svg.lucide-send,
svg.lucide-share-2,
svg.lucide-settings, svg.lucide-settings,
svg.lucide-shield, svg.lucide-shield,
svg.lucide-sliders, svg.lucide-sliders,
@@ -488,7 +509,13 @@
.theme-key-windows95 .support-message-scroll, .theme-key-windows95 .support-message-scroll,
.theme-key-windows95 .ticket-message-avatar, .theme-key-windows95 .ticket-message-avatar,
.theme-key-windows95 .ticket-message-bubble, .theme-key-windows95 .ticket-message-bubble,
.theme-key-windows95 .ticket-composer { .theme-key-windows95 .ticket-composer,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button,
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
border-width: 2px; border-width: 2px;
border-style: solid; border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff; border-color: #ffffff #404040 #404040 #ffffff;
@@ -510,6 +537,7 @@
body:has(.theme-key-windows95) .language-select-content, body:has(.theme-key-windows95) .language-select-content,
body:has(.theme-key-windows95) .support-select-content, body:has(.theme-key-windows95) .support-select-content,
body:has(.theme-key-windows95) .install-platform-content,
body:has(.theme-key-windows95) .field-error-tooltip, body:has(.theme-key-windows95) .field-error-tooltip,
body:has(.theme-key-windows95) .admin-select-content { body:has(.theme-key-windows95) .admin-select-content {
border-width: 2px; border-width: 2px;
@@ -525,18 +553,23 @@ body:has(.theme-key-windows95) .admin-select-content {
body:has(.theme-key-windows95) .language-select-item, body:has(.theme-key-windows95) .language-select-item,
body:has(.theme-key-windows95) .support-select-item, body:has(.theme-key-windows95) .support-select-item,
body:has(.theme-key-windows95) .install-platform-item,
body:has(.theme-key-windows95) .admin-select-item { body:has(.theme-key-windows95) .admin-select-item {
border-radius: 0 !important; border-radius: 0 !important;
} }
body:has(.theme-key-windows95) .support-select-item[data-highlighted], body:has(.theme-key-windows95) .support-select-item[data-highlighted],
body:has(.theme-key-windows95) .support-select-item[data-selected] { body:has(.theme-key-windows95) .support-select-item[data-selected],
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: #000080; background: #000080;
color: #ffffff !important; color: #ffffff !important;
} }
body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg, body:has(.theme-key-windows95) .support-select-item[data-highlighted] svg,
body:has(.theme-key-windows95) .support-select-item[data-selected] svg { body:has(.theme-key-windows95) .support-select-item[data-selected] svg,
body:has(.theme-key-windows95) .install-platform-item[data-highlighted] svg,
body:has(.theme-key-windows95) .install-platform-item[data-selected] svg {
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
@@ -560,7 +593,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .link-button, .theme-key-windows95 .link-button,
.theme-key-windows95 .support-new-ticket-button, .theme-key-windows95 .support-new-ticket-button,
.theme-key-windows95 .support-select-trigger, .theme-key-windows95 .support-select-trigger,
.theme-key-windows95 .support-status-tabs-trigger { .theme-key-windows95 .support-status-tabs-trigger,
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
min-height: 34px; min-height: 34px;
border: 2px solid; border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff; border-color: #ffffff #404040 #404040 #ffffff;
@@ -625,7 +660,8 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .support-new-ticket-button.active, .theme-key-windows95 .support-new-ticket-button.active,
.theme-key-windows95 .support-status-tabs-trigger[data-state="active"], .theme-key-windows95 .support-status-tabs-trigger[data-state="active"],
.theme-key-windows95 .support-select-item[data-highlighted], .theme-key-windows95 .support-select-item[data-highlighted],
.theme-key-windows95 .support-select-item[data-selected] { .theme-key-windows95 .support-select-item[data-selected],
.theme-key-windows95 .install-app-button.active {
background: var(--accent); background: var(--accent);
color: #ffffff; color: #ffffff;
} }
@@ -718,6 +754,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .ticket-composer:focus-within, .theme-key-windows95 .ticket-composer:focus-within,
.theme-key-windows95 .support-select-trigger:focus-visible, .theme-key-windows95 .support-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .ticket-card:focus-visible, .theme-key-windows95 .ticket-card:focus-visible,
.theme-key-windows95 .support-status-tabs-trigger:focus-visible { .theme-key-windows95 .support-status-tabs-trigger:focus-visible {
outline: 1px dotted #000000; outline: 1px dotted #000000;
@@ -731,6 +770,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
transition: none; transition: none;
} }
/* ---------- Install guide theme surfaces ---------- */
.theme-key-windows95 .install-platform-trigger,
.theme-key-windows95 .install-app-button {
background: #c0c0c0;
color: #000000;
transition: none;
transform: none;
}
.theme-key-windows95 .install-platform-trigger:hover,
.theme-key-windows95 .install-app-button:hover:not(:disabled):not(.active) {
background: #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-app-button.active,
.theme-key-windows95 .install-app-button.active:hover:not(:disabled) {
background: var(--accent);
color: #ffffff;
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
transform: none;
}
.theme-key-windows95 .install-step,
.theme-key-windows95 .install-subscription-card,
.theme-key-windows95 .install-qr-wrap,
.theme-key-windows95 .install-loading {
background: #c0c0c0;
transition: none;
}
.theme-key-windows95 .install-step:hover,
.theme-key-windows95 .install-subscription-card:hover {
transform: none;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .install-step-icon,
.theme-key-windows95 .install-subscription-header-icon {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #dfdfdf;
color: var(--accent);
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080;
}
body:has(.theme-key-windows95) .install-platform-content {
background: #c0c0c0;
}
body:has(.theme-key-windows95) .install-platform-item[data-highlighted],
body:has(.theme-key-windows95) .install-platform-item[data-selected] {
background: var(--accent);
color: #ffffff !important;
}
.theme-key-windows95 .install-qr-divider {
color: #404040;
opacity: 1;
}
.theme-key-windows95 .install-feature-star.attention-dot {
background: #ffff00 !important;
border: 1px solid #000000;
box-shadow: 1px 1px 0 #000000;
}
.theme-key-windows95 .install-loading .ui-spinner {
color: var(--accent);
}
.theme-key-windows95 .card-heading-accent, .theme-key-windows95 .card-heading-accent,
.theme-key-windows95 .brand-row strong, .theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1, .theme-key-windows95 .login-brand h1,
@@ -1059,6 +1177,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-revenue-period-btn:focus-visible, .theme-key-windows95 .admin-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible, .theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger:focus-visible, .theme-key-windows95 .language-select-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger:focus-visible,
.theme-key-windows95 .install-platform-trigger[data-state="open"],
.theme-key-windows95 .install-app-button:focus-visible,
.theme-key-windows95 .bottom-nav button:focus-visible { .theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000; outline: 1px dotted #000000;
outline-offset: -4px; outline-offset: -4px;
@@ -1116,6 +1237,7 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 .admin-nav-item.active svg.lucide, .theme-key-windows95 .admin-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide, .theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide, .theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .install-app-button.active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide { .theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
@@ -1133,7 +1255,6 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
.theme-key-windows95 svg.lucide-map, .theme-key-windows95 svg.lucide-map,
.theme-key-windows95 svg.lucide-menu, .theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click, .theme-key-windows95 svg.lucide-mouse-pointer-click,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-radio, .theme-key-windows95 svg.lucide-radio,
.theme-key-windows95 svg.lucide-repeat-2, .theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server, .theme-key-windows95 svg.lucide-server,
@@ -9,7 +9,7 @@
"use_primary_accent": false, "use_primary_accent": false,
"use_in_admin": true, "use_in_admin": true,
"css_file": "style.css", "css_file": "style.css",
"assets_version": 9, "assets_version": 11,
"tokens": { "tokens": {
"color_scheme": "light", "color_scheme": "light",
"style_preset": "win95" "style_preset": "win95"
@@ -1,5 +1,6 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from .guides import warm_subscription_guides_config
def create_subscription_webapp_application( def create_subscription_webapp_application(
@@ -24,6 +25,8 @@ def create_subscription_webapp_application(
app["webapp_logo_cache"] = None app["webapp_logo_cache"] = None
app["webapp_logo_cache_lock"] = asyncio.Lock() app["webapp_logo_cache_lock"] = asyncio.Lock()
app["webapp_settings_cache"] = {"ts": 0.0, "data": {}} app["webapp_settings_cache"] = {"ts": 0.0, "data": {}}
app["subscription_guides_config_cache"] = {"fingerprint": None, "status": None}
app["subscription_guides_config_lock"] = asyncio.Lock()
app["webapp_rate_limit_buckets"] = {} app["webapp_rate_limit_buckets"] = {}
app["webapp_rate_limit_lock"] = asyncio.Lock() app["webapp_rate_limit_lock"] = asyncio.Lock()
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
await _ensure_shared_http_session() await _ensure_shared_http_session()
await _warm_webapp_logo_cache(app_obj) await _warm_webapp_logo_cache(app_obj)
await _warm_webapp_animated_emoji_cache(app_obj) await _warm_webapp_animated_emoji_cache(app_obj)
await warm_subscription_guides_config(app_obj)
async def _shutdown(app_obj: web.Application) -> None: async def _shutdown(app_obj: web.Application) -> None:
await _close_shared_http_session() await _close_shared_http_session()
+43 -14
View File
@@ -9,6 +9,24 @@ from config.settings import Settings
_WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {} _WEBAPP_USER_PAYLOAD_CACHES: dict[tuple[int, str, int], AsyncTTLCache] = {}
def reset_webapp_settings_cache(app: Any) -> None:
cache = app.get("webapp_settings_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
def reset_subscription_guides_cache(app: Any) -> None:
cache = app.get("subscription_guides_config_cache") if hasattr(app, "get") else None
if isinstance(cache, dict):
cache["fingerprint"] = None
cache["status"] = None
def _payload_namespaces(include_devices: bool = False) -> tuple[str, ...]:
return ("me", "devices") if include_devices else ("me",)
def _webapp_user_payload_cache( def _webapp_user_payload_cache(
settings: Settings, settings: Settings,
namespace: str, namespace: str,
@@ -58,13 +76,22 @@ def invalidate_local_webapp_user_payload(
def invalidate_all_local_webapp_user_payloads( def invalidate_all_local_webapp_user_payloads(
settings: Settings, settings: Settings,
namespace: Optional[str] = None, namespace: Optional[str] = None,
*,
include_devices: Optional[bool] = None,
) -> None: ) -> None:
if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple( for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items() _WEBAPP_USER_PAYLOAD_CACHES.items()
): ):
if settings_id != id(settings): if settings_id != id(settings):
continue continue
if namespace is not None and cache_namespace != namespace: if namespaces is not None and cache_namespace not in namespaces:
continue continue
cache.invalidate() cache.invalidate()
@@ -95,21 +122,23 @@ async def invalidate_webapp_user_caches(
await cache_delete(settings, *keys) await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
async def invalidate_all_webapp_user_caches( async def invalidate_all_webapp_user_caches(
settings: Settings, settings: Settings,
*, *,
include_devices: bool = False, include_devices: bool = False,
) -> None: ) -> None:
namespaces = ["me"] await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
if include_devices:
namespaces.append("devices")
for namespace in namespaces:
invalidate_all_local_webapp_user_payloads(settings, namespace)
try:
await cache_delete_pattern(
settings,
redis_key(settings, "cache", "webapp", namespace, "*"),
)
except Exception:
continue
+2 -20
View File
@@ -2,7 +2,7 @@
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import ( from bot.app.web.webapp.cache_helpers import (
invalidate_local_webapp_user_payload, invalidate_webapp_user_caches as _invalidate_user_payload_caches,
) )
@@ -26,25 +26,7 @@ async def _invalidate_webapp_user_caches(
*user_ids: Optional[int], *user_ids: Optional[int],
include_devices: bool = False, include_devices: bool = False,
) -> None: ) -> None:
keys: List[str] = [] await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
seen: set[int] = set()
for raw_user_id in user_ids:
if raw_user_id is None:
continue
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
continue
if user_id in seen:
continue
seen.add(user_id)
keys.append(redis_key(settings, "cache", "webapp", "me", user_id))
invalidate_local_webapp_user_payload(settings, "me", user_id)
if include_devices:
keys.append(redis_key(settings, "cache", "webapp", "devices", user_id))
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
def _validation_error_response(exc: ValidationError) -> web.Response: def _validation_error_response(exc: ValidationError) -> web.Response:
+289
View File
@@ -0,0 +1,289 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_status,
validate_panel_subscription_guides_config,
)
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS = 30
async def warm_subscription_guides_config(app: web.Application) -> None:
try:
await _subscription_guides_status_shared(app)
except Exception as exc:
logger.warning("Failed to warm subscription guides config: %s", exc)
async def subscription_guides_route(request: web.Request) -> web.Response:
_require_user_id(request)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def public_subscription_guides_route(request: web.Request) -> web.Response:
share_token = subscription_dal.normalize_install_share_token(
request.match_info.get("share_token")
)
if not share_token:
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
subscription = await _public_subscription_payload(request, share_token)
if not subscription.get("active"):
return web.json_response(
{
"ok": False,
"enabled": False,
"config": None,
"source": None,
"subscription": subscription,
"error": "subscription_unavailable",
},
status=404,
)
status = await _subscription_guides_status_shared(request.app)
payload = {
"enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None,
"source": status.get("source"),
"subscription": subscription,
}
if status.get("error"):
payload["error"] = status["error"]
return web.json_response({"ok": True, **payload})
async def _subscription_guides_status_shared(app: web.Application) -> Dict[str, Any]:
settings: Settings = app["settings"]
cache = app.setdefault("subscription_guides_config_cache", {})
lock: asyncio.Lock = app.setdefault("subscription_guides_config_lock", asyncio.Lock())
fingerprint = _subscription_guides_settings_fingerprint(settings)
now = time.monotonic()
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
async with lock:
cached = cache.get("status")
if cached is not None and cache.get("fingerprint") == fingerprint:
if cached.get("enabled") or now - float(cache.get("ts", 0.0)) < (
SUBSCRIPTION_GUIDES_CACHE_ERROR_TTL_SECONDS
):
return cached
status = await _load_subscription_guides_status(app, settings)
cache["fingerprint"] = fingerprint
cache["status"] = status
cache["ts"] = time.monotonic()
return status
async def _load_subscription_guides_status(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return subscription_guides_status(settings)
if bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)):
panel_status = await _subscription_guides_status_from_panel_config(app, settings)
if panel_status.get("enabled"):
return panel_status
return subscription_guides_status(settings)
async def _subscription_guides_status_from_panel_config(
app: web.Application,
settings: Settings,
) -> Dict[str, Any]:
panel_service = _panel_service_from_app(app)
if panel_service is None:
return {
"enabled": False,
"config": None,
"source": "panel",
"error": "Panel service is unavailable",
}
try:
config_uuid = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or "").strip()
if not config_uuid:
config_uuid = await _default_panel_subscription_page_config_uuid(panel_service)
config_uuid = config_uuid or PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
detail = await panel_service.get_subscription_page_config_by_uuid(config_uuid)
if detail is None and config_uuid != PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
detail = await panel_service.get_subscription_page_config_by_uuid(
PANEL_DEFAULT_SUBPAGE_CONFIG_UUID
)
if detail is None:
raise SubscriptionGuidesConfigError(
f"Panel subscription page config {config_uuid} is unavailable"
)
config = validate_panel_subscription_guides_config(detail)
except (SubscriptionGuidesConfigError, Exception) as exc:
logger.warning("Failed to load subscription guides config from Remnawave Panel: %s", exc)
return {"enabled": False, "config": None, "source": "panel", "error": str(exc)}
return {"enabled": True, "config": config, "source": "panel", "error": None}
async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> str:
get_list = getattr(panel_service, "get_subscription_page_config_list", None)
if not callable(get_list):
return ""
payload = await get_list()
configs = (payload or {}).get("configs")
if not isinstance(configs, list):
return ""
candidates: list[Dict[str, Any]] = [item for item in configs if isinstance(item, dict)]
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid == PANEL_DEFAULT_SUBPAGE_CONFIG_UUID:
return uuid
candidates.sort(key=lambda item: int(item.get("viewPosition") or 0))
for item in candidates:
uuid = str(item.get("uuid") or "").strip()
if uuid:
return uuid
return ""
async def _public_subscription_payload(
request: web.Request,
share_token: str,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app)
raw_link = ""
username = ""
resolved_short_uuid = ""
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
local_sub = await subscription_dal.get_subscription_by_install_share_token(
session,
share_token,
)
if (
local_sub
and getattr(local_sub, "panel_user_uuid", None)
and _local_subscription_is_publicly_active(local_sub)
and panel_service
):
panel_user = await panel_service.get_user_by_uuid(local_sub.panel_user_uuid)
if panel_user:
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
username = str(panel_user.get("username") or "").strip()
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
display_link, connect_url = await prepare_config_links(settings, raw_link)
return {
"active": bool(display_link),
"config_link": display_link,
"connect_url": connect_url or display_link,
"panel_short_uuid": resolved_short_uuid or None,
"install_share_token": share_token,
"username": username,
"share_url": _public_install_url(request, share_token),
}
def _panel_service_from_app(app: web.Application) -> Any:
subscription_service: Optional[SubscriptionService] = app.get("subscription_service")
panel_service = (
getattr(subscription_service, "panel_service", None) if subscription_service else None
)
return panel_service or app.get("panel_service")
def _subscription_guides_settings_fingerprint(settings: Settings) -> Tuple[Any, ...]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "")
return (
bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True)),
bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", "") or ""),
str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_UUID", "") or ""),
hashlib.sha256(admin_json.encode("utf-8")).hexdigest(),
str(getattr(settings, "PANEL_API_URL", "") or ""),
bool(getattr(settings, "PANEL_API_KEY", "") or ""),
)
def _local_subscription_is_publicly_active(subscription: Any) -> bool:
end_date = getattr(subscription, "end_date", None)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
return bool(
getattr(subscription, "is_active", False)
and end_date
and end_date > datetime.now(timezone.utc)
)
def _public_install_url(request: web.Request, share_token: str) -> str:
settings: Settings = request.app["settings"]
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host")
or request.headers.get("Host")
or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
headers = request.headers
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
user_agent = headers.get(
"User-Agent",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
return {
"host": host,
"x-forwarded-host": host,
"x-forwarded-proto": proto,
"user-agent": user_agent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"upgrade-insecure-requests": "1",
}
+7
View File
@@ -6,6 +6,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route) app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route) app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route) app.router.add_get("/home", index_route)
app.router.add_get("/install", index_route)
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
app.router.add_get("/invite", index_route) app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route) app.router.add_get("/settings", index_route)
@@ -60,6 +62,11 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/bootstrap", bootstrap_route) app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/i18n", i18n_route) app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_route) app.router.add_get("/api/me", me_route)
app.router.add_get("/api/subscription-guides", subscription_guides_route)
app.router.add_get(
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
public_subscription_guides_route,
)
app.router.add_get("/api/account/avatar", account_avatar_route) app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route) app.router.add_post("/api/account/language", account_language_route)
app.router.add_post("/api/account/email/request", account_email_request_route) app.router.add_post("/api/account/email/request", account_email_request_route)
+70 -5
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001 # ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload from config.webapp_themes_config import public_themes_catalog_payload
@@ -52,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if db_user.panel_user_uuid if db_user.panel_user_uuid
else None else None
) )
install_share_token = (
await subscription_dal.ensure_install_share_token(session, local_sub)
if active and local_sub
else None
)
trial_available = bool( trial_available = bool(
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0 and settings.TRIAL_DURATION_DAYS > 0
@@ -82,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"language_code": lang, "language_code": lang,
"is_admin": is_admin, "is_admin": is_admin,
}, },
"subscription": _serialize_subscription(settings, active, local_sub, lang), "subscription": _serialize_subscription(
request,
settings,
active,
local_sub,
lang,
install_share_token=install_share_token,
),
"referral": { "referral": {
"code": referral_code, "code": referral_code,
"bot_link": referral_link, "bot_link": referral_link,
@@ -132,6 +145,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
"subscription_purchase_description": settings.subscription_purchase_description(lang), "subscription_purchase_description": settings.subscription_purchase_description(lang),
"subscription_guides_enabled": subscription_guides_available(settings),
"email_auth_enabled": settings.email_auth_configured, "email_auth_enabled": settings.email_auth_configured,
}, },
} }
@@ -179,11 +193,26 @@ def _build_webapp_referral_link(
def _serialize_subscription( def _serialize_subscription(
settings: Settings, request_or_settings: Any,
active: Optional[Dict[str, Any]], settings_or_active: Any,
local_sub: Optional[Any], active_or_local_sub: Optional[Any] = None,
lang: str, local_sub_or_lang: Optional[Any] = None,
lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if lang is None:
request = None
settings = request_or_settings
active = settings_or_active
local_sub = active_or_local_sub
lang = str(local_sub_or_lang or "ru")
else:
request = request_or_settings
settings = settings_or_active
active = active_or_local_sub
local_sub = local_sub_or_lang
if not active: if not active:
return { return {
"active": False, "active": False,
@@ -192,6 +221,9 @@ def _serialize_subscription(
"days_left": 0, "days_left": 0,
"config_link": None, "config_link": None,
"connect_url": None, "connect_url": None,
"panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None,
} }
end_date = active.get("end_date") end_date = active.get("end_date")
@@ -231,6 +263,10 @@ def _serialize_subscription(
can_topup_traffic = False can_topup_traffic = False
can_topup_devices = False can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
share_token = str(
install_share_token or getattr(local_sub, "install_share_token", "") or ""
).strip()
return { return {
"active": seconds_left > 0, "active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN", "status": active.get("status_from_panel") or "UNKNOWN",
@@ -240,6 +276,9 @@ def _serialize_subscription(
"remaining_text": _format_remaining(seconds_left, lang), "remaining_text": _format_remaining(seconds_left, lang),
"config_link": active.get("config_link"), "config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"), "connect_url": active.get("connect_button_url") or active.get("config_link"),
"panel_short_uuid": panel_short_uuid or None,
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
"install_share_url": _build_install_share_link(request, settings, share_token),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True), "traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
@@ -284,6 +323,32 @@ def _serialize_subscription(
} }
def _build_install_share_link(
request: Optional[web.Request],
settings: Settings,
share_token: str,
) -> Optional[str]:
share_token = subscription_dal.normalize_install_share_token(share_token)
if not share_token or request is None:
return None
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base:
parts = urlsplit(configured_base)
if parts.scheme and parts.netloc:
base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
base = configured_base.rstrip("/")
else:
host = (
request.headers.get("X-Forwarded-Host")
or request.headers.get("Host")
or request.host
)
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}"
return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _serialize_plans( def _serialize_plans(
settings: Settings, settings: Settings,
lang: str, lang: str,
+22
View File
@@ -16,6 +16,10 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings from config.settings import Settings
from .start import send_main_menu from .start import send_main_menu
@@ -160,12 +164,30 @@ async def process_promo_code_input(
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text, config_link=config_link_text,
) )
install_links = await ensure_user_install_guide_links(session, settings, user.id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
response_to_user_text = append_install_share_link_text(
response_to_user_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user.id,
)
install_share_url = None
reply_markup = get_connect_and_main_keyboard( reply_markup = get_connect_and_main_keyboard(
current_lang, current_lang,
i18n, i18n,
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
) )
else: else:
await session.commit() await session.commit()
+22
View File
@@ -23,6 +23,10 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback from bot.utils.callback_answer import safe_answer_callback
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings from config.settings import Settings
from db.dal import user_dal from db.dal import user_dal
@@ -715,6 +719,23 @@ async def start_command_handler(
), ),
config_link=config_link_text, config_link=config_link_text,
) )
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
promo_success_text = append_install_share_link_text(
promo_success_text,
_,
install_share_url,
)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for promo user %s.",
user_id,
)
install_share_url = None
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -726,6 +747,7 @@ async def start_command_handler(
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
), ),
parse_mode="HTML", parse_mode="HTML",
) )
+43 -1
View File
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription from db.models import Subscription
@@ -1026,12 +1030,50 @@ async def my_subscription_command_handler(
local_sub = await subscription_dal.get_active_subscription_by_user_id( local_sub = await subscription_dal.get_active_subscription_by_user_id(
session, event.from_user.id session, event.from_user.id
) )
install_links = await ensure_user_install_guide_links(
session,
settings,
event.from_user.id,
local_subscription=local_sub,
)
install_url = install_links.personal_url
install_share_url = install_links.public_share_url
if install_share_url:
try:
await session.commit()
text = append_install_share_link_text(text, get_text, install_share_url)
except Exception:
await session.rollback()
logging.exception(
"Failed to persist install guide share token for user %s.",
event.from_user.id,
)
install_share_url = None
# Build rows to prepend above the base "back" markup # Build rows to prepend above the base "back" markup
prepend_rows = [] prepend_rows = []
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app # 1) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display cfg_link_val = connect_button_url or config_link_display
if cfg_link_val: if install_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=install_url),
)
]
)
if install_share_url:
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("install_guide_share_button"),
url=install_share_url,
)
]
)
elif cfg_link_val:
prepend_rows.append( prepend_rows.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
@@ -14,6 +14,10 @@ from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from config.settings import Settings from config.settings import Settings
from .start import send_main_menu from .start import send_main_menu
@@ -74,6 +78,7 @@ async def request_trial_confirmation_handler(
config_link_display_for_trial = None config_link_display_for_trial = None
config_link_for_trial = None config_link_for_trial = None
connect_button_url_for_trial = None connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
@@ -104,6 +109,14 @@ async def request_trial_confirmation_handler(
traffic_gb=traffic_display, traffic_gb=traffic_display,
) )
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
# Send notification to admin about new trial # Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n) notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj) await notification_service.notify_trial_activation(user_id, end_date_obj)
@@ -139,6 +152,7 @@ async def request_trial_confirmation_handler(
settings, settings,
config_link_display_for_trial, config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial, connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
) )
if activation_result and activation_result.get("activated") if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard( else get_main_menu_inline_keyboard(
@@ -214,6 +228,7 @@ async def confirm_activate_trial_handler(
config_link_display_for_trial = None config_link_display_for_trial = None
config_link_for_trial = None config_link_for_trial = None
connect_button_url_for_trial = None connect_button_url_for_trial = None
install_share_url = None
if activation_result and activation_result.get("activated"): if activation_result and activation_result.get("activated"):
try: try:
@@ -243,6 +258,13 @@ async def confirm_activate_trial_handler(
config_link=config_link_for_trial, config_link=config_link_for_trial,
traffic_gb=traffic_display, traffic_gb=traffic_display,
) )
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
final_message_text_in_chat = append_install_share_link_text(
final_message_text_in_chat,
_,
install_share_url,
)
else: else:
message_key_from_service = ( message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed") activation_result.get("message_key", "trial_activation_failed")
@@ -266,6 +288,7 @@ async def confirm_activate_trial_handler(
settings, settings,
config_link_display_for_trial, config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial, connect_button_url=connect_button_url_for_trial,
install_share_url=install_share_url,
) )
if activation_result and activation_result.get("activated") if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard( else get_main_menu_inline_keyboard(
+18 -1
View File
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
from aiogram.types import InlineKeyboardMarkup, WebAppInfo from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.utils.install_links import bot_install_guide_url
from config.settings import Settings from config.settings import Settings
BOT_MENU_CONTEXT = "bot" BOT_MENU_CONTEXT = "bot"
@@ -689,13 +690,29 @@ def get_connect_and_main_keyboard(
config_link: Optional[str], config_link: Optional[str],
connect_button_url: Optional[str] = None, connect_button_url: Optional[str] = None,
preserve_message: bool = False, preserve_message: bool = False,
install_share_url: Optional[str] = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button.""" """Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
install_url = bot_install_guide_url(settings)
button_target = connect_button_url or config_link button_target = connect_button_url or config_link
if button_target: if install_url:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=install_url),
)
)
if install_share_url:
builder.row(
InlineKeyboardButton(
text=_("install_guide_share_button"),
url=install_share_url,
)
)
elif button_target:
builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target)) builder.row(InlineKeyboardButton(text=_("connect_button"), url=button_target))
elif settings.SUBSCRIPTION_MINI_APP_URL: elif settings.SUBSCRIPTION_MINI_APP_URL:
builder.row( builder.row(
@@ -11,6 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal from db.dal import payment_dal, user_dal
from db.models import Payment, User from db.models import Payment, User
@@ -136,6 +140,7 @@ async def send_success_message_to_user(
settings: Any, settings: Any,
config_link_display: Optional[str], config_link_display: Optional[str],
connect_button_url: Optional[str], connect_button_url: Optional[str],
install_share_url: Optional[str] = None,
include_keyboard: bool = True, include_keyboard: bool = True,
log_prefix: str = "payment_providers", log_prefix: str = "payment_providers",
) -> None: ) -> None:
@@ -148,6 +153,7 @@ async def send_success_message_to_user(
settings, settings,
config_link_display, config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
preserve_message=True, preserve_message=True,
) )
try: try:
@@ -332,6 +338,31 @@ async def finalize_successful_payment(
if req.text_prefix: if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}" success_text = f"{req.text_prefix}\n{success_text}"
install_share_url = None
if not req.skip_keyboard:
install_links = await ensure_user_install_guide_links(
req.session,
req.settings,
req.user_id,
)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await req.session.commit()
success_text = append_install_share_link_text(
success_text,
translator,
install_share_url,
)
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to persist install guide share token for user %s.",
req.log_prefix,
req.user_id,
)
install_share_url = None
await send_success_message_to_user( await send_success_message_to_user(
bot=req.bot, bot=req.bot,
user_id=req.user_id, user_id=req.user_id,
@@ -341,6 +372,7 @@ async def finalize_successful_payment(
settings=req.settings, settings=req.settings,
config_link_display=config_link_display, config_link_display=config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard, include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix, log_prefix=req.log_prefix,
) )
+15
View File
@@ -36,6 +36,10 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.request_security import ip_in_allowlist, request_client_ip from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal from db.dal import payment_dal, user_billing_dal, user_dal
@@ -741,6 +745,16 @@ async def process_successful_payment(
) )
) )
include_keyboard = True include_keyboard = True
install_share_url = None
if include_keyboard:
install_links = await ensure_user_install_guide_links(session, settings, user_id)
install_share_url = install_links.public_share_url
details_message = append_install_share_link_text(
details_message,
translator,
install_share_url,
)
await send_success_message_to_user( await send_success_message_to_user(
bot=bot, bot=bot,
user_id=user_id, user_id=user_id,
@@ -750,6 +764,7 @@ async def process_successful_payment(
settings=settings, settings=settings,
config_link_display=config_link_display, config_link_display=config_link_display,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=include_keyboard, include_keyboard=include_keyboard,
log_prefix="YooKassa webhook", log_prefix="YooKassa webhook",
) )
+48
View File
@@ -597,6 +597,54 @@ class PanelApiService:
return f"{base_sub_url}/{client_type.lower()}" return f"{base_sub_url}/{client_type.lower()}"
return base_sub_url return base_sub_url
async def get_subscription_page_config_by_short_uuid(
self,
short_uuid: str,
request_headers: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, Any]]:
if not short_uuid:
return None
endpoint = f"/subscriptions/subpage-config/{short_uuid}"
payload = {"requestHeaders": request_headers or {}}
response_data = await self._request(
"GET",
endpoint,
json=payload,
log_full_response=False,
)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config for short UUID {short_uuid}. Response: {response_data}" # noqa: E501
)
return None
async def get_subscription_page_config_list(self) -> Optional[Dict[str, Any]]:
endpoint = "/subscription-page-configs"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config list from panel. Response: {response_data}"
)
return None
async def get_subscription_page_config_by_uuid(
self,
config_uuid: str,
) -> Optional[Dict[str, Any]]:
config_uuid = str(config_uuid or "").strip()
if not config_uuid:
return None
endpoint = f"/subscription-page-configs/{config_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error"):
return response_data.get("response", response_data)
logging.error(
f"Failed to get subscription page config {config_uuid} from panel. Response: {response_data}" # noqa: E501
)
return None
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]: async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
if self._devices_cache.ttl_seconds <= 0: if self._devices_cache.ttl_seconds <= 0:
return await self._get_user_devices_uncached(user_uuid) return await self._get_user_devices_uncached(user_uuid)
@@ -763,6 +763,10 @@ class SubscriptionLifecycleMixin:
return { return {
"user_id": panel_user_data.get("uuid"), "user_id": panel_user_data.get("uuid"),
"panel_subscription_uuid": panel_user_data.get("subscriptionUuid")
or panel_user_data.get("shortUuid")
or (local_active_sub.panel_subscription_uuid if local_active_sub else None),
"panel_short_uuid": panel_user_data.get("shortUuid"),
"end_date": panel_end_date, "end_date": panel_end_date,
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link, "config_link": display_link,
+84
View File
@@ -0,0 +1,84 @@
"""Helpers for Telegram bot install-guide links."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.mini_app_url import (
subscription_mini_app_install_url,
subscription_public_install_url,
)
from config.subscription_guides_config import subscription_guides_available
from db.dal import subscription_dal
@dataclass(frozen=True)
class InstallGuideLinks:
personal_url: Optional[str] = None
public_share_url: Optional[str] = None
def bot_install_guides_enabled(settings: Any) -> bool:
return bool(
getattr(settings, "SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED", False)
and subscription_guides_available(settings)
and subscription_mini_app_install_url(settings)
)
def bot_install_guide_url(settings: Any) -> Optional[str]:
if not bot_install_guides_enabled(settings):
return None
return subscription_mini_app_install_url(settings)
async def ensure_user_install_guide_links(
session: AsyncSession,
settings: Any,
user_id: int,
panel_user_uuid: Optional[str] = None,
local_subscription: Optional[Any] = None,
) -> InstallGuideLinks:
personal_url = bot_install_guide_url(settings)
if not personal_url:
return InstallGuideLinks()
public_share_url = None
try:
local_sub = (
local_subscription
if local_subscription is not None
else await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
panel_user_uuid,
)
)
if local_sub is not None:
share_token = await subscription_dal.ensure_install_share_token(session, local_sub)
public_share_url = subscription_public_install_url(settings, share_token)
except Exception:
logging.exception("Failed to resolve install guide share link for user %s.", user_id)
return InstallGuideLinks(personal_url=personal_url, public_share_url=public_share_url)
def append_install_share_link_text(
text: str,
translator: Any,
public_share_url: Optional[str],
) -> str:
if not public_share_url:
return text
try:
share_line = translator(
"install_guide_share_link_line",
install_share_link=public_share_url,
)
except Exception:
share_line = f"\n\nInstall guide:\n<code>{public_share_url}</code>"
return f"{text}{share_line}"
+30 -1
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from config.settings import Settings from config.settings import Settings
from db.dal.subscription_dal import normalize_install_share_token
def append_query_params(base_url: str, params: dict[str, str]) -> str: def append_query_params(base_url: str, params: dict[str, str]) -> str:
@@ -31,3 +32,31 @@ def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[s
return None return None
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular" normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
return append_query_params(base, {"topup": normalized}) return append_query_params(base, {"topup": normalized})
def subscription_mini_app_path_url(settings: Settings, path: str) -> Optional[str]:
"""Return a Mini App URL with ``path`` appended to the configured app base."""
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not base:
return None
normalized_path = f"/{str(path or '').lstrip('/')}"
return f"{base.rstrip('/')}{normalized_path}"
def subscription_mini_app_install_url(settings: Settings) -> Optional[str]:
"""Return the personal embedded install guide URL."""
return subscription_mini_app_path_url(settings, "/install")
def subscription_public_install_url(settings: Settings, share_token: str) -> Optional[str]:
"""Return the public install guide URL for a normalized share token."""
token = normalize_install_share_token(share_token)
base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip()
if not token or not base:
return None
parts = urlsplit(base)
if parts.scheme and parts.netloc:
public_base = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
else:
public_base = base.rstrip("/")
return f"{public_base.rstrip('/')}/s/{quote(token)}"
File diff suppressed because one or more lines are too long
+29
View File
@@ -323,6 +323,35 @@ class Settings(BaseSettings):
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False) WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
SUBSCRIPTION_GUIDES_ENABLED: bool = Field(
default=True,
description="Show embedded install instructions inside the subscription Mini App.",
)
SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED: bool = Field(
default=True,
description=(
"Open Mini App install guides from Telegram bot connect buttons and show public "
"install guide share links."
),
)
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED: bool = Field(
default=True,
description=(
"Use Remnawave Panel Subscription Page config for embedded guides when available."
),
)
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED: bool = Field(
default=False,
description="Enable admin JSON override for embedded guides config.",
)
SUBSCRIPTION_PAGE_CONFIG_PATH: str = Field(
default="data/subpage-config/multiapp.json",
description="Path to Remnawave Subscription Page v1 JSON config for embedded guides.",
)
SUBSCRIPTION_PAGE_CONFIG_JSON: str = Field(
default="",
description="Admin-provided Remnawave Subscription Page v1 JSON config override.",
)
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
@@ -0,0 +1,669 @@
"""Loader and validator for Remnawave Subscription Page v1 configs."""
from __future__ import annotations
import copy
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
from urllib.parse import urlsplit
class SubscriptionGuidesConfigError(ValueError):
"""Raised when the embedded subscription guides config is invalid."""
APP_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG_PATH = "data/subpage-config/multiapp.json"
DEFAULT_CONFIG_BUNDLED_PATH = (
Path(__file__).resolve().parent / "defaults" / "subscription_page_multiapp.json"
)
ALLOWED_LOCALES = {
"az",
"be",
"de",
"en",
"es",
"fa",
"fr",
"hi",
"id",
"ja",
"pl",
"pt",
"ru",
"th",
"tk",
"tr",
"uk",
"uz",
"vi",
"zh",
}
ALLOWED_PLATFORMS = {
"android",
"androidTV",
"appleTV",
"ios",
"linux",
"macos",
"windows",
}
ALLOWED_BUTTON_TYPES = {"copyButton", "external", "subscriptionLink"}
BASE_TRANSLATION_KEYS = (
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
)
ALLOWED_SVG_COLORS = {
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
}
UI_SUBSCRIPTION_INFO_TYPES = {"cards", "collapsed", "expanded", "hidden"}
UI_INSTALLATION_GUIDE_TYPES = {"accordion", "cards", "minimal", "timeline"}
SVG_KEY_RE = re.compile(r"^[A-Za-z]+$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
UNSAFE_SVG_RE = re.compile(
r"(<\s*/?\s*(?:script|foreignObject|iframe|object|embed|image|use|style|a)\b)"
r"|(\son[a-z]+\s*=)"
r"|(javascript\s*:)"
r"|(data\s*:)",
re.IGNORECASE,
)
_CONFIG_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
PANEL_CONFIG_KEYS = (
"config",
"subscriptionPageConfig",
"subpageConfig",
"subPageConfig",
"pageConfig",
)
PANEL_WRAPPER_KEYS = ("response", "data", "result", *PANEL_CONFIG_KEYS)
def validate_subscription_guides_config_text(raw: str) -> Dict[str, Any]:
"""Parse and validate a v1 subscription guides config JSON string."""
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON: {exc.msg}") from exc
return validate_subscription_guides_config(payload)
def default_subscription_guides_config_text() -> str:
try:
return DEFAULT_CONFIG_BUNDLED_PATH.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(
f"Bundled default config is unavailable: {exc}"
) from exc
def resolve_subscription_guides_config_path(settings: Any) -> Path:
configured_path = str(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PATH", DEFAULT_CONFIG_PATH)
or DEFAULT_CONFIG_PATH
).strip()
if not configured_path:
raise SubscriptionGuidesConfigError("SUBSCRIPTION_PAGE_CONFIG_PATH is empty")
path = Path(configured_path)
if not path.is_absolute():
path = APP_ROOT / path
return path
def ensure_subscription_guides_config_file(settings: Any) -> Path:
path = resolve_subscription_guides_config_path(settings)
if not path.exists():
raise SubscriptionGuidesConfigError(f"Config file does not exist: {path}")
return path
def subscription_guides_admin_config_json(settings: Any) -> Tuple[str, str]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if admin_json:
return admin_json, "admin_json"
return "", "empty"
def load_subscription_guides_config(settings: Any) -> Tuple[Dict[str, Any], str]:
"""Load the enabled guides config from admin JSON or a configured file path."""
source, raw = _read_config_source(settings)
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
cache_key = (source, digest)
cached = _CONFIG_CACHE.get(cache_key)
if cached is not None:
return copy.deepcopy(cached), source
config = validate_subscription_guides_config_text(raw)
_CONFIG_CACHE[cache_key] = copy.deepcopy(config)
return config, source
def subscription_guides_status(settings: Any) -> Dict[str, Any]:
"""Return a safe status payload for user-facing guide availability checks."""
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return {"enabled": False, "config": None, "source": None, "error": None}
try:
config, source = load_subscription_guides_config(settings)
except SubscriptionGuidesConfigError as exc:
return {"enabled": False, "config": None, "source": None, "error": str(exc)}
return {"enabled": True, "config": config, "source": source, "error": None}
def subscription_guides_available(settings: Any) -> bool:
if not bool(getattr(settings, "SUBSCRIPTION_GUIDES_ENABLED", False)):
return False
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
if (
not (
admin_json
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False))
)
and bool(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED", True))
and getattr(settings, "PANEL_API_URL", None)
and getattr(settings, "PANEL_API_KEY", None)
):
return True
status = subscription_guides_status(settings)
return bool(status.get("enabled") and status.get("config"))
def extract_subscription_guides_config_from_panel(payload: Any) -> Any:
"""Extract Subscription Page v1 config from flexible Panel API response shapes."""
return _extract_config_candidate(payload, set())
def validate_panel_subscription_guides_config(
payload: Any,
*,
allow_default_when_missing: bool = False,
) -> Dict[str, Any]:
config = extract_subscription_guides_config_from_panel(payload)
if config is None:
if allow_default_when_missing and panel_subscription_page_allowed(payload):
default_text = default_subscription_guides_config_text()
return validate_subscription_guides_config_text(default_text)
raise SubscriptionGuidesConfigError("Panel response does not contain a v1 config")
return validate_subscription_guides_config(config)
def panel_subscription_page_allowed(payload: Any) -> bool:
candidate = _find_panel_response_object(payload, set())
return bool(candidate and candidate.get("webpageAllowed") is True)
def validate_subscription_guides_config(payload: Any) -> Dict[str, Any]:
if not isinstance(payload, Mapping):
raise SubscriptionGuidesConfigError("Config root must be an object")
if payload.get("version") != "1":
raise SubscriptionGuidesConfigError(
"Only Subscription Page config version '1' is supported"
)
locales = _validate_locales(payload.get("locales"))
svg_library = _validate_svg_library(payload.get("svgLibrary"))
branding = _validate_branding(payload.get("brandingSettings"))
ui_config = _validate_ui_config(payload.get("uiConfig"))
base_settings = _validate_base_settings(payload.get("baseSettings"))
base_translations = _validate_base_translations(payload.get("baseTranslations"), locales)
platforms = _validate_platforms(payload.get("platforms"), locales, svg_library)
return {
"version": "1",
"locales": locales,
"brandingSettings": branding,
"uiConfig": ui_config,
"baseSettings": base_settings,
"baseTranslations": base_translations,
"svgLibrary": svg_library,
"platforms": platforms,
}
def _read_config_source(settings: Any) -> Tuple[str, str]:
admin_json = str(getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON", "") or "").strip()
json_override_enabled = bool(
getattr(settings, "SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED", False)
)
if admin_json and json_override_enabled:
return "admin_json", admin_json
path = ensure_subscription_guides_config_file(settings)
try:
return "file", path.read_text(encoding="utf-8")
except OSError as exc:
raise SubscriptionGuidesConfigError(f"Failed to read config file: {exc}") from exc
def _extract_config_candidate(value: Any, seen: set[int]) -> Any:
if isinstance(value, str):
text = value.strip()
if not text.startswith(("{", "[")):
return None
try:
return _extract_config_candidate(json.loads(text), seen)
except json.JSONDecodeError as exc:
raise SubscriptionGuidesConfigError(f"Invalid JSON in Panel config: {exc.msg}") from exc
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if _looks_like_v1_config(value):
return value
for key in PANEL_WRAPPER_KEYS:
if key not in value:
continue
candidate = _extract_config_candidate(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _looks_like_v1_config(value: Mapping[str, Any]) -> bool:
return (
value.get("version") == "1"
and "locales" in value
and "svgLibrary" in value
and "platforms" in value
)
def _find_panel_response_object(value: Any, seen: set[int]) -> Optional[Mapping[str, Any]]:
if not isinstance(value, Mapping):
return None
value_id = id(value)
if value_id in seen:
return None
seen.add(value_id)
if "webpageAllowed" in value:
return value
for key in ("response", "data", "result"):
candidate = _find_panel_response_object(value.get(key), seen)
if candidate is not None:
return candidate
return None
def _validate_locales(value: Any) -> list[str]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError("locales must be a non-empty array")
locales: list[str] = []
for index, item in enumerate(value):
locale = str(item or "").strip()
if locale not in ALLOWED_LOCALES:
raise SubscriptionGuidesConfigError(f"Unsupported locale at locales[{index}]: {locale}")
if locale not in locales:
locales.append(locale)
return locales
def _validate_branding(value: Any) -> Dict[str, str]:
data = _require_object(value, "brandingSettings")
result = {
"title": _require_text(data, "title", "brandingSettings.title"),
"logoUrl": _require_text(data, "logoUrl", "brandingSettings.logoUrl"),
"supportUrl": _require_text(data, "supportUrl", "brandingSettings.supportUrl"),
}
_assert_http_url(result["logoUrl"], "brandingSettings.logoUrl")
_assert_http_url(result["supportUrl"], "brandingSettings.supportUrl")
return result
def _validate_ui_config(value: Any) -> Dict[str, str]:
data = _require_object(value, "uiConfig")
subscription_info = _require_text(
data,
"subscriptionInfoBlockType",
"uiConfig.subscriptionInfoBlockType",
)
installation_guides = _require_text(
data,
"installationGuidesBlockType",
"uiConfig.installationGuidesBlockType",
)
if subscription_info not in UI_SUBSCRIPTION_INFO_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.subscriptionInfoBlockType: {subscription_info}"
)
if installation_guides not in UI_INSTALLATION_GUIDE_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported uiConfig.installationGuidesBlockType: {installation_guides}"
)
return {
"subscriptionInfoBlockType": subscription_info,
"installationGuidesBlockType": installation_guides,
}
def _validate_base_settings(value: Any) -> Dict[str, Any]:
data = value if isinstance(value, Mapping) else {}
return {
"metaTitle": _optional_text(data, "metaTitle") or "Subscription",
"metaDescription": _optional_text(data, "metaDescription") or "Subscription",
"showConnectionKeys": bool(data.get("showConnectionKeys", False)),
"hideGetLinkButton": bool(data.get("hideGetLinkButton", False)),
}
def _validate_base_translations(value: Any, locales: Iterable[str]) -> Dict[str, Dict[str, str]]:
data = _require_object(value, "baseTranslations")
result: Dict[str, Dict[str, str]] = {}
for key in BASE_TRANSLATION_KEYS:
result[key] = _validate_locale_strings(
data.get(key),
locales,
f"baseTranslations.{key}",
)
return result
def _validate_svg_library(value: Any) -> Dict[str, str]:
data = _require_object(value, "svgLibrary")
if not data:
raise SubscriptionGuidesConfigError("svgLibrary must not be empty")
result: Dict[str, str] = {}
for key, raw_svg in data.items():
svg_key = str(key or "").strip()
if not SVG_KEY_RE.fullmatch(svg_key):
raise SubscriptionGuidesConfigError(f"Invalid svgLibrary key: {svg_key}")
result[svg_key] = _sanitize_svg(raw_svg, f"svgLibrary.{svg_key}")
return result
def _validate_platforms(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
) -> Dict[str, Dict[str, Any]]:
data = _require_object(value, "platforms")
if not data:
raise SubscriptionGuidesConfigError("platforms must not be empty")
result: Dict[str, Dict[str, Any]] = {}
for platform_key, raw_platform in data.items():
key = str(platform_key or "").strip()
if key not in ALLOWED_PLATFORMS:
raise SubscriptionGuidesConfigError(f"Unsupported platform: {key}")
platform = _require_object(raw_platform, f"platforms.{key}")
icon_key = _validate_svg_icon_key(
platform.get("svgIconKey"),
svg_library,
f"platforms.{key}.svgIconKey",
)
apps = _validate_apps(platform.get("apps"), locales, svg_library, f"platforms.{key}.apps")
result[key] = {
"displayName": _validate_localized_or_text(
platform.get("displayName"),
locales,
f"platforms.{key}.displayName",
),
"svgIconKey": icon_key,
"apps": apps,
}
return result
def _validate_apps(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
apps: list[Dict[str, Any]] = []
for index, raw_app in enumerate(value):
app_path = f"{path}[{index}]"
app = _require_object(raw_app, app_path)
name = _require_text(app, "name", f"{app_path}.name")
if len(name) < 2:
raise SubscriptionGuidesConfigError(f"{app_path}.name must contain at least 2 chars")
icon_key = _optional_svg_icon_key(
app.get("svgIconKey"),
svg_library,
f"{app_path}.svgIconKey",
)
apps.append(
{
"name": name,
"svgIconKey": icon_key,
"featured": bool(app.get("featured", False)),
"blocks": _validate_blocks(
app.get("blocks"),
locales,
svg_library,
f"{app_path}.blocks",
),
}
)
return apps
def _validate_blocks(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if not isinstance(value, list) or not value:
raise SubscriptionGuidesConfigError(f"{path} must be a non-empty array")
blocks: list[Dict[str, Any]] = []
for index, raw_block in enumerate(value):
block_path = f"{path}[{index}]"
block = _require_object(raw_block, block_path)
color = _optional_text(block, "svgIconColor")
if color and color not in ALLOWED_SVG_COLORS and not HEX_COLOR_RE.fullmatch(color):
raise SubscriptionGuidesConfigError(f"{block_path}.svgIconColor is invalid")
blocks.append(
{
"svgIconKey": _validate_svg_icon_key(
block.get("svgIconKey"),
svg_library,
f"{block_path}.svgIconKey",
),
"svgIconColor": color or "",
"title": _validate_locale_strings(
block.get("title"),
locales,
f"{block_path}.title",
),
"description": _validate_locale_strings(
block.get("description"),
locales,
f"{block_path}.description",
),
"buttons": _validate_buttons(
block.get("buttons"),
locales,
svg_library,
f"{block_path}.buttons",
),
}
)
return blocks
def _validate_buttons(
value: Any,
locales: Iterable[str],
svg_library: Mapping[str, str],
path: str,
) -> list[Dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise SubscriptionGuidesConfigError(f"{path} must be an array")
buttons: list[Dict[str, Any]] = []
for index, raw_button in enumerate(value):
button_path = f"{path}[{index}]"
button = _require_object(raw_button, button_path)
button_type = _require_text(button, "type", f"{button_path}.type")
if button_type not in ALLOWED_BUTTON_TYPES:
raise SubscriptionGuidesConfigError(
f"Unsupported button type at {button_path}: {button_type}"
)
link = _require_text(button, "link", f"{button_path}.link")
_validate_button_link(link, button_type, f"{button_path}.link")
buttons.append(
{
"type": button_type,
"link": link,
"text": _validate_locale_strings(
button.get("text"),
locales,
f"{button_path}.text",
),
"svgIconKey": _validate_svg_icon_key(
button.get("svgIconKey"),
svg_library,
f"{button_path}.svgIconKey",
),
}
)
return buttons
def _validate_locale_strings(value: Any, locales: Iterable[str], path: str) -> Dict[str, str]:
data = _require_object(value, path)
result: Dict[str, str] = {}
for locale in locales:
text = data.get(locale)
if not isinstance(text, str) or not text.strip():
raise SubscriptionGuidesConfigError(f"{path}.{locale} is required")
result[locale] = text.strip()
return result
def _validate_localized_or_text(
value: Any,
locales: Iterable[str],
path: str,
) -> str | Dict[str, str]:
if isinstance(value, str):
text = value.strip()
if text:
return text
return _validate_locale_strings(value, locales, path)
def _validate_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> str:
key = _string_value(value)
if not key:
raise SubscriptionGuidesConfigError(f"{path} is required")
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _optional_svg_icon_key(value: Any, svg_library: Mapping[str, str], path: str) -> Optional[str]:
key = _string_value(value)
if not key:
return None
if key not in svg_library:
raise SubscriptionGuidesConfigError(f"{path} references missing svgLibrary key: {key}")
return key
def _validate_button_link(link: str, _button_type: str, path: str) -> None:
_assert_safe_link(link, path)
def _assert_safe_link(value: str, path: str) -> None:
if CONTROL_CHARS_RE.search(value):
raise SubscriptionGuidesConfigError(f"{path} contains control characters")
lower = value.strip().lower()
if lower.startswith(("javascript:", "data:", "vbscript:")):
raise SubscriptionGuidesConfigError(f"{path} uses an unsafe URL scheme")
def _assert_http_url(value: str, path: str) -> None:
_assert_safe_link(value, path)
parts = urlsplit(value)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise SubscriptionGuidesConfigError(f"{path} must be an http(s) URL")
def _sanitize_svg(value: Any, path: str) -> str:
svg = _string_value(value)
if not svg:
raise SubscriptionGuidesConfigError(f"{path} is required")
trimmed = svg.strip()
if not trimmed.lower().startswith("<svg"):
raise SubscriptionGuidesConfigError(f"{path} must be an SVG document")
if UNSAFE_SVG_RE.search(trimmed):
raise SubscriptionGuidesConfigError(f"{path} contains unsafe SVG markup")
return trimmed
def _require_object(value: Any, path: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise SubscriptionGuidesConfigError(f"{path} must be an object")
return value
def _require_text(data: Mapping[str, Any], key: str, path: str) -> str:
value = _string_value(data.get(key))
if not value:
raise SubscriptionGuidesConfigError(f"{path} is required")
return value
def _optional_text(data: Mapping[str, Any], key: str) -> str:
return _string_value(data.get(key))
def _string_value(value: Any) -> str:
if not isinstance(value, str):
return ""
return value.strip()
+8 -1
View File
@@ -365,7 +365,11 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
except OSError: except OSError:
return True return True
if key == "light": if key == "light":
return "--success-text" not in style or ".theme-key-light.app-shell" not in style return (
"--success-text" not in style
or ".theme-key-light.app-shell" not in style
or "Install guide theme surfaces" not in style
)
if key == "ascii": if key == "ascii":
return ( return (
".theme-key-ascii" not in style ".theme-key-ascii" not in style
@@ -374,6 +378,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "ascii-boot-type" not in style or "ascii-boot-type" not in style
or "Console-style tables" not in style or "Console-style tables" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
) )
if key != "windows95": if key != "windows95":
return False return False
@@ -394,7 +399,9 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "::-webkit-slider-thumb" not in style or "::-webkit-slider-thumb" not in style
or "?v=9" not in style or "?v=9" not in style
or "lucide-life-buoy" not in style or "lucide-life-buoy" not in style
or "lucide-qr-code" not in style
or "New webapp surfaces: support, purchase info, password login" not in style or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons) or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
) )
+75
View File
@@ -1,4 +1,6 @@
import logging import logging
import re
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
from db.models import Subscription from db.models import Subscription
INSTALL_SHARE_TOKEN_BYTES = 16
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
model_columns = Subscription.__mapper__.columns.keys() model_columns = Subscription.__mapper__.columns.keys()
@@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid(
return result.scalar_one_or_none() return result.scalar_one_or_none()
def normalize_install_share_token(value: Any) -> str:
token = str(value or "").strip().lower()
if not re.fullmatch(r"[a-f0-9]{32}", token):
return ""
return token
async def get_subscription_by_install_share_token(
session: AsyncSession,
token: str,
) -> Optional[Subscription]:
normalized = normalize_install_share_token(token)
if not normalized:
return None
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def ensure_install_share_token(
session: AsyncSession,
subscription: Subscription,
) -> str:
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
if existing != getattr(subscription, "install_share_token", None):
subscription.install_share_token = existing
await session.flush()
return existing
subscription_id = getattr(subscription, "subscription_id", None)
for _attempt in range(10):
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
if await get_subscription_by_install_share_token(session, token):
continue
if subscription_id:
result = await session.execute(
update(Subscription)
.where(
Subscription.subscription_id == subscription_id,
or_(
Subscription.install_share_token.is_(None),
Subscription.install_share_token == "",
Subscription.install_share_token == raw_existing,
),
)
.values(install_share_token=token)
)
await session.flush()
if result.rowcount:
await session.refresh(subscription)
return normalize_install_share_token(
getattr(subscription, "install_share_token", None)
) or token
await session.refresh(subscription)
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
existing = normalize_install_share_token(raw_existing)
if existing:
return existing
continue
subscription.install_share_token = token
await session.flush()
await session.refresh(subscription)
return token
raise RuntimeError("Failed to generate a unique install share token")
async def get_active_subscriptions_for_user( async def get_active_subscriptions_for_user(
session: AsyncSession, user_id: int session: AsyncSession, user_id: int
) -> List[Subscription]: ) -> List[Subscription]:
+25
View File
@@ -882,6 +882,26 @@ def _migration_0026_add_lifetime_traffic_synced_at(connection: Connection) -> No
) )
def _migration_0027_add_subscription_install_share_token(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
if "install_share_token" not in columns:
connection.execute(
text("ALTER TABLE subscriptions ADD COLUMN install_share_token VARCHAR(32)")
)
connection.execute(
text(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscriptions_install_share_token
ON subscriptions (install_share_token)
WHERE install_share_token IS NOT NULL
"""
)
)
MIGRATIONS: List[Migration] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [
description="Track when lifetime traffic usage was last synced from panel", description="Track when lifetime traffic usage was last synced from panel",
upgrade=_migration_0026_add_lifetime_traffic_synced_at, upgrade=_migration_0026_add_lifetime_traffic_synced_at,
), ),
Migration(
id="0027_add_subscription_install_share_token",
description="Add stable public share tokens for install instructions",
upgrade=_migration_0027_add_subscription_install_share_token,
),
] ]
+1
View File
@@ -106,6 +106,7 @@ class Subscription(Base):
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
panel_user_uuid = Column(String, nullable=False, index=True) panel_user_uuid = Column(String, nullable=False, index=True)
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True) panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True)
install_share_token = Column(String(32), unique=True, index=True, nullable=True)
start_date = Column(DateTime(timezone=True), nullable=True) start_date = Column(DateTime(timezone=True), nullable=True)
end_date = Column(DateTime(timezone=True), nullable=False, index=True) end_date = Column(DateTime(timezone=True), nullable=False, index=True)
duration_months = Column(Integer, nullable=True) duration_months = Column(Integer, nullable=True)
+300 -2
View File
@@ -1,9 +1,12 @@
{ {
"name": "remnawave-tg-shop", "name": "frontend",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"qrcode": "^1.5.4"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
@@ -1847,6 +1850,30 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -1915,6 +1942,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/class-variance-authority": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -1928,6 +1964,17 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1938,6 +1985,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1984,6 +2049,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2028,6 +2102,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.21.0", "version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
@@ -2490,6 +2576,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2560,6 +2655,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3115,11 +3219,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3155,6 +3267,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
@@ -3339,6 +3460,38 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
@@ -3411,6 +3564,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3444,6 +3603,32 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/style-to-object": { "node_modules/style-to-object": {
"version": "1.0.14", "version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
@@ -3748,6 +3933,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -3758,6 +3949,113 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+3
View File
@@ -38,5 +38,8 @@
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"vite": "^8.0.12" "vite": "^8.0.12"
},
"dependencies": {
"qrcode": "^1.5.4"
} }
} }
+164 -6
View File
@@ -3,6 +3,7 @@
import { createAuthStore } from "./lib/webapp/stores/authStore.js"; import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js"; import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js"; import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
import { createInstallGuidesStore } from "./lib/webapp/stores/installGuidesStore.js";
import { createSupportStore } from "./lib/webapp/stores/supportStore.js"; import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
import { createAccountStore } from "./lib/webapp/stores/accountStore.js"; import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
import { Tooltip } from "$components/ui/primitives.js"; import { Tooltip } from "$components/ui/primitives.js";
@@ -15,6 +16,7 @@
import TariffDialogs from "./webapp/TariffDialogs.svelte"; import TariffDialogs from "./webapp/TariffDialogs.svelte";
import DevicesScreen from "./webapp/screens/DevicesScreen.svelte"; import DevicesScreen from "./webapp/screens/DevicesScreen.svelte";
import HomeScreen from "./webapp/screens/HomeScreen.svelte"; import HomeScreen from "./webapp/screens/HomeScreen.svelte";
import InstallGuideScreen from "./webapp/screens/InstallGuideScreen.svelte";
import InviteScreen from "./webapp/screens/InviteScreen.svelte"; import InviteScreen from "./webapp/screens/InviteScreen.svelte";
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte"; import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
import SupportScreen from "./webapp/screens/SupportScreen.svelte"; import SupportScreen from "./webapp/screens/SupportScreen.svelte";
@@ -71,6 +73,7 @@
adminSectionFromPath, adminSectionFromPath,
adminUserIdFromPath, adminUserIdFromPath,
normalizeSection, normalizeSection,
publicInstallTokenFromPath,
sectionFromPath, sectionFromPath,
supportTicketIdFromPath, supportTicketIdFromPath,
syncSectionPath, syncSectionPath,
@@ -99,6 +102,8 @@
let activeTab = "home"; let activeTab = "home";
let screen = "home"; let screen = "home";
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null; let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
let publicInstallSubscription = null;
let publicInstallToken = "";
let trialBusy = false; let trialBusy = false;
let promoCode = ""; let promoCode = "";
let promoBusy = false; let promoBusy = false;
@@ -182,6 +187,7 @@
}); });
const devicesStore = createDevicesStore({ api, t, showToast }); const devicesStore = createDevicesStore({ api, t, showToast });
const supportStore = createSupportStore({ api, t, showToast }); const supportStore = createSupportStore({ api, t, showToast });
const installGuidesStore = createInstallGuidesStore({ api, t, showToast });
const accountStore = createAccountStore({ const accountStore = createAccountStore({
api, api,
publicApi, publicApi,
@@ -207,6 +213,7 @@
setContext("billingStore", billingStore); setContext("billingStore", billingStore);
setContext("devicesStore", devicesStore); setContext("devicesStore", devicesStore);
setContext("supportStore", supportStore); setContext("supportStore", supportStore);
setContext("installGuidesStore", installGuidesStore);
setContext("accountStore", accountStore); setContext("accountStore", accountStore);
$: ({ $: ({
@@ -300,6 +307,7 @@
: plans; : plans;
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled); $: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true); $: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
$: installGuidesEnabled = Boolean(appSettings?.subscription_guides_enabled);
$: supportStore.setActive(Boolean(mode === "app" && screen === "support" && supportEnabled)); $: supportStore.setActive(Boolean(mode === "app" && screen === "support" && supportEnabled));
$: subscription = data?.subscription || DEV_MOCK.data.subscription; $: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean( $: hasActiveTariffSubscription = Boolean(
@@ -477,12 +485,29 @@
} }
} }
function canUseInstallGuides(settings = appSettings, sub = subscription) {
const enabled =
settings === appSettings
? installGuidesEnabled
: Boolean(settings?.subscription_guides_enabled);
return Boolean(enabled && sub?.active);
}
onMount(() => { onMount(() => {
if (isPreviewBoard) return; if (isPreviewBoard) return;
const onAnyPointerDown = () => { const onAnyPointerDown = () => {
if (mode === "login") loginEmailTooltipOpen = false; if (mode === "login") loginEmailTooltipOpen = false;
}; };
const onPopState = () => { const onPopState = () => {
const shareToken = publicInstallTokenFromPath(window.location.pathname);
if (shareToken) {
void loadPublicInstall(shareToken);
return;
}
if (mode === "publicInstall") {
void boot();
return;
}
const section = sectionFromPath(window.location.pathname); const section = sectionFromPath(window.location.pathname);
if (mode === "login") { if (mode === "login") {
setPasswordLoginMode(isPasswordLoginPath(), true); setPasswordLoginMode(isPasswordLoginPath(), true);
@@ -509,14 +534,17 @@
? "home" ? "home"
: section === "support" && !supportEnabled : section === "support" && !supportEnabled
? "home" ? "home"
: section; : section === "install" && !canUseInstallGuides()
activeTab = nextSection; ? "home"
: section;
activeTab = nextSection === "install" ? "home" : nextSection;
screen = nextSection; screen = nextSection;
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled); if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
if (nextSection === "support") { if (nextSection === "support") {
supportStore.loadList(); supportStore.loadList();
supportStore.startPolling({ includeList: true }); supportStore.startPolling({ includeList: true });
} }
if (nextSection === "install") installGuidesStore.load(true);
} }
}; };
window.addEventListener("popstate", onPopState); window.addEventListener("popstate", onPopState);
@@ -701,12 +729,12 @@
await appendStylesheetWithFallback( await appendStylesheetWithFallback(
"subscription-webapp-admin-css", "subscription-webapp-admin-css",
cssHref, cssHref,
"subscription_webapp_admin.css", "subscription_webapp_admin.css"
); );
await appendScriptWithFallback( await appendScriptWithFallback(
"subscription-webapp-admin-js", "subscription-webapp-admin-js",
jsSrc, jsSrc,
"subscription_webapp_admin.js", "subscription_webapp_admin.js"
); );
const loaded = readAdminBundleApi(); const loaded = readAdminBundleApi();
if (!loaded) throw new Error("admin_bundle_missing_mount"); if (!loaded) throw new Error("admin_bundle_missing_mount");
@@ -776,6 +804,11 @@
} }
async function boot() { async function boot() {
const shareToken = publicInstallTokenFromPath(window.location.pathname);
if (shareToken) {
await loadPublicInstall(shareToken);
return;
}
await runWebappBoot({ await runWebappBoot({
MOCK, MOCK,
setMode: (next) => { setMode: (next) => {
@@ -866,6 +899,12 @@
if (section === "support" && payload.settings?.support_tickets_enabled === false) { if (section === "support" && payload.settings?.support_tickets_enabled === false) {
section = "home"; section = "home";
} }
if (
section === "install" &&
!(payload.settings?.subscription_guides_enabled && payload.subscription?.active)
) {
section = "home";
}
const initialAdminSection = const initialAdminSection =
section === "admin" ? adminSectionFromPath(window.location.pathname) : null; section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
if (section === "admin" && payload.user?.is_admin) { if (section === "admin" && payload.user?.is_admin) {
@@ -881,7 +920,7 @@
} }
const initialSupportTicketId = const initialSupportTicketId =
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null; section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
activeTab = section === "admin" ? "settings" : section; activeTab = section === "admin" ? "settings" : section === "install" ? "home" : section;
screen = section; screen = section;
mode = "app"; mode = "app";
if (payload.settings?.support_tickets_enabled !== false) { if (payload.settings?.support_tickets_enabled !== false) {
@@ -907,6 +946,9 @@
if (section === "devices" && payload.settings?.my_devices_enabled) { if (section === "devices" && payload.settings?.my_devices_enabled) {
await devicesStore.loadDevices(true); await devicesStore.loadDevices(true);
} }
if (section === "install") {
await installGuidesStore.load(true);
}
if (section === "support") { if (section === "support") {
if (initialSupportTicketId) if (initialSupportTicketId)
await supportStore.openTicket(initialSupportTicketId, { skipPush: true }); await supportStore.openTicket(initialSupportTicketId, { skipPush: true });
@@ -947,6 +989,19 @@
} }
} }
async function loadPublicInstall(shareToken) {
mode = "publicInstall";
screen = "install";
activeTab = "home";
publicInstallToken = shareToken;
publicInstallSubscription = {
install_share_token: shareToken,
share_url: typeof window !== "undefined" ? `${window.location.origin}/s/${shareToken}` : "",
};
const response = await installGuidesStore.loadPublic(shareToken, true);
publicInstallSubscription = response?.subscription || publicInstallSubscription;
}
function showLogin() { function showLogin() {
mode = "login"; mode = "login";
screen = "login"; screen = "login";
@@ -1002,6 +1057,44 @@
window.location.assign(url); window.location.assign(url);
} }
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function openAppLink(url) {
const raw = String(url || "").trim();
if (!raw || hasControlChars(raw) || /^(javascript|data|vbscript):/i.test(raw)) {
return;
}
if (/^https?:\/\//i.test(raw)) {
openExternalLink(raw);
return;
}
if (/^tg:\/\//i.test(raw) && tg?.openTelegramLink) {
try {
tg.openTelegramLink(raw);
return;
} catch {
// Fall back to the generic deeplink path below.
}
}
try {
const anchor = document.createElement("a");
anchor.href = raw;
anchor.target = "_self";
anchor.rel = "noreferrer";
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
} catch {
window.location.assign(raw);
}
}
function openConnectLink() { function openConnectLink() {
const url = subscription?.connect_url || subscription?.config_link; const url = subscription?.connect_url || subscription?.config_link;
if (!url) { if (!url) {
@@ -1011,6 +1104,23 @@
openExternalLink(url); openExternalLink(url);
} }
function openPublicConnectLink() {
const url = publicInstallSubscription?.connect_url || publicInstallSubscription?.config_link;
if (!url) {
showToast(t("wa_connect_link_unavailable"));
return;
}
openExternalLink(url);
}
function openInstallOrConnect() {
if (canUseInstallGuides()) {
goInstall();
return;
}
openConnectLink();
}
async function copyText(value, success = t("wa_copied")) { async function copyText(value, success = t("wa_copied")) {
if (!value) { if (!value) {
showToast(t("wa_unavailable")); showToast(t("wa_unavailable"));
@@ -1092,6 +1202,18 @@
syncSectionPath("home"); syncSectionPath("home");
} }
function goInstall() {
if (!canUseInstallGuides()) {
openConnectLink();
return;
}
billingStore.closePaymentModal();
activeTab = "home";
screen = "install";
syncSectionPath("install");
installGuidesStore.load(true);
}
function goInvite() { function goInvite() {
billingStore.closePaymentModal(); billingStore.closePaymentModal();
activeTab = "invite"; activeTab = "invite";
@@ -1228,6 +1350,7 @@
async function handleAdminPersistedSaved(options = {}) { async function handleAdminPersistedSaved(options = {}) {
invalidateWebappTariffOptionCaches(billingStore); invalidateWebappTariffOptionCaches(billingStore);
installGuidesStore.reset();
try { try {
await loadData(); await loadData();
} catch { } catch {
@@ -1277,6 +1400,28 @@
<BrandMark {brand} size="md" /> <BrandMark {brand} size="md" />
<div>{t("wa_loading")}</div> <div>{t("wa_loading")}</div>
</div> </div>
{:else if mode === "publicInstall"}
<div class="public-install-shell">
<a class="public-install-brand" href="/" aria-label={brandTitle}>
<BrandMark {brand} />
<strong>{brandTitle}</strong>
</a>
<InstallGuideScreen
{currentLang}
telegramPlatform={tg?.platform || ""}
user={{}}
subscription={publicInstallSubscription || {
install_share_token: publicInstallToken,
}}
{goHome}
openConnectLink={openPublicConnectLink}
{openExternalLink}
{openAppLink}
{copyText}
{t}
publicMode
/>
</div>
{:else if mode === "login"} {:else if mode === "login"}
<AuthScreen <AuthScreen
{screen} {screen}
@@ -1365,7 +1510,7 @@
{trafficMode} {trafficMode}
{trialBusy} {trialBusy}
{activateTrial} {activateTrial}
{openConnectLink} openConnectLink={openInstallOrConnect}
{openPaymentModal} {openPaymentModal}
{openRegularTopupModal} {openRegularTopupModal}
{openPremiumTopupModal} {openPremiumTopupModal}
@@ -1373,6 +1518,19 @@
{primaryPayActionLabel} {primaryPayActionLabel}
{t} {t}
/> />
{:else if screen === "install"}
<InstallGuideScreen
{currentLang}
telegramPlatform={tg?.platform || ""}
{user}
{subscription}
{goHome}
{openConnectLink}
{openExternalLink}
{openAppLink}
{copyText}
{t}
/>
{:else if screen === "invite"} {:else if screen === "invite"}
<InviteScreen <InviteScreen
{referral} {referral}
@@ -1,5 +1,14 @@
<script> <script>
import { Check, ChevronRight, Copy, Eye, EyeOff, Search, X } from "$components/ui/icons.js"; import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
FileText,
Search,
X,
} from "$components/ui/icons.js";
import * as UiIcons from "$components/ui/icons.js"; import * as UiIcons from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js"; import { Accordion, Switch } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
@@ -131,6 +140,17 @@
closeIconPicker(); closeIconPicker();
} }
async function handleJsonFile(field, event) {
const file = event?.currentTarget?.files?.[0];
if (!file) return;
try {
const text = await file.text();
settingsStore.markDirty(field.key, text);
} finally {
event.currentTarget.value = "";
}
}
function normalizeWebhookPath(path) { function normalizeWebhookPath(path) {
const normalized = String(path || "").trim(); const normalized = String(path || "").trim();
if (!normalized) return ""; if (!normalized) return "";
@@ -227,6 +247,7 @@
notifications: "Уведомления", notifications: "Уведомления",
support: "Поддержка", support: "Поддержка",
devices: "Устройства", devices: "Устройства",
subscription_guides: "Connection guides",
}; };
return adminText(`settings_section_${id}`, {}, map[id] || id); return adminText(`settings_section_${id}`, {}, map[id] || id);
} }
@@ -264,7 +285,9 @@
function fieldPlaceholderText(field) { function fieldPlaceholderText(field) {
const fallback = field.placeholder || ""; const fallback = field.placeholder || "";
return field.i18n_placeholder_key ? adminText(field.i18n_placeholder_key, {}, fallback) : fallback; return field.i18n_placeholder_key
? adminText(field.i18n_placeholder_key, {}, fallback)
: fallback;
} }
function subsectionTitle(group) { function subsectionTitle(group) {
@@ -419,6 +442,41 @@
value={valueFor(field) ?? ""} value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea> ></textarea>
{:else if field.type === "json"}
<div class="admin-json-toolbar">
<input
id={"json-file-" + field.key}
class="admin-json-file-input"
type="file"
accept="application/json,.json"
onchange={(event) => handleJsonFile(field, event)}
/>
<label
class="admin-btn admin-btn-sm admin-btn-ghost admin-json-upload"
for={"json-file-" + field.key}
>
<FileText size={13} />
{at("settings_json_upload", {}, "Load .json")}
</label>
{#if valueFor(field)}
<AdminButton
size="sm"
variant="ghost"
onclick={() => settingsStore.markDirty(field.key, "")}
>
<X size={12} />
{at("clear", {}, "Clear")}
</AdminButton>
{/if}
</div>
<textarea
class="admin-setting-textarea admin-setting-json-textarea"
rows="10"
spellcheck="false"
placeholder={fieldPlaceholderText(field)}
value={valueFor(field) ?? ""}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
></textarea>
{:else if field.secret} {:else if field.secret}
<input <input
class="input" class="input"
+2
View File
@@ -38,6 +38,7 @@ export {
Menu, Menu,
MessageSquare, MessageSquare,
MessageSquarePlus, MessageSquarePlus,
Monitor,
MousePointerClick, MousePointerClick,
Paintbrush, Paintbrush,
Plus, Plus,
@@ -50,6 +51,7 @@ export {
Send, Send,
Server, Server,
Settings, Settings,
Share2,
Shield, Shield,
Sliders, Sliders,
Smartphone, Smartphone,
+1
View File
@@ -20,6 +20,7 @@ export const LANGUAGE_FLAGS = {
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"]; export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
export const APP_SECTION_PATHS = { export const APP_SECTION_PATHS = {
home: "/home", home: "/home",
install: "/install",
invite: "/invite", invite: "/invite",
devices: "/devices", devices: "/devices",
support: "/support", support: "/support",
+11
View File
@@ -604,6 +604,17 @@ export async function mockApi(path, options = {}, context = {}) {
} }
if (cleanPath === "/support/unread") return { ok: true, unread: 1 }; if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
if (path === "/me") return clone(DEV_MOCK.data); if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides);
if (cleanPath.startsWith("/subscription-guides/public/")) {
const shareToken = decodeURIComponent(cleanPath.split("/").pop() || "");
const subscription = clone(DEV_MOCK.data.subscription);
subscription.install_share_token = shareToken;
subscription.share_url = `${window.location.origin}/s/${shareToken}`;
return {
...clone(DEV_MOCK.data.subscription_guides),
subscription,
};
}
if (path === "/auth/email/request") return { ok: true }; if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") { if (path === "/auth/email/verify" || path === "/auth/email/magic") {
return { ok: true, csrf_token: "local-preview-csrf" }; return { ok: true, csrf_token: "local-preview-csrf" };
+195
View File
@@ -23,6 +23,181 @@ const ASCII_THEME = {
}, },
}; };
const INSTALL_GUIDES_CONFIG = {
version: "1",
locales: ["ru", "en"],
brandingSettings: {
title: "/minishop",
logoUrl: "https://example.com/logo.svg",
supportUrl: "https://t.me/support",
},
uiConfig: {
subscriptionInfoBlockType: "collapsed",
installationGuidesBlockType: "cards",
},
baseSettings: {
metaTitle: "Subscription",
metaDescription: "Subscription",
showConnectionKeys: false,
hideGetLinkButton: false,
},
baseTranslations: Object.fromEntries(
[
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
].map((key) => [
key,
{
ru: key === "installationGuideHeader" ? "Установка и настройка" : key,
en: key === "installationGuideHeader" ? "Install and configure" : key,
},
])
),
svgLibrary: {
App: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="5" y="3" width="14" height="18" rx="3"/><path d="M9 7h6M9 17h6"/></svg>',
Copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="8" y="8" width="10" height="10" rx="2"/><path d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
Desktop:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
Download:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>',
Phone:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>',
},
platforms: {
ios: {
displayName: "iOS",
svgIconKey: "Phone",
apps: [
{
name: "Streisand",
svgIconKey: "App",
featured: true,
blocks: [
{
svgIconKey: "Download",
svgIconColor: "green",
title: { ru: "Установите приложение", en: "Install the app" },
description: {
ru: "Откройте App Store и установите клиент.",
en: "Open the App Store and install the client.",
},
buttons: [
{
type: "external",
link: "https://apps.apple.com/app/streisand/id6450534064",
text: { ru: "Открыть App Store", en: "Open App Store" },
svgIconKey: "Download",
},
{
type: "subscriptionLink",
link: "streisand://import/{{SUBSCRIPTION_LINK}}",
text: { ru: "Импортировать", en: "Import" },
svgIconKey: "App",
},
{
type: "copyButton",
link: "{{SUBSCRIPTION_LINK}}",
text: { ru: "Скопировать ссылку", en: "Copy link" },
svgIconKey: "Copy",
},
],
},
],
},
],
},
android: {
displayName: "Android",
svgIconKey: "Phone",
apps: [
{
name: "Happ",
svgIconKey: "App",
featured: true,
blocks: [
{
svgIconKey: "Download",
svgIconColor: "emerald",
title: { ru: "Установите Happ", en: "Install Happ" },
description: {
ru: "Загрузите приложение и добавьте подписку по ссылке.",
en: "Install the app and add the subscription link.",
},
buttons: [
{
type: "external",
link: "https://play.google.com/store/apps/details?id=com.happproxy",
text: { ru: "Открыть Google Play", en: "Open Google Play" },
svgIconKey: "Download",
},
{
type: "copyButton",
link: "{{SUBSCRIPTION_LINK}}",
text: { ru: "Скопировать ссылку", en: "Copy link" },
svgIconKey: "Copy",
},
],
},
],
},
],
},
windows: {
displayName: "Windows",
svgIconKey: "Desktop",
apps: [
{
name: "Hiddify",
svgIconKey: "Desktop",
featured: true,
blocks: [
{
svgIconKey: "Download",
svgIconColor: "sky",
title: { ru: "Установите клиент", en: "Install the client" },
description: {
ru: "Скачайте приложение и импортируйте ссылку подписки.",
en: "Download the client and import the subscription link.",
},
buttons: [
{
type: "external",
link: "https://github.com/hiddify/hiddify-app/releases",
text: { ru: "Открыть релизы", en: "Open releases" },
svgIconKey: "Download",
},
{
type: "copyButton",
link: "{{SUBSCRIPTION_LINK}}",
text: { ru: "Скопировать ссылку", en: "Copy link" },
svgIconKey: "Copy",
},
],
},
],
},
],
},
},
};
export const DEV_MOCK = { export const DEV_MOCK = {
config: { config: {
title: "/minishop", title: "/minishop",
@@ -103,6 +278,9 @@ export const DEV_MOCK = {
days_left: 25, days_left: 25,
config_link: "https://sub.example.com/sub/preview-token", config_link: "https://sub.example.com/sub/preview-token",
connect_url: "https://sub.example.com/connect/preview-token", connect_url: "https://sub.example.com/connect/preview-token",
panel_short_uuid: "preview-token",
install_share_token: "8f559061460e8fede78ef18dce887236",
install_share_url: "https://app.example.com/s/8f559061460e8fede78ef18dce887236",
traffic_used: "18.4 GB", traffic_used: "18.4 GB",
traffic_limit: "100 GB", traffic_limit: "100 GB",
traffic_used_bytes: 19756849561, traffic_used_bytes: 19756849561,
@@ -120,6 +298,12 @@ export const DEV_MOCK = {
can_topup_premium_traffic: true, can_topup_premium_traffic: true,
max_devices: 5, max_devices: 5,
}, },
subscription_guides: {
ok: true,
enabled: true,
config: INSTALL_GUIDES_CONFIG,
source: "mock",
},
devices: { devices: {
ok: true, ok: true,
enabled: true, enabled: true,
@@ -227,6 +411,7 @@ export const DEV_MOCK = {
trial_traffic_strategy: "NO_RESET", trial_traffic_strategy: "NO_RESET",
subscription_purchase_description: subscription_purchase_description:
"Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.", "Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.",
subscription_guides_enabled: true,
email_auth_enabled: true, email_auth_enabled: true,
}, },
}, },
@@ -250,6 +435,16 @@ export function applyPreviewMock(kind) {
return; return;
} }
if (mode === "guides" || mode === "install") {
DEV_MOCK.data.settings.subscription_guides_enabled = true;
DEV_MOCK.data.subscription_guides = {
...DEV_MOCK.data.subscription_guides,
enabled: true,
config: INSTALL_GUIDES_CONFIG,
};
return;
}
if (mode === "traffic") { if (mode === "traffic") {
DEV_MOCK.data.settings.traffic_mode = true; DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false; DEV_MOCK.data.settings.trial_available = false;
+9
View File
@@ -6,6 +6,7 @@ export function normalizeSection(value) {
.toLowerCase(); .toLowerCase();
if ( if (
section === "invite" || section === "invite" ||
section === "install" ||
section === "devices" || section === "devices" ||
section === "support" || section === "support" ||
section === "settings" || section === "settings" ||
@@ -28,6 +29,14 @@ export function sectionFromPath(pathname) {
return normalizeSection(section); return normalizeSection(section);
} }
export function publicInstallTokenFromPath(pathname) {
const normalized = String(pathname || "")
.trim()
.replace(/\/+$/, "");
const match = normalized.match(/^\/s\/([a-f0-9]{32})$/i);
return match ? match[1].toLowerCase() : "";
}
export function adminSectionFromPath(pathname) { export function adminSectionFromPath(pathname) {
const normalized = String(pathname || "") const normalized = String(pathname || "")
.toLowerCase() .toLowerCase()
@@ -0,0 +1,96 @@
import { writable } from "svelte/store";
export function createInstallGuidesStore({ api, t, showToast }) {
let inFlight = null;
const state = writable({
enabled: false,
config: null,
source: null,
subscription: null,
error: "",
loading: false,
loaded: false,
});
async function fetchGuides(path, force = false) {
if (inFlight?.path === path) return inFlight.promise;
let snapshot;
state.update((s) => {
snapshot = s;
return s;
});
if (!force && snapshot?.loaded) return snapshot;
const promise = (async () => {
state.update((s) => ({
...s,
loading: true,
loaded: force ? false : s.loaded,
error: "",
}));
try {
const response = await api(path);
const next = {
enabled: Boolean(response?.enabled),
config: response?.config || null,
source: response?.source || null,
subscription: response?.subscription || null,
error: response?.error || "",
loading: false,
loaded: true,
};
state.set(next);
return next;
} catch (error) {
const message =
error?.message || t("wa_install_unavailable", {}, "Instructions unavailable");
if (typeof showToast === "function") showToast(message);
const next = {
enabled: false,
config: null,
source: null,
subscription: null,
error: message,
loading: false,
loaded: true,
};
state.set(next);
return next;
} finally {
inFlight = null;
}
})();
inFlight = { path, promise };
return promise;
}
async function load(force = false) {
return fetchGuides("/subscription-guides", force);
}
async function loadPublic(shareToken, force = false) {
const encoded = encodeURIComponent(String(shareToken || ""));
return fetchGuides(`/subscription-guides/public/${encoded}`, force);
}
function reset() {
inFlight = null;
state.set({
enabled: false,
config: null,
source: null,
subscription: null,
error: "",
loading: false,
loaded: false,
});
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
load,
loadPublic,
reset,
};
}
+25
View File
@@ -233,6 +233,31 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
} }
.admin-setting-control .admin-setting-json-textarea {
min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.admin-json-toolbar {
display: flex;
flex: 1 1 100%;
align-items: center;
gap: 8px;
}
.admin-json-file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.admin-json-upload {
cursor: pointer;
}
.admin-btn.admin-btn-icon { .admin-btn.admin-btn-icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
+47
View File
@@ -26,6 +26,33 @@ a {
padding-bottom: 86px; padding-bottom: 86px;
} }
.public-install-shell {
width: min(100%, 440px);
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));
}
.public-install-brand {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
box-sizing: border-box;
padding: 0 16px;
color: var(--text);
text-decoration: none;
}
.public-install-brand strong {
color: var(--accent);
font-size: 15px;
line-height: 1.2;
}
.loader { .loader {
display: grid; display: grid;
min-height: 100dvh; min-height: 100dvh;
@@ -2269,6 +2296,26 @@ a {
margin-right: auto; margin-right: auto;
} }
.public-install-shell {
width: auto;
max-width: none;
min-height: 100dvh;
margin: 0;
overflow-x: visible;
padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px;
}
.public-install-shell > .public-install-brand,
.public-install-shell > main {
max-width: 1080px;
margin-left: auto;
margin-right: auto;
}
.public-install-shell > .public-install-brand {
width: min(100%, 1080px);
}
/* Auth screen: keep it as a focused card in the centre, hide rail. */ /* Auth screen: keep it as a focused card in the centre, hide rail. */
.phone-screen.auth-screen { .phone-screen.auth-screen {
width: min(100%, 460px); width: min(100%, 460px);
+1 -1
View File
@@ -23,7 +23,7 @@
</script> </script>
<div class="phone-screen" class:home-screen={screen === "home"}> <div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "support" || screen === "settings"} {#if screen === "install" || screen === "invite" || screen === "devices" || screen === "support" || screen === "settings"}
<header class="app-header accent-title"> <header class="app-header accent-title">
<div class="brand-row"> <div class="brand-row">
<BrandMark {brand} /> <BrandMark {brand} />
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -50,6 +50,8 @@
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
"back_to_payment_methods_button": "⬅️ Back", "back_to_payment_methods_button": "⬅️ Back",
"connect_button": "🔗 Connect", "connect_button": "🔗 Connect",
"install_guide_share_button": "🔗 Share install guide",
"install_guide_share_link_line": "\n\nInstall guide for sharing:\n<code>{install_share_link}</code>",
"cancel_button": "❌ Cancel", "cancel_button": "❌ Cancel",
"devices_button": "📱 My Devices ({current_devices}/{max_devices})", "devices_button": "📱 My Devices ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>", "my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
@@ -1600,5 +1602,35 @@
"admin_sort_updated_desc": "Newest activity", "admin_sort_updated_desc": "Newest activity",
"admin_sort_updated_asc": "Oldest activity", "admin_sort_updated_asc": "Oldest activity",
"admin_sort_created_desc": "Newest created", "admin_sort_created_desc": "Newest created",
"admin_sort_created_asc": "Oldest created" "admin_sort_created_asc": "Oldest created",
"wa_install_title": "Install and configure",
"wa_install_subtitle": "Choose your platform and app.",
"wa_install_platform": "Platform",
"wa_install_app": "App",
"wa_install_loading": "Loading instructions...",
"wa_install_unavailable": "Instructions are unavailable.",
"wa_install_featured": "Recommended",
"wa_install_subscription_link": "Subscription link",
"wa_install_subscription_link_hint": "Scan the QR code or copy the link.",
"wa_install_qr_alt": "Subscription QR code",
"wa_install_copy_subscription_link": "Copy link",
"wa_install_link_copied": "Link copied",
"wa_install_share": "Share",
"wa_install_share_copied": "Install guide link copied",
"admin_settings_section_subscription_guides": "Install guides",
"admin_settings_field_subscription_guides_enabled_label": "Embedded install guides",
"admin_settings_field_subscription_guides_enabled_description": "Open install instructions inside the Web App instead of an external connect page.",
"admin_settings_field_subscription_guides_bot_menu_enabled_label": "Open install guides from bot",
"admin_settings_field_subscription_guides_bot_menu_enabled_description": "Use the Telegram Mini App install screen for bot connect buttons and show public install guide links.",
"admin_settings_field_subscription_page_config_panel_enabled_label": "Use Remnawave Panel config",
"admin_settings_field_subscription_page_config_panel_enabled_description": "Fetch Subscription Page config from Remnawave Panel by the user's subscription short UUID.",
"admin_settings_field_subscription_page_config_json_override_enabled_label": "Enable admin JSON override",
"admin_settings_field_subscription_page_config_json_override_enabled_description": "Use the JSON field below instead of Remnawave Panel config. Disabled by default.",
"admin_settings_field_subscription_page_config_path_label": "Subscription Page config path",
"admin_settings_field_subscription_page_config_path_description": "Fallback path to a Remnawave Subscription Page v1 JSON config file.",
"admin_settings_field_subscription_page_config_path_placeholder": "data/subpage-config/multiapp.json",
"admin_settings_field_subscription_page_config_json_label": "Subscription Page config JSON",
"admin_settings_field_subscription_page_config_json_description": "Optional admin JSON override. It is applied only when the JSON override switch is enabled.",
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
"admin_settings_json_upload": "Load .json"
} }
+33 -1
View File
@@ -50,6 +50,8 @@
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
"back_to_payment_methods_button": "⬅️ Назад", "back_to_payment_methods_button": "⬅️ Назад",
"connect_button": "🔗 Подключиться", "connect_button": "🔗 Подключиться",
"install_guide_share_button": "🔗 Поделиться инструкцией",
"install_guide_share_link_line": "\n\nИнструкция для передачи:\n<code>{install_share_link}</code>",
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})", "devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>", "my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.", "no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
@@ -1600,5 +1602,35 @@
"admin_sort_updated_desc": "Сначала новые", "admin_sort_updated_desc": "Сначала новые",
"admin_sort_updated_asc": "Сначала старые", "admin_sort_updated_asc": "Сначала старые",
"admin_sort_created_desc": "Созданы недавно", "admin_sort_created_desc": "Созданы недавно",
"admin_sort_created_asc": "Созданы давно" "admin_sort_created_asc": "Созданы давно",
"wa_install_title": "Установка и настройка",
"wa_install_subtitle": "Выберите платформу и приложение.",
"wa_install_platform": "Платформа",
"wa_install_app": "Приложение",
"wa_install_loading": "Загружаем инструкции...",
"wa_install_unavailable": "Инструкции недоступны.",
"wa_install_featured": "Рекомендуем",
"wa_install_subscription_link": "Ссылка подписки",
"wa_install_subscription_link_hint": "Отсканируйте QR-код или скопируйте ссылку.",
"wa_install_qr_alt": "QR-код подписки",
"wa_install_copy_subscription_link": "Скопировать ссылку",
"wa_install_link_copied": "Ссылка скопирована",
"wa_install_share": "Поделиться",
"wa_install_share_copied": "Ссылка на инструкцию скопирована",
"admin_settings_section_subscription_guides": "Инструкции подключения",
"admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения",
"admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.",
"admin_settings_field_subscription_guides_bot_menu_enabled_label": "Открывать инструкции из бота",
"admin_settings_field_subscription_guides_bot_menu_enabled_description": "Открывать экран установки в Telegram Mini App для кнопок подключения в боте и показывать публичные ссылки на инструкцию.",
"admin_settings_field_subscription_page_config_panel_enabled_label": "Использовать конфиг Remnawave Panel",
"admin_settings_field_subscription_page_config_panel_enabled_description": "Брать Subscription Page config из Remnawave Panel по short UUID подписки пользователя.",
"admin_settings_field_subscription_page_config_json_override_enabled_label": "Включить JSON-override из админки",
"admin_settings_field_subscription_page_config_json_override_enabled_description": "Использовать JSON-поле ниже вместо конфига Remnawave Panel. По умолчанию выключено.",
"admin_settings_field_subscription_page_config_path_label": "Путь к конфигу Subscription Page",
"admin_settings_field_subscription_page_config_path_description": "Запасной путь к JSON-файлу формата Remnawave Subscription Page v1.",
"admin_settings_field_subscription_page_config_path_placeholder": "data/subpage-config/multiapp.json",
"admin_settings_field_subscription_page_config_json_label": "JSON-конфиг Subscription Page",
"admin_settings_field_subscription_page_config_json_description": "Необязательный JSON-override из админки. Применяется только когда включен соответствующий тумблер.",
"admin_settings_field_subscription_page_config_json_placeholder": "{\n \"version\": \"1\"\n}",
"admin_settings_json_upload": "Загрузить .json"
} }
@@ -22,6 +22,15 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN", "SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
) )
SUBSCRIPTION_GUIDE_SETTINGS = (
"SUBSCRIPTION_GUIDES_ENABLED",
"SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED",
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED",
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED",
"SUBSCRIPTION_PAGE_CONFIG_PATH",
"SUBSCRIPTION_PAGE_CONFIG_JSON",
)
def _manifest_by_key() -> dict[str, dict]: def _manifest_by_key() -> dict[str, dict]:
return {item["key"]: item for item in manifest_payload()} return {item["key"]: item for item in manifest_payload()}
@@ -70,6 +79,23 @@ def test_subscription_purchase_description_settings_i18n_keys_exist():
assert field["i18n_description_key"] in messages assert field["i18n_description_key"] in messages
def test_subscription_guide_settings_i18n_keys_exist():
manifest = _manifest_by_key()
assert manifest["SUBSCRIPTION_GUIDES_ENABLED"]["section"] == "subscription_guides"
assert manifest["SUBSCRIPTION_GUIDES_ENABLED"]["section_order"] == 10
assert manifest["SUBSCRIPTION_PAGE_CONFIG_JSON"]["type"] == "json"
for language in ("ru", "en"):
messages = _locale(language)
assert "admin_settings_section_subscription_guides" in messages
for setting_key in SUBSCRIPTION_GUIDE_SETTINGS:
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
def test_payment_provider_settings_include_webhook_metadata(): def test_payment_provider_settings_include_webhook_metadata():
manifest = _manifest_by_key() manifest = _manifest_by_key()
+67
View File
@@ -0,0 +1,67 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.app.web.admin_api_impl import webapp_runtime
class AdminWebappRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_refresh_resets_settings_cache_and_invalidates_user_payloads(self):
settings = SimpleNamespace()
request = SimpleNamespace(
app={
"settings": settings,
"webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}},
"subscription_guides_config_cache": {
"fingerprint": ("stale",),
"status": {"enabled": True},
},
}
)
with patch.object(
webapp_runtime,
"invalidate_all_webapp_user_payloads",
AsyncMock(),
) as invalidate_mock:
await webapp_runtime.refresh_webapp_runtime_after_settings_change(
request,
updates={"SUBSCRIPTION_GUIDES_ENABLED": True},
deletes=[],
)
self.assertEqual(request.app["webapp_settings_cache"], {"ts": 0.0, "data": {}})
self.assertEqual(
request.app["subscription_guides_config_cache"],
{"fingerprint": None, "status": None},
)
invalidate_mock.assert_awaited_once_with(settings, include_devices=False)
async def test_refresh_clears_logo_cache_for_appearance_settings(self):
settings = SimpleNamespace()
request = SimpleNamespace(
app={
"settings": settings,
"webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}},
"webapp_logo_cache": ("url", b"body", "image/png"),
}
)
with (
patch.object(
webapp_runtime,
"invalidate_all_webapp_user_payloads",
AsyncMock(),
),
patch(
"bot.app.web.admin_api_impl.themes.prune_unused_appearance_assets"
) as prune_mock,
):
await webapp_runtime.refresh_webapp_runtime_after_settings_change(
request,
updates={"WEBAPP_LOGO_URL": "/webapp-uploaded-logo/logo.png"},
deletes=[],
)
self.assertIsNone(request.app["webapp_logo_cache"])
prune_mock.assert_called_once_with(settings)
+37 -1
View File
@@ -1,6 +1,12 @@
import unittest import unittest
from bot.utils.mini_app_url import append_query_params, subscription_mini_app_topup_url from bot.utils.mini_app_url import (
append_query_params,
subscription_mini_app_install_url,
subscription_mini_app_path_url,
subscription_mini_app_topup_url,
subscription_public_install_url,
)
from config.settings import Settings from config.settings import Settings
@@ -39,3 +45,33 @@ class MiniAppUrlTests(unittest.TestCase):
subscription_mini_app_topup_url(s, "regular"), subscription_mini_app_topup_url(s, "regular"),
"https://app.example.com/webapp?topup=regular", "https://app.example.com/webapp?topup=regular",
) )
def test_subscription_mini_app_path_url(self):
s = Settings(
_env_file=None,
BOT_TOKEN="x",
POSTGRES_USER="u",
POSTGRES_PASSWORD="p",
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp/",
)
self.assertEqual(
subscription_mini_app_path_url(s, "/install"),
"https://app.example.com/webapp/install",
)
self.assertEqual(
subscription_mini_app_install_url(s),
"https://app.example.com/webapp/install",
)
def test_subscription_public_install_url_uses_origin(self):
s = Settings(
_env_file=None,
BOT_TOKEN="x",
POSTGRES_USER="u",
POSTGRES_PASSWORD="p",
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/webapp",
)
self.assertEqual(
subscription_public_install_url(s, "8f559061460e8fede78ef18dce887236"),
"https://app.example.com/s/8f559061460e8fede78ef18dce887236",
)
+46
View File
@@ -79,6 +79,52 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(service._request.await_count, 3) self.assertEqual(service._request.await_count, 3)
async def test_get_subscription_page_config_by_short_uuid_uses_panel_endpoint(self):
service = self._make_service()
panel_payload = {"config": {"version": "1"}}
service._request = AsyncMock(return_value={"response": panel_payload})
result = await service.get_subscription_page_config_by_short_uuid(
"short-uuid",
request_headers={"user-agent": "Mozilla/5.0"},
)
self.assertEqual(result, panel_payload)
service._request.assert_awaited_once_with(
"GET",
"/subscriptions/subpage-config/short-uuid",
json={"requestHeaders": {"user-agent": "Mozilla/5.0"}},
log_full_response=False,
)
async def test_get_subscription_page_config_list_uses_panel_endpoint(self):
service = self._make_service()
panel_payload = {"configs": [{"uuid": "default"}]}
service._request = AsyncMock(return_value={"response": panel_payload})
result = await service.get_subscription_page_config_list()
self.assertEqual(result, panel_payload)
service._request.assert_awaited_once_with(
"GET",
"/subscription-page-configs",
log_full_response=False,
)
async def test_get_subscription_page_config_by_uuid_uses_panel_endpoint(self):
service = self._make_service()
panel_payload = {"uuid": "default", "config": {"version": "1"}}
service._request = AsyncMock(return_value={"response": panel_payload})
result = await service.get_subscription_page_config_by_uuid("default")
self.assertEqual(result, panel_payload)
service._request.assert_awaited_once_with(
"GET",
"/subscription-page-configs/default",
log_full_response=False,
)
async def test_get_all_panel_users_uses_singleflight_cache_and_update_invalidates(self): async def test_get_all_panel_users_uses_singleflight_cache_and_update_invalidates(self):
service = self._make_service() service = self._make_service()
get_calls = 0 get_calls = 0
+18
View File
@@ -32,6 +32,24 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN) self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400) self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
def test_subscription_guides_defaults_are_enabled(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertTrue(settings.SUBSCRIPTION_GUIDES_ENABLED)
self.assertTrue(settings.SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED)
self.assertTrue(settings.SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED)
self.assertFalse(settings.SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED)
self.assertEqual(
settings.SUBSCRIPTION_PAGE_CONFIG_PATH,
"data/subpage-config/multiapp.json",
)
self.assertEqual(settings.SUBSCRIPTION_PAGE_CONFIG_JSON, "")
def test_deprecated_webapp_appearance_env_values_are_ignored(self): def test_deprecated_webapp_appearance_env_values_are_ignored(self):
settings = Settings( settings = Settings(
_env_file=None, _env_file=None,
+310
View File
@@ -0,0 +1,310 @@
import json
from types import SimpleNamespace
import pytest
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
default_subscription_guides_config_text,
extract_subscription_guides_config_from_panel,
load_subscription_guides_config,
panel_subscription_page_allowed,
subscription_guides_admin_config_json,
validate_panel_subscription_guides_config,
validate_subscription_guides_config,
validate_subscription_guides_config_text,
)
BASE_TRANSLATION_KEYS = (
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
)
def _localized(text):
return {"ru": text, "en": text}
def _config(app_name="Streisand"):
return {
"version": "1",
"locales": ["ru", "en"],
"brandingSettings": {
"title": "Demo",
"logoUrl": "https://example.com/logo.svg",
"supportUrl": "https://t.me/support",
},
"uiConfig": {
"subscriptionInfoBlockType": "collapsed",
"installationGuidesBlockType": "cards",
},
"baseSettings": {
"metaTitle": "Subscription",
"metaDescription": "Subscription",
"showConnectionKeys": False,
"hideGetLinkButton": False,
},
"baseTranslations": {key: _localized(key) for key in BASE_TRANSLATION_KEYS},
"svgLibrary": {
"App": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Copy": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Download": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Phone": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
},
"platforms": {
"ios": {
"displayName": "iOS",
"svgIconKey": "Phone",
"apps": [
{
"name": app_name,
"svgIconKey": "App",
"featured": True,
"blocks": [
{
"svgIconKey": "Download",
"svgIconColor": "green",
"title": _localized("Install app"),
"description": _localized("Install and import the subscription."),
"buttons": [
{
"type": "external",
"link": "https://apps.apple.com/app/example",
"text": _localized("Open store"),
"svgIconKey": "Download",
},
{
"type": "copyButton",
"link": "{{SUBSCRIPTION_LINK}}",
"text": _localized("Copy link"),
"svgIconKey": "Copy",
},
],
}
],
}
],
}
},
}
def test_valid_multiapp_like_config_is_normalized():
config = validate_subscription_guides_config(_config())
assert config["version"] == "1"
assert config["locales"] == ["ru", "en"]
assert config["platforms"]["ios"]["apps"][0]["name"] == "Streisand"
def test_bundled_default_multiapp_config_is_valid():
config = validate_subscription_guides_config_text(default_subscription_guides_config_text())
assert set(config["platforms"]) == {
"android",
"androidTV",
"appleTV",
"ios",
"linux",
"macos",
"windows",
}
assert config["platforms"]["ios"]["displayName"]["ru"] == "iOS"
def test_missing_locale_string_is_rejected():
config = _config()
del config["platforms"]["ios"]["apps"][0]["blocks"][0]["title"]["en"]
with pytest.raises(SubscriptionGuidesConfigError, match="title.en"):
validate_subscription_guides_config(config)
def test_bad_platform_is_rejected():
config = _config()
config["platforms"]["bsd"] = config["platforms"].pop("ios")
with pytest.raises(SubscriptionGuidesConfigError, match="Unsupported platform"):
validate_subscription_guides_config(config)
def test_missing_svg_key_is_rejected():
config = _config()
config["platforms"]["ios"]["svgIconKey"] = "Missing"
with pytest.raises(SubscriptionGuidesConfigError, match="missing svgLibrary key"):
validate_subscription_guides_config(config)
def test_unsafe_svg_is_rejected():
config = _config()
config["svgLibrary"]["App"] = '<svg viewBox="0 0 24 24" onload="alert(1)"></svg>'
with pytest.raises(SubscriptionGuidesConfigError, match="unsafe SVG"):
validate_subscription_guides_config(config)
def test_unsafe_external_link_is_rejected():
config = _config()
config["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0][
"link"
] = "javascript:alert(1)"
with pytest.raises(SubscriptionGuidesConfigError, match="unsafe URL scheme"):
validate_subscription_guides_config(config)
def test_external_custom_scheme_is_allowed_for_multiapp_compatibility():
config = _config()
config["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0][
"link"
] = "streisand://import/demo"
validated = validate_subscription_guides_config(config)
assert (
validated["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0]["link"]
== "streisand://import/demo"
)
def test_panel_response_config_wrapper_is_supported():
payload = {"response": {"config": json.dumps(_config(app_name="Panel App"))}}
validated = validate_panel_subscription_guides_config(payload)
assert validated["platforms"]["ios"]["apps"][0]["name"] == "Panel App"
def test_panel_response_direct_v1_config_is_supported():
payload = {"response": _config(app_name="Panel Direct App")}
extracted = extract_subscription_guides_config_from_panel(payload)
validated = validate_panel_subscription_guides_config(payload)
assert extracted["version"] == "1"
assert validated["platforms"]["ios"]["apps"][0]["name"] == "Panel Direct App"
def test_panel_response_without_v1_config_is_rejected():
with pytest.raises(SubscriptionGuidesConfigError, match="does not contain"):
validate_panel_subscription_guides_config({"response": {"config": {"version": "2"}}})
def test_panel_response_with_allowed_default_uses_bundled_config():
validated = validate_panel_subscription_guides_config(
{"response": {"subpageConfigUuid": None, "webpageAllowed": True}},
allow_default_when_missing=True,
)
assert panel_subscription_page_allowed({"response": {"webpageAllowed": True}})
assert validated["version"] == "1"
assert set(validated["platforms"]) >= {"ios", "android", "windows"}
def test_admin_json_overrides_file_path(tmp_path):
file_config = _config(app_name="File App")
json_config = _config(app_name="JSON App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=True,
SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(json_config),
)
loaded, source = load_subscription_guides_config(settings)
assert source == "admin_json"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "JSON App"
def test_admin_json_is_ignored_when_override_switch_is_disabled(tmp_path):
file_config = _config(app_name="File App")
json_config = _config(app_name="JSON App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=False,
SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(json_config),
)
loaded, source = load_subscription_guides_config(settings)
assert source == "file"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "File App"
def test_file_path_is_used_when_admin_json_is_empty(tmp_path):
file_config = _config(app_name="File App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
loaded, source = load_subscription_guides_config(settings)
assert source == "file"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "File App"
def test_missing_file_path_is_not_created_implicitly(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
with pytest.raises(SubscriptionGuidesConfigError, match="does not exist"):
load_subscription_guides_config(settings)
assert not config_path.exists()
def test_admin_json_editor_is_empty_when_override_is_empty(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
raw, source = subscription_guides_admin_config_json(settings)
assert source == "empty"
assert raw == ""
assert not config_path.exists()
def test_admin_json_editor_keeps_admin_override_without_creating_file(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
override = json.dumps(_config(app_name="JSON App"))
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON=override,
)
raw, source = subscription_guides_admin_config_json(settings)
assert not config_path.exists()
assert source == "admin_json"
assert json.loads(raw)["platforms"]["ios"]["apps"][0]["name"] == "JSON App"
+276
View File
@@ -0,0 +1,276 @@
import asyncio
import json
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.app.web import subscription_webapp as guides
from config.subscription_guides_config import default_subscription_guides_config_text
class _AsyncSessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return object()
async def __aexit__(self, exc_type, exc, tb):
return False
class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
def _request(self, settings, panel_service, match_info=None):
return SimpleNamespace(
app={
"settings": settings,
"async_session_factory": _AsyncSessionFactory(),
"panel_service": panel_service,
"subscription_guides_config_cache": {"fingerprint": None, "status": None},
"subscription_guides_config_lock": asyncio.Lock(),
},
match_info=match_info or {},
headers={"User-Agent": "Mozilla/5.0", "Host": "app.example.test"},
host="app.example.test",
scheme="https",
)
def _settings(self, **overrides):
values = {
"SUBSCRIPTION_GUIDES_ENABLED": True,
"SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED": True,
"SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED": False,
"SUBSCRIPTION_PAGE_CONFIG_JSON": "",
"SUBSCRIPTION_PAGE_CONFIG_PATH": "data/subpage-config/multiapp.json",
"SUBSCRIPTION_MINI_APP_URL": "https://app.example.test",
"CRYPT4_ENABLED": False,
"CRYPT4_REDIRECT_URL": "",
"CRYPT4_LINK_CACHE_TTL_SECONDS": 3600,
}
values.update(overrides)
return SimpleNamespace(**values)
def _auth_patch(self):
return patch.dict(
guides.subscription_guides_route.__globals__,
{"_require_user_id": lambda _: 42},
)
async def test_uses_panel_config_when_admin_json_is_empty(self):
default_uuid = "00000000-0000-0000-0000-000000000000"
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(
return_value={"configs": [{"uuid": default_uuid, "viewPosition": 1}]}
),
get_subscription_page_config_by_uuid=AsyncMock(
return_value={
"uuid": default_uuid,
"config": json.loads(default_subscription_guides_config_text()),
}
)
)
request = self._request(self._settings(), panel_service)
with self._auth_patch():
response = await guides.subscription_guides_route(request)
body = json.loads(response.text)
self.assertTrue(body["enabled"])
self.assertEqual(body["source"], "panel")
self.assertEqual(body["config"]["version"], "1")
panel_service.get_subscription_page_config_list.assert_awaited_once()
panel_service.get_subscription_page_config_by_uuid.assert_awaited_once_with(default_uuid)
async def test_admin_json_override_takes_priority_over_panel(self):
admin_config = json.loads(default_subscription_guides_config_text())
panel_service = SimpleNamespace(get_subscription_page_config_by_uuid=AsyncMock())
request = self._request(
self._settings(
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=True,
SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(admin_config),
),
panel_service,
)
with self._auth_patch():
response = await guides.subscription_guides_route(request)
body = json.loads(response.text)
self.assertTrue(body["enabled"])
self.assertEqual(body["source"], "admin_json")
panel_service.get_subscription_page_config_by_uuid.assert_not_called()
async def test_admin_json_is_ignored_until_override_switch_is_enabled(self):
default_uuid = "00000000-0000-0000-0000-000000000000"
admin_config = json.loads(default_subscription_guides_config_text())
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(
return_value={"configs": [{"uuid": default_uuid, "viewPosition": 1}]}
),
get_subscription_page_config_by_uuid=AsyncMock(
return_value={
"uuid": default_uuid,
"config": json.loads(default_subscription_guides_config_text()),
}
)
)
request = self._request(
self._settings(SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(admin_config)),
panel_service,
)
with self._auth_patch():
response = await guides.subscription_guides_route(request)
body = json.loads(response.text)
self.assertTrue(body["enabled"])
self.assertEqual(body["source"], "panel")
panel_service.get_subscription_page_config_by_uuid.assert_awaited_once_with(default_uuid)
async def test_panel_config_is_cached_for_multiple_users(self):
default_uuid = "00000000-0000-0000-0000-000000000000"
panel_config = json.loads(default_subscription_guides_config_text())
panel_config["platforms"]["windows"]["apps"][0]["name"] = "Throne"
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(
return_value={"configs": [{"uuid": default_uuid, "viewPosition": 1}]}
),
get_subscription_page_config_by_uuid=AsyncMock(
return_value={"uuid": default_uuid, "config": panel_config}
),
)
request = self._request(self._settings(), panel_service)
with self._auth_patch():
response = await guides.subscription_guides_route(request)
second_response = await guides.subscription_guides_route(request)
body = json.loads(response.text)
second_body = json.loads(second_response.text)
self.assertTrue(body["enabled"])
self.assertTrue(second_body["enabled"])
self.assertEqual(body["source"], "panel")
self.assertEqual(body["config"]["version"], "1")
self.assertIn("windows", body["config"]["platforms"])
windows_apps = [app["name"] for app in body["config"]["platforms"]["windows"]["apps"]]
self.assertIn("Throne", windows_apps)
panel_service.get_subscription_page_config_list.assert_awaited_once()
panel_service.get_subscription_page_config_by_uuid.assert_awaited_once_with(default_uuid)
async def test_public_route_returns_shared_config_and_subscription_payload(self):
default_uuid = "00000000-0000-0000-0000-000000000000"
share_token = "8f559061460e8fede78ef18dce887236"
panel_config = json.loads(default_subscription_guides_config_text())
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(
return_value={"configs": [{"uuid": default_uuid, "viewPosition": 1}]}
),
get_subscription_page_config_by_uuid=AsyncMock(
return_value={"uuid": default_uuid, "config": panel_config}
),
get_user_by_uuid=AsyncMock(
return_value={
"shortUuid": "share-short",
"subscriptionUrl": "https://sb.example.test/share-short",
"username": "demo",
}
),
)
request = self._request(
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
panel_service,
match_info={"share_token": share_token},
)
local_sub = SimpleNamespace(
panel_user_uuid="panel-user",
install_share_token=share_token,
is_active=True,
end_date=datetime.now(timezone.utc) + timedelta(days=3),
)
with patch.object(
guides.subscription_dal,
"get_subscription_by_install_share_token",
AsyncMock(return_value=local_sub),
):
response = await guides.public_subscription_guides_route(request)
body = json.loads(response.text)
self.assertTrue(body["enabled"])
self.assertEqual(body["subscription"]["config_link"], "https://sb.example.test/share-short")
self.assertEqual(
body["subscription"]["share_url"],
f"https://app.example.test/s/{share_token}",
)
self.assertEqual(body["subscription"]["install_share_token"], share_token)
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-user")
async def test_public_route_rejects_unknown_share_token_without_loading_config(self):
share_token = "8f559061460e8fede78ef18dce887236"
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(),
get_subscription_page_config_by_uuid=AsyncMock(),
get_user_by_uuid=AsyncMock(),
)
request = self._request(
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
panel_service,
match_info={"share_token": share_token},
)
with patch.object(
guides.subscription_dal,
"get_subscription_by_install_share_token",
AsyncMock(return_value=None),
):
response = await guides.public_subscription_guides_route(request)
body = json.loads(response.text)
self.assertEqual(response.status, 404)
self.assertFalse(body["ok"])
self.assertEqual(body["error"], "subscription_unavailable")
self.assertFalse(body["enabled"])
self.assertIsNone(body["config"])
self.assertEqual(body["subscription"]["install_share_token"], share_token)
self.assertFalse(body["subscription"]["active"])
panel_service.get_subscription_page_config_list.assert_not_called()
panel_service.get_subscription_page_config_by_uuid.assert_not_called()
panel_service.get_user_by_uuid.assert_not_called()
async def test_public_route_rejects_inactive_share_token_without_panel_user_lookup(self):
share_token = "8f559061460e8fede78ef18dce887236"
panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock(),
get_subscription_page_config_by_uuid=AsyncMock(),
get_user_by_uuid=AsyncMock(),
)
request = self._request(
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
panel_service,
match_info={"share_token": share_token},
)
local_sub = SimpleNamespace(
panel_user_uuid="panel-user",
install_share_token=share_token,
is_active=False,
end_date=datetime.now(timezone.utc) + timedelta(days=3),
)
with patch.object(
guides.subscription_dal,
"get_subscription_by_install_share_token",
AsyncMock(return_value=local_sub),
):
response = await guides.public_subscription_guides_route(request)
body = json.loads(response.text)
self.assertEqual(response.status, 404)
self.assertFalse(body["ok"])
self.assertEqual(body["error"], "subscription_unavailable")
self.assertFalse(body["subscription"]["active"])
panel_service.get_user_by_uuid.assert_not_called()
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -8,6 +8,7 @@ from bot.handlers.user import referral
from bot.handlers.user.subscription.core import _with_subscription_purchase_description from bot.handlers.user.subscription.core import _with_subscription_purchase_description
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard, get_bot_interface_inline_keyboard,
get_connect_and_main_keyboard,
get_information_links_keyboard, get_information_links_keyboard,
get_language_selection_keyboard, get_language_selection_keyboard,
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
@@ -41,6 +42,13 @@ class UserBotMenuTests(unittest.TestCase):
TERMS_OF_SERVICE_URL="", TERMS_OF_SERVICE_URL="",
TRIAL_ENABLED=True, TRIAL_ENABLED=True,
SERVER_STATUS_URL="", SERVER_STATUS_URL="",
SUBSCRIPTION_GUIDES_ENABLED=True,
SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED=False,
SUBSCRIPTION_PAGE_CONFIG_PANEL_ENABLED=True,
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=False,
SUBSCRIPTION_PAGE_CONFIG_JSON="",
PANEL_API_URL="https://panel.example.com",
PANEL_API_KEY="token",
) )
def _callback_data(self, markup): def _callback_data(self, markup):
@@ -70,6 +78,38 @@ class UserBotMenuTests(unittest.TestCase):
self.assertIn("main_action:bot_info", callbacks) self.assertIn("main_action:bot_info", callbacks)
self.assertIn("main_action:back_to_main", callbacks) self.assertIn("main_action:back_to_main", callbacks)
def test_connect_keyboard_uses_subscription_url_when_bot_guides_disabled(self):
markup = get_connect_and_main_keyboard(
"en",
self.i18n,
self.settings,
"https://sb.example.com/user",
connect_button_url=None,
)
self.assertEqual(markup.inline_keyboard[0][0].url, "https://sb.example.com/user")
self.assertIsNone(markup.inline_keyboard[0][0].web_app)
def test_connect_keyboard_opens_install_guide_when_bot_guides_enabled(self):
self.settings.SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED = True
markup = get_connect_and_main_keyboard(
"en",
self.i18n,
self.settings,
"https://sb.example.com/user",
install_share_url="https://app.example.com/s/8f559061460e8fede78ef18dce887236",
)
self.assertIsNone(markup.inline_keyboard[0][0].url)
self.assertEqual(
markup.inline_keyboard[0][0].web_app.url,
"https://app.example.com/install",
)
self.assertEqual(
markup.inline_keyboard[1][0].url,
"https://app.example.com/s/8f559061460e8fede78ef18dce887236",
)
def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self): def test_nested_bot_menu_keyboards_can_target_bot_interface_back(self):
subscription_markup = get_subscription_options_keyboard( subscription_markup = get_subscription_options_keyboard(
{1: 100}, {1: 100},
+22 -3
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import bot.app.web.subscription_webapp # noqa: F401 import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import common as common_module from bot.app.web.webapp import cache_helpers
class WebappRedisCacheInvalidationTests(unittest.IsolatedAsyncioTestCase): class WebappRedisCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
@@ -14,8 +14,8 @@ class WebappRedisCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
async def fake_delete(_settings, *keys): async def fake_delete(_settings, *keys):
deleted.extend(keys) deleted.extend(keys)
with patch.object(common_module, "cache_delete", fake_delete): with patch.object(cache_helpers, "cache_delete", fake_delete):
await common_module._invalidate_webapp_user_caches( await cache_helpers.invalidate_webapp_user_caches(
settings, settings,
42, 42,
"42", "42",
@@ -33,6 +33,25 @@ class WebappRedisCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
], ],
) )
async def test_invalidate_all_webapp_user_payloads_deletes_namespace_patterns(self):
settings = SimpleNamespace(REDIS_URL="redis://redis:6379/0", REDIS_KEY_PREFIX="shop")
patterns = []
async def fake_delete_pattern(_settings, pattern):
patterns.append(pattern)
return 0
with patch.object(cache_helpers, "cache_delete_pattern", fake_delete_pattern):
await cache_helpers.invalidate_all_webapp_user_payloads(settings, include_devices=True)
self.assertEqual(
patterns,
[
"shop:cache:webapp:me:*",
"shop:cache:webapp:devices:*",
],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+7
View File
@@ -50,6 +50,8 @@ class WebAppRouteContractTests(unittest.TestCase):
("GET", "/"): "index_route", ("GET", "/"): "index_route",
("GET", "/login/password"): "index_route", ("GET", "/login/password"): "index_route",
("GET", "/home"): "index_route", ("GET", "/home"): "index_route",
("GET", "/install"): "index_route",
("GET", "/s/{share_token}"): "index_route",
("GET", "/invite"): "index_route", ("GET", "/invite"): "index_route",
("GET", "/devices"): "index_route", ("GET", "/devices"): "index_route",
("GET", "/settings"): "index_route", ("GET", "/settings"): "index_route",
@@ -77,6 +79,11 @@ class WebAppRouteContractTests(unittest.TestCase):
("POST", "/api/auth/email/password"): "email_password_auth_route", ("POST", "/api/auth/email/password"): "email_password_auth_route",
("POST", "/api/auth/logout"): "logout_route", ("POST", "/api/auth/logout"): "logout_route",
("GET", "/api/me"): "me_route", ("GET", "/api/me"): "me_route",
("GET", "/api/subscription-guides"): "subscription_guides_route",
(
"GET",
"/api/subscription-guides/public/{share_token}",
): "public_subscription_guides_route",
("GET", "/api/account/avatar"): "account_avatar_route", ("GET", "/api/account/avatar"): "account_avatar_route",
("POST", "/api/account/language"): "account_language_route", ("POST", "/api/account/language"): "account_language_route",
("POST", "/api/account/email/request"): "account_email_request_route", ("POST", "/api/account/email/request"): "account_email_request_route",
+42 -3
View File
@@ -33,13 +33,14 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertEqual(win95.tokens.style_preset, "win95") self.assertEqual(win95.tokens.style_preset, "win95")
self.assertFalse(win95.use_primary_accent) self.assertFalse(win95.use_primary_accent)
self.assertTrue(win95.use_in_admin) self.assertTrue(win95.use_in_admin)
self.assertEqual(win95.assets_version, 9) self.assertEqual(win95.assets_version, 11)
self.assertEqual(cfg.theme_by_key("light").assets_version, 3)
ascii_theme = cfg.theme_by_key("ascii") ascii_theme = cfg.theme_by_key("ascii")
self.assertIsNotNone(ascii_theme) self.assertIsNotNone(ascii_theme)
self.assertEqual(ascii_theme.css_file, "style.css") self.assertEqual(ascii_theme.css_file, "style.css")
self.assertFalse(ascii_theme.use_primary_accent) self.assertFalse(ascii_theme.use_primary_accent)
self.assertTrue(ascii_theme.use_in_admin) self.assertTrue(ascii_theme.use_in_admin)
self.assertEqual(ascii_theme.assets_version, 3) self.assertEqual(ascii_theme.assets_version, 4)
def test_env_override_default_theme(self): def test_env_override_default_theme(self):
cfg = builtin_webapp_themes_config("#00fe7a") cfg = builtin_webapp_themes_config("#00fe7a")
@@ -381,7 +382,7 @@ class WebappThemesConfigTests(unittest.TestCase):
descriptor["assets_version"], descriptor["assets_version"],
cfg.theme_by_key("windows95").assets_version, cfg.theme_by_key("windows95").assets_version,
) )
self.assertEqual(descriptor["assets_version"], 9) self.assertEqual(descriptor["assets_version"], 11)
self.assertIn("lucide-house", css) self.assertIn("lucide-house", css)
self.assertIn("lucide-earth", css) self.assertIn("lucide-earth", css)
self.assertIn("lucide-circle-check", css) self.assertIn("lucide-circle-check", css)
@@ -391,7 +392,9 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertIn("::-webkit-slider-thumb", css) self.assertIn("::-webkit-slider-thumb", css)
self.assertIn("?v=9", css) self.assertIn("?v=9", css)
self.assertIn("lucide-life-buoy", css) self.assertIn("lucide-life-buoy", css)
self.assertIn("lucide-qr-code", css)
self.assertIn("New webapp surfaces: support, purchase info, password login", css) self.assertIn("New webapp surfaces: support, purchase info, password login", css)
self.assertIn("Install guide theme surfaces", css)
self.assertIn( self.assertIn(
".theme-key-windows95 .support-list-card {\n grid-template-rows: auto auto minmax(0, 1fr);", ".theme-key-windows95 .support-list-card {\n grid-template-rows: auto auto minmax(0, 1fr);",
css, css,
@@ -429,3 +432,39 @@ class WebappThemesConfigTests(unittest.TestCase):
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
self.assertIn(".theme-key-light.app-shell", css) self.assertIn(".theme-key-light.app-shell", css)
self.assertIn("Install guide theme surfaces", css)
def test_resolved_refreshes_stale_builtin_ascii_assets(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
stale_theme_dir = themes_dir / "ascii"
stale_theme_dir.mkdir(parents=True)
(stale_theme_dir / "theme.json").write_text(
json.dumps(
{
"key": "ascii",
"names": {"en": "ASCII"},
"enabled": True,
"default": False,
"use_primary_accent": False,
"css_file": "style.css",
"assets_version": 1,
"tokens": {"color_scheme": "dark", "style_preset": "ascii"},
}
),
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("ascii").assets_version)
self.assertEqual(descriptor["assets_version"], 4)
self.assertIn("Console-style tables", css)
self.assertIn("Install guide theme surfaces", css)