Merge branch 'feature/install-page' into dev
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
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:
|
||||
@@ -27,12 +33,25 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
override = overrides_by_key.get(key)
|
||||
value = current_value(settings, key)
|
||||
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 = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"overridden": bool(override),
|
||||
"overridden": overridden,
|
||||
"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:
|
||||
response_field["has_value"] = bool(value)
|
||||
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")
|
||||
if not isinstance(deletes, list):
|
||||
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(
|
||||
settings,
|
||||
@@ -74,32 +99,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Bust the public webapp settings cache so users see new values immediately.
|
||||
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)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
|
||||
|
||||
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
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:
|
||||
@@ -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)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -213,12 +214,7 @@ async def _persist_appearance_upload(
|
||||
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
|
||||
return False
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
request.app["webapp_logo_cache"] = None
|
||||
prune_unused_appearance_assets(settings)
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
|
||||
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)
|
||||
return _error(500, "write_failed", str(exc))
|
||||
|
||||
cache = request.app.get("webapp_settings_cache")
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
|
||||
|
||||
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)
|
||||
@@ -16,7 +16,7 @@ from typing import Any, List, Optional, Tuple
|
||||
@dataclass(frozen=True)
|
||||
class SettingField:
|
||||
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
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -160,6 +160,59 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("WEBAPP_FAVICON_URL", "url", "appearance", "URL отдельной favicon"),
|
||||
SettingField("WEBAPP_LOGO_FAVICON_URL", "url", "appearance", "Favicon из логотипа"),
|
||||
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 ────────────────────────────
|
||||
SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"),
|
||||
SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"),
|
||||
@@ -454,6 +507,18 @@ def manifest_keys() -> List[str]:
|
||||
def coerce_value(field: SettingField, raw: Any) -> Any:
|
||||
"""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() == ""):
|
||||
return None
|
||||
|
||||
@@ -523,6 +588,7 @@ def manifest_payload() -> List[dict]:
|
||||
"notifications": 7,
|
||||
"support": 8,
|
||||
"devices": 9,
|
||||
"subscription_guides": 10,
|
||||
}
|
||||
items: List[dict] = []
|
||||
for field in aggregated_manifest():
|
||||
|
||||
@@ -11,6 +11,7 @@ from bot.app.web.webapp import (
|
||||
billing as _billing,
|
||||
common as _common,
|
||||
devices as _devices,
|
||||
guides as _guides,
|
||||
payloads as _payloads,
|
||||
routes as _routes,
|
||||
serializers as _serializers,
|
||||
@@ -27,6 +28,7 @@ _MODULES = (
|
||||
_serializers,
|
||||
_billing,
|
||||
_devices,
|
||||
_guides,
|
||||
_support,
|
||||
_routes,
|
||||
_application,
|
||||
|
||||
@@ -149,6 +149,12 @@
|
||||
.theme-key-ascii .ticket-message-avatar,
|
||||
.theme-key-ascii .ticket-message-bubble,
|
||||
.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-header,
|
||||
.theme-key-ascii .admin-card,
|
||||
@@ -183,6 +189,8 @@
|
||||
.theme-key-ascii .support-new-ticket-button,
|
||||
.theme-key-ascii .support-select-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-chip,
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
@@ -230,6 +238,7 @@
|
||||
.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-selected],
|
||||
.theme-key-ascii .install-app-button.active,
|
||||
.theme-key-ascii .admin-nav-item.active,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active {
|
||||
@@ -259,6 +268,9 @@
|
||||
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
|
||||
.theme-key-ascii .admin-mobile-toggle:focus-visible,
|
||||
.theme-key-ascii .language-select-trigger:focus-visible,
|
||||
.theme-key-ascii .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 {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: 1px;
|
||||
@@ -412,6 +424,7 @@
|
||||
}
|
||||
|
||||
body:has(.theme-key-ascii) .support-select-content,
|
||||
body:has(.theme-key-ascii) .install-platform-content,
|
||||
body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
@@ -420,19 +433,24 @@ body:has(.theme-key-ascii) .field-error-tooltip {
|
||||
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;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
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;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
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;
|
||||
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-nav-item.active svg.lucide,
|
||||
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
|
||||
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide,
|
||||
.theme-key-ascii .install-app-button.active svg.lucide {
|
||||
color: #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-trigger, .bottom-nav, .bottom-nav 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-conversation-card, .support-new-ticket-button,
|
||||
.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-panel-dash-card,
|
||||
.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-input, .admin-textarea, .admin-btn,
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ---------- 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,
|
||||
* row separator using dashed line.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"assets_version": 4,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -129,3 +129,57 @@
|
||||
.theme-key-light .bonus-card-head > svg {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 2,
|
||||
"assets_version": 3,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -138,12 +138,15 @@
|
||||
.theme-key-windows95 svg.lucide-megaphone,
|
||||
.theme-key-windows95 svg.lucide-message-square,
|
||||
.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-plus,
|
||||
.theme-key-windows95 svg.lucide-qr-code,
|
||||
.theme-key-windows95 svg.lucide-refresh-cw,
|
||||
.theme-key-windows95 svg.lucide-save,
|
||||
.theme-key-windows95 svg.lucide-search,
|
||||
.theme-key-windows95 svg.lucide-send,
|
||||
.theme-key-windows95 svg.lucide-share-2,
|
||||
.theme-key-windows95 svg.lucide-settings,
|
||||
.theme-key-windows95 svg.lucide-shield,
|
||||
.theme-key-windows95 svg.lucide-sliders,
|
||||
@@ -273,6 +276,10 @@
|
||||
--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 {
|
||||
--win95-button-icon: var(--win95-icon-paintbrush);
|
||||
}
|
||||
@@ -281,6 +288,10 @@
|
||||
--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 {
|
||||
--win95-button-icon: var(--win95-icon-refresh);
|
||||
}
|
||||
@@ -293,6 +304,10 @@
|
||||
--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 {
|
||||
--win95-button-icon: var(--win95-icon-settings);
|
||||
}
|
||||
@@ -371,12 +386,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -425,12 +443,15 @@
|
||||
svg.lucide-megaphone,
|
||||
svg.lucide-message-square,
|
||||
svg.lucide-message-square-plus,
|
||||
svg.lucide-monitor,
|
||||
svg.lucide-paintbrush,
|
||||
svg.lucide-plus,
|
||||
svg.lucide-qr-code,
|
||||
svg.lucide-refresh-cw,
|
||||
svg.lucide-save,
|
||||
svg.lucide-search,
|
||||
svg.lucide-send,
|
||||
svg.lucide-share-2,
|
||||
svg.lucide-settings,
|
||||
svg.lucide-shield,
|
||||
svg.lucide-sliders,
|
||||
@@ -488,7 +509,13 @@
|
||||
.theme-key-windows95 .support-message-scroll,
|
||||
.theme-key-windows95 .ticket-message-avatar,
|
||||
.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-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
@@ -510,6 +537,7 @@
|
||||
|
||||
body:has(.theme-key-windows95) .language-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) .admin-select-content {
|
||||
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) .support-select-item,
|
||||
body:has(.theme-key-windows95) .install-platform-item,
|
||||
body:has(.theme-key-windows95) .admin-select-item {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
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;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -560,7 +593,9 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
.theme-key-windows95 .link-button,
|
||||
.theme-key-windows95 .support-new-ticket-button,
|
||||
.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;
|
||||
border: 2px solid;
|
||||
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-status-tabs-trigger[data-state="active"],
|
||||
.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);
|
||||
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 .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 .support-status-tabs-trigger:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
@@ -731,6 +770,85 @@ body:has(.theme-key-windows95) .support-select-item[data-selected] svg {
|
||||
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 .brand-row strong,
|
||||
.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-mobile-toggle: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 {
|
||||
outline: 1px dotted #000000;
|
||||
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-tabs-trigger[data-state="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 {
|
||||
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-menu,
|
||||
.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-repeat-2,
|
||||
.theme-key-windows95 svg.lucide-server,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 9,
|
||||
"assets_version": 11,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .guides import warm_subscription_guides_config
|
||||
|
||||
|
||||
def create_subscription_webapp_application(
|
||||
@@ -24,6 +25,8 @@ def create_subscription_webapp_application(
|
||||
app["webapp_logo_cache"] = None
|
||||
app["webapp_logo_cache_lock"] = asyncio.Lock()
|
||||
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_lock"] = asyncio.Lock()
|
||||
|
||||
@@ -31,6 +34,7 @@ def create_subscription_webapp_application(
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_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:
|
||||
await _close_shared_http_session()
|
||||
|
||||
@@ -9,6 +9,24 @@ from config.settings import Settings
|
||||
_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(
|
||||
settings: Settings,
|
||||
namespace: str,
|
||||
@@ -58,13 +76,22 @@ def invalidate_local_webapp_user_payload(
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
*,
|
||||
include_devices: Optional[bool] = 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(
|
||||
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||
):
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespace is not None and cache_namespace != namespace:
|
||||
if namespaces is not None and cache_namespace not in namespaces:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
@@ -95,21 +122,23 @@ async def invalidate_webapp_user_caches(
|
||||
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(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
namespaces = ["me"]
|
||||
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
|
||||
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
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],
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
keys: List[str] = []
|
||||
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)
|
||||
await _invalidate_user_payload_caches(settings, *user_ids, include_devices=include_devices)
|
||||
|
||||
|
||||
def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -6,6 +6,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", 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("/devices", 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/i18n", i18n_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_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
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
|
||||
|
||||
|
||||
@@ -52,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if db_user.panel_user_uuid
|
||||
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(
|
||||
settings.TRIAL_ENABLED
|
||||
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,
|
||||
"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": {
|
||||
"code": referral_code,
|
||||
"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_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
"subscription_purchase_description": settings.subscription_purchase_description(lang),
|
||||
"subscription_guides_enabled": subscription_guides_available(settings),
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
@@ -179,11 +193,26 @@ def _build_webapp_referral_link(
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
request_or_settings: Any,
|
||||
settings_or_active: Any,
|
||||
active_or_local_sub: Optional[Any] = None,
|
||||
local_sub_or_lang: Optional[Any] = None,
|
||||
lang: Optional[str] = None,
|
||||
*,
|
||||
install_share_token: Optional[str] = None,
|
||||
) -> 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:
|
||||
return {
|
||||
"active": False,
|
||||
@@ -192,6 +221,9 @@ def _serialize_subscription(
|
||||
"days_left": 0,
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
"panel_short_uuid": None,
|
||||
"install_share_token": None,
|
||||
"install_share_url": None,
|
||||
}
|
||||
|
||||
end_date = active.get("end_date")
|
||||
@@ -231,6 +263,10 @@ def _serialize_subscription(
|
||||
can_topup_traffic = 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 {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
@@ -240,6 +276,9 @@ def _serialize_subscription(
|
||||
"remaining_text": _format_remaining(seconds_left, lang),
|
||||
"config_link": 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_used": _format_bytes(active.get("traffic_used_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(
|
||||
settings: Settings,
|
||||
lang: str,
|
||||
|
||||
@@ -16,6 +16,10 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.states.user_states import UserPromoStates
|
||||
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 .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"),
|
||||
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(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
|
||||
@@ -23,6 +23,10 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
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 config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
@@ -715,6 +719,23 @@ async def start_command_handler(
|
||||
),
|
||||
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
|
||||
|
||||
@@ -726,6 +747,7 @@ async def start_command_handler(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@@ -27,6 +27,10 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
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 db.dal import subscription_dal, user_billing_dal
|
||||
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(
|
||||
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
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Connect button: prefer the actual subscription URL; fall back to mini-app
|
||||
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(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -14,6 +14,10 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
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 .start import send_main_menu
|
||||
@@ -74,6 +78,7 @@ async def request_trial_confirmation_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -104,6 +109,14 @@ async def request_trial_confirmation_handler(
|
||||
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
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
@@ -139,6 +152,7 @@ async def request_trial_confirmation_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
@@ -214,6 +228,7 @@ async def confirm_activate_trial_handler(
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
install_share_url = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
@@ -243,6 +258,13 @@ async def confirm_activate_trial_handler(
|
||||
config_link=config_link_for_trial,
|
||||
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:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
@@ -266,6 +288,7 @@ async def confirm_activate_trial_handler(
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
install_share_url=install_share_url,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from config.settings import Settings
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
@@ -689,13 +690,29 @@ def get_connect_and_main_keyboard(
|
||||
config_link: Optional[str],
|
||||
connect_button_url: Optional[str] = None,
|
||||
preserve_message: bool = False,
|
||||
install_share_url: Optional[str] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
install_url = bot_install_guide_url(settings)
|
||||
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))
|
||||
elif settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
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.services.notification_service import NotificationService
|
||||
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 db.dal import payment_dal, user_dal
|
||||
from db.models import Payment, User
|
||||
@@ -136,6 +140,7 @@ async def send_success_message_to_user(
|
||||
settings: Any,
|
||||
config_link_display: Optional[str],
|
||||
connect_button_url: Optional[str],
|
||||
install_share_url: Optional[str] = None,
|
||||
include_keyboard: bool = True,
|
||||
log_prefix: str = "payment_providers",
|
||||
) -> None:
|
||||
@@ -148,6 +153,7 @@ async def send_success_message_to_user(
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
@@ -332,6 +338,31 @@ async def finalize_successful_payment(
|
||||
if req.text_prefix:
|
||||
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(
|
||||
bot=req.bot,
|
||||
user_id=req.user_id,
|
||||
@@ -341,6 +372,7 @@ async def finalize_successful_payment(
|
||||
settings=req.settings,
|
||||
config_link_display=config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
include_keyboard=not req.skip_keyboard,
|
||||
log_prefix=req.log_prefix,
|
||||
)
|
||||
|
||||
@@ -36,6 +36,10 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
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 config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal, user_dal
|
||||
@@ -741,6 +745,16 @@ async def process_successful_payment(
|
||||
)
|
||||
)
|
||||
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(
|
||||
bot=bot,
|
||||
user_id=user_id,
|
||||
@@ -750,6 +764,7 @@ async def process_successful_payment(
|
||||
settings=settings,
|
||||
config_link_display=config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
install_share_url=install_share_url,
|
||||
include_keyboard=include_keyboard,
|
||||
log_prefix="YooKassa webhook",
|
||||
)
|
||||
|
||||
@@ -597,6 +597,54 @@ class PanelApiService:
|
||||
return f"{base_sub_url}/{client_type.lower()}"
|
||||
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]]]:
|
||||
if self._devices_cache.ttl_seconds <= 0:
|
||||
return await self._get_user_devices_uncached(user_uuid)
|
||||
|
||||
@@ -763,6 +763,10 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
return {
|
||||
"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,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": display_link,
|
||||
|
||||
@@ -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}"
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 db.dal.subscription_dal import normalize_install_share_token
|
||||
|
||||
|
||||
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
|
||||
normalized = "premium" if str(kind or "").strip().lower() == "premium" else "regular"
|
||||
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
@@ -323,6 +323,35 @@ class Settings(BaseSettings):
|
||||
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
|
||||
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
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))
|
||||
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||
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()
|
||||
@@ -365,7 +365,11 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
except OSError:
|
||||
return True
|
||||
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":
|
||||
return (
|
||||
".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 "Console-style tables" 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":
|
||||
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 "?v=9" 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 "Install guide theme surfaces" not in style
|
||||
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Subscription
|
||||
|
||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||
|
||||
|
||||
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model_columns = Subscription.__mapper__.columns.keys()
|
||||
@@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid(
|
||||
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(
|
||||
session: AsyncSession, user_id: int
|
||||
) -> List[Subscription]:
|
||||
|
||||
@@ -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] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track when lifetime traffic usage was last synced from panel",
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ class Subscription(Base):
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
panel_user_uuid = Column(String, nullable=False, index=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)
|
||||
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
duration_months = Column(Integer, nullable=True)
|
||||
|
||||
Generated
+300
-2
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "remnawave-tg-shop",
|
||||
"name": "frontend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@internationalized/date": "^3.12.1",
|
||||
@@ -1847,6 +1850,30 @@
|
||||
"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": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
@@ -1915,6 +1942,15 @@
|
||||
"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": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -1928,6 +1964,17 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1938,6 +1985,24 @@
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2028,6 +2102,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.21.0",
|
||||
"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_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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2560,6 +2655,15 @@
|
||||
"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": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -3115,11 +3219,19 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3155,6 +3267,15 @@
|
||||
"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": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
@@ -3339,6 +3460,38 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
||||
@@ -3411,6 +3564,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -3444,6 +3603,32 @@
|
||||
"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": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
|
||||
@@ -3748,6 +3933,12 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -3758,6 +3949,113 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -38,5 +38,8 @@
|
||||
"tailwindcss": "4.3.0",
|
||||
"uplot": "^1.6.32",
|
||||
"vite": "^8.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
+164
-6
@@ -3,6 +3,7 @@
|
||||
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
|
||||
import { createBillingStore } from "./lib/webapp/stores/billingStore.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 { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
@@ -15,6 +16,7 @@
|
||||
import TariffDialogs from "./webapp/TariffDialogs.svelte";
|
||||
import DevicesScreen from "./webapp/screens/DevicesScreen.svelte";
|
||||
import HomeScreen from "./webapp/screens/HomeScreen.svelte";
|
||||
import InstallGuideScreen from "./webapp/screens/InstallGuideScreen.svelte";
|
||||
import InviteScreen from "./webapp/screens/InviteScreen.svelte";
|
||||
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
|
||||
import SupportScreen from "./webapp/screens/SupportScreen.svelte";
|
||||
@@ -71,6 +73,7 @@
|
||||
adminSectionFromPath,
|
||||
adminUserIdFromPath,
|
||||
normalizeSection,
|
||||
publicInstallTokenFromPath,
|
||||
sectionFromPath,
|
||||
supportTicketIdFromPath,
|
||||
syncSectionPath,
|
||||
@@ -99,6 +102,8 @@
|
||||
let activeTab = "home";
|
||||
let screen = "home";
|
||||
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
||||
let publicInstallSubscription = null;
|
||||
let publicInstallToken = "";
|
||||
let trialBusy = false;
|
||||
let promoCode = "";
|
||||
let promoBusy = false;
|
||||
@@ -182,6 +187,7 @@
|
||||
});
|
||||
const devicesStore = createDevicesStore({ api, t, showToast });
|
||||
const supportStore = createSupportStore({ api, t, showToast });
|
||||
const installGuidesStore = createInstallGuidesStore({ api, t, showToast });
|
||||
const accountStore = createAccountStore({
|
||||
api,
|
||||
publicApi,
|
||||
@@ -207,6 +213,7 @@
|
||||
setContext("billingStore", billingStore);
|
||||
setContext("devicesStore", devicesStore);
|
||||
setContext("supportStore", supportStore);
|
||||
setContext("installGuidesStore", installGuidesStore);
|
||||
setContext("accountStore", accountStore);
|
||||
|
||||
$: ({
|
||||
@@ -300,6 +307,7 @@
|
||||
: plans;
|
||||
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
|
||||
$: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
|
||||
$: installGuidesEnabled = Boolean(appSettings?.subscription_guides_enabled);
|
||||
$: supportStore.setActive(Boolean(mode === "app" && screen === "support" && supportEnabled));
|
||||
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
|
||||
$: 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(() => {
|
||||
if (isPreviewBoard) return;
|
||||
const onAnyPointerDown = () => {
|
||||
if (mode === "login") loginEmailTooltipOpen = false;
|
||||
};
|
||||
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);
|
||||
if (mode === "login") {
|
||||
setPasswordLoginMode(isPasswordLoginPath(), true);
|
||||
@@ -509,14 +534,17 @@
|
||||
? "home"
|
||||
: section === "support" && !supportEnabled
|
||||
? "home"
|
||||
: section;
|
||||
activeTab = nextSection;
|
||||
: section === "install" && !canUseInstallGuides()
|
||||
? "home"
|
||||
: section;
|
||||
activeTab = nextSection === "install" ? "home" : nextSection;
|
||||
screen = nextSection;
|
||||
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
|
||||
if (nextSection === "support") {
|
||||
supportStore.loadList();
|
||||
supportStore.startPolling({ includeList: true });
|
||||
}
|
||||
if (nextSection === "install") installGuidesStore.load(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
@@ -701,12 +729,12 @@
|
||||
await appendStylesheetWithFallback(
|
||||
"subscription-webapp-admin-css",
|
||||
cssHref,
|
||||
"subscription_webapp_admin.css",
|
||||
"subscription_webapp_admin.css"
|
||||
);
|
||||
await appendScriptWithFallback(
|
||||
"subscription-webapp-admin-js",
|
||||
jsSrc,
|
||||
"subscription_webapp_admin.js",
|
||||
"subscription_webapp_admin.js"
|
||||
);
|
||||
const loaded = readAdminBundleApi();
|
||||
if (!loaded) throw new Error("admin_bundle_missing_mount");
|
||||
@@ -776,6 +804,11 @@
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const shareToken = publicInstallTokenFromPath(window.location.pathname);
|
||||
if (shareToken) {
|
||||
await loadPublicInstall(shareToken);
|
||||
return;
|
||||
}
|
||||
await runWebappBoot({
|
||||
MOCK,
|
||||
setMode: (next) => {
|
||||
@@ -866,6 +899,12 @@
|
||||
if (section === "support" && payload.settings?.support_tickets_enabled === false) {
|
||||
section = "home";
|
||||
}
|
||||
if (
|
||||
section === "install" &&
|
||||
!(payload.settings?.subscription_guides_enabled && payload.subscription?.active)
|
||||
) {
|
||||
section = "home";
|
||||
}
|
||||
const initialAdminSection =
|
||||
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
|
||||
if (section === "admin" && payload.user?.is_admin) {
|
||||
@@ -881,7 +920,7 @@
|
||||
}
|
||||
const initialSupportTicketId =
|
||||
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
|
||||
activeTab = section === "admin" ? "settings" : section;
|
||||
activeTab = section === "admin" ? "settings" : section === "install" ? "home" : section;
|
||||
screen = section;
|
||||
mode = "app";
|
||||
if (payload.settings?.support_tickets_enabled !== false) {
|
||||
@@ -907,6 +946,9 @@
|
||||
if (section === "devices" && payload.settings?.my_devices_enabled) {
|
||||
await devicesStore.loadDevices(true);
|
||||
}
|
||||
if (section === "install") {
|
||||
await installGuidesStore.load(true);
|
||||
}
|
||||
if (section === "support") {
|
||||
if (initialSupportTicketId)
|
||||
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() {
|
||||
mode = "login";
|
||||
screen = "login";
|
||||
@@ -1002,6 +1057,44 @@
|
||||
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() {
|
||||
const url = subscription?.connect_url || subscription?.config_link;
|
||||
if (!url) {
|
||||
@@ -1011,6 +1104,23 @@
|
||||
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")) {
|
||||
if (!value) {
|
||||
showToast(t("wa_unavailable"));
|
||||
@@ -1092,6 +1202,18 @@
|
||||
syncSectionPath("home");
|
||||
}
|
||||
|
||||
function goInstall() {
|
||||
if (!canUseInstallGuides()) {
|
||||
openConnectLink();
|
||||
return;
|
||||
}
|
||||
billingStore.closePaymentModal();
|
||||
activeTab = "home";
|
||||
screen = "install";
|
||||
syncSectionPath("install");
|
||||
installGuidesStore.load(true);
|
||||
}
|
||||
|
||||
function goInvite() {
|
||||
billingStore.closePaymentModal();
|
||||
activeTab = "invite";
|
||||
@@ -1228,6 +1350,7 @@
|
||||
|
||||
async function handleAdminPersistedSaved(options = {}) {
|
||||
invalidateWebappTariffOptionCaches(billingStore);
|
||||
installGuidesStore.reset();
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
@@ -1277,6 +1400,28 @@
|
||||
<BrandMark {brand} size="md" />
|
||||
<div>{t("wa_loading")}</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"}
|
||||
<AuthScreen
|
||||
{screen}
|
||||
@@ -1365,7 +1510,7 @@
|
||||
{trafficMode}
|
||||
{trialBusy}
|
||||
{activateTrial}
|
||||
{openConnectLink}
|
||||
openConnectLink={openInstallOrConnect}
|
||||
{openPaymentModal}
|
||||
{openRegularTopupModal}
|
||||
{openPremiumTopupModal}
|
||||
@@ -1373,6 +1518,19 @@
|
||||
{primaryPayActionLabel}
|
||||
{t}
|
||||
/>
|
||||
{:else if screen === "install"}
|
||||
<InstallGuideScreen
|
||||
{currentLang}
|
||||
telegramPlatform={tg?.platform || ""}
|
||||
{user}
|
||||
{subscription}
|
||||
{goHome}
|
||||
{openConnectLink}
|
||||
{openExternalLink}
|
||||
{openAppLink}
|
||||
{copyText}
|
||||
{t}
|
||||
/>
|
||||
{:else if screen === "invite"}
|
||||
<InviteScreen
|
||||
{referral}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<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 { Accordion, Switch } from "$components/ui/primitives.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
@@ -131,6 +140,17 @@
|
||||
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) {
|
||||
const normalized = String(path || "").trim();
|
||||
if (!normalized) return "";
|
||||
@@ -227,6 +247,7 @@
|
||||
notifications: "Уведомления",
|
||||
support: "Поддержка",
|
||||
devices: "Устройства",
|
||||
subscription_guides: "Connection guides",
|
||||
};
|
||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||
}
|
||||
@@ -264,7 +285,9 @@
|
||||
|
||||
function fieldPlaceholderText(field) {
|
||||
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) {
|
||||
@@ -419,6 +442,41 @@
|
||||
value={valueFor(field) ?? ""}
|
||||
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
></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}
|
||||
<input
|
||||
class="input"
|
||||
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
@@ -50,6 +51,7 @@ export {
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
Share2,
|
||||
Shield,
|
||||
Sliders,
|
||||
Smartphone,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const LANGUAGE_FLAGS = {
|
||||
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
|
||||
export const APP_SECTION_PATHS = {
|
||||
home: "/home",
|
||||
install: "/install",
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
support: "/support",
|
||||
|
||||
@@ -604,6 +604,17 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
}
|
||||
if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
|
||||
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/verify" || path === "/auth/email/magic") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
|
||||
@@ -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 = {
|
||||
config: {
|
||||
title: "/minishop",
|
||||
@@ -103,6 +278,9 @@ export const DEV_MOCK = {
|
||||
days_left: 25,
|
||||
config_link: "https://sub.example.com/sub/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_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
@@ -120,6 +298,12 @@ export const DEV_MOCK = {
|
||||
can_topup_premium_traffic: true,
|
||||
max_devices: 5,
|
||||
},
|
||||
subscription_guides: {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
config: INSTALL_GUIDES_CONFIG,
|
||||
source: "mock",
|
||||
},
|
||||
devices: {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
@@ -227,6 +411,7 @@ export const DEV_MOCK = {
|
||||
trial_traffic_strategy: "NO_RESET",
|
||||
subscription_purchase_description:
|
||||
"Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.",
|
||||
subscription_guides_enabled: true,
|
||||
email_auth_enabled: true,
|
||||
},
|
||||
},
|
||||
@@ -250,6 +435,16 @@ export function applyPreviewMock(kind) {
|
||||
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") {
|
||||
DEV_MOCK.data.settings.traffic_mode = true;
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
|
||||
@@ -6,6 +6,7 @@ export function normalizeSection(value) {
|
||||
.toLowerCase();
|
||||
if (
|
||||
section === "invite" ||
|
||||
section === "install" ||
|
||||
section === "devices" ||
|
||||
section === "support" ||
|
||||
section === "settings" ||
|
||||
@@ -28,6 +29,14 @@ export function sectionFromPath(pathname) {
|
||||
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) {
|
||||
const normalized = String(pathname || "")
|
||||
.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,
|
||||
};
|
||||
}
|
||||
@@ -233,6 +233,31 @@
|
||||
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 {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
@@ -26,6 +26,33 @@ a {
|
||||
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 {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
@@ -2269,6 +2296,26 @@ a {
|
||||
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. */
|
||||
.phone-screen.auth-screen {
|
||||
width: min(100%, 460px);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div class="brand-row">
|
||||
<BrandMark {brand} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+33
-1
@@ -50,6 +50,8 @@
|
||||
"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",
|
||||
"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",
|
||||
"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>",
|
||||
@@ -1600,5 +1602,35 @@
|
||||
"admin_sort_updated_desc": "Newest activity",
|
||||
"admin_sort_updated_asc": "Oldest activity",
|
||||
"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
@@ -50,6 +50,8 @@
|
||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||
"back_to_payment_methods_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})",
|
||||
"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} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||
@@ -1600,5 +1602,35 @@
|
||||
"admin_sort_updated_desc": "Сначала новые",
|
||||
"admin_sort_updated_asc": "Сначала старые",
|
||||
"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_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]:
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,12 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -39,3 +45,33 @@ class MiniAppUrlTests(unittest.TestCase):
|
||||
subscription_mini_app_topup_url(s, "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",
|
||||
)
|
||||
|
||||
@@ -79,6 +79,52 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
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):
|
||||
service = self._make_service()
|
||||
get_calls = 0
|
||||
|
||||
@@ -32,6 +32,24 @@ class SettingsTests(unittest.TestCase):
|
||||
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
|
||||
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):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -8,6 +8,7 @@ from bot.handlers.user import referral
|
||||
from bot.handlers.user.subscription.core import _with_subscription_purchase_description
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_bot_interface_inline_keyboard,
|
||||
get_connect_and_main_keyboard,
|
||||
get_information_links_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
@@ -41,6 +42,13 @@ class UserBotMenuTests(unittest.TestCase):
|
||||
TERMS_OF_SERVICE_URL="",
|
||||
TRIAL_ENABLED=True,
|
||||
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):
|
||||
@@ -70,6 +78,38 @@ class UserBotMenuTests(unittest.TestCase):
|
||||
self.assertIn("main_action:bot_info", 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):
|
||||
subscription_markup = get_subscription_options_keyboard(
|
||||
{1: 100},
|
||||
|
||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
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):
|
||||
@@ -14,8 +14,8 @@ class WebappRedisCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def fake_delete(_settings, *keys):
|
||||
deleted.extend(keys)
|
||||
|
||||
with patch.object(common_module, "cache_delete", fake_delete):
|
||||
await common_module._invalidate_webapp_user_caches(
|
||||
with patch.object(cache_helpers, "cache_delete", fake_delete):
|
||||
await cache_helpers.invalidate_webapp_user_caches(
|
||||
settings,
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -50,6 +50,8 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/"): "index_route",
|
||||
("GET", "/login/password"): "index_route",
|
||||
("GET", "/home"): "index_route",
|
||||
("GET", "/install"): "index_route",
|
||||
("GET", "/s/{share_token}"): "index_route",
|
||||
("GET", "/invite"): "index_route",
|
||||
("GET", "/devices"): "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/logout"): "logout_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",
|
||||
("POST", "/api/account/language"): "account_language_route",
|
||||
("POST", "/api/account/email/request"): "account_email_request_route",
|
||||
|
||||
@@ -33,13 +33,14 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
self.assertEqual(win95.tokens.style_preset, "win95")
|
||||
self.assertFalse(win95.use_primary_accent)
|
||||
self.assertTrue(win95.use_in_admin)
|
||||
self.assertEqual(win95.assets_version, 9)
|
||||
self.assertEqual(win95.assets_version, 11)
|
||||
self.assertEqual(cfg.theme_by_key("light").assets_version, 3)
|
||||
ascii_theme = cfg.theme_by_key("ascii")
|
||||
self.assertIsNotNone(ascii_theme)
|
||||
self.assertEqual(ascii_theme.css_file, "style.css")
|
||||
self.assertFalse(ascii_theme.use_primary_accent)
|
||||
self.assertTrue(ascii_theme.use_in_admin)
|
||||
self.assertEqual(ascii_theme.assets_version, 3)
|
||||
self.assertEqual(ascii_theme.assets_version, 4)
|
||||
|
||||
def test_env_override_default_theme(self):
|
||||
cfg = builtin_webapp_themes_config("#00fe7a")
|
||||
@@ -381,7 +382,7 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
descriptor["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-earth", css)
|
||||
self.assertIn("lucide-circle-check", css)
|
||||
@@ -391,7 +392,9 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
self.assertIn("::-webkit-slider-thumb", css)
|
||||
self.assertIn("?v=9", 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("Install guide theme surfaces", css)
|
||||
self.assertIn(
|
||||
".theme-key-windows95 .support-list-card {\n grid-template-rows: auto auto minmax(0, 1fr);",
|
||||
css,
|
||||
@@ -429,3 +432,39 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
|
||||
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user