feat: install instruction inside web app

This commit is contained in:
3252a8
2026-05-22 13:58:34 +03:00
parent 8a38524774
commit 9e61a3d8a8
41 changed files with 8216 additions and 72 deletions
+27 -22
View File
@@ -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:
@@ -26,12 +32,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)
sections[section_id]["fields"].append(response_field)
@@ -51,6 +70,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,
@@ -65,26 +90,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"] = {}
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)})
+3 -10
View File
@@ -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)
+57 -1
View File
@@ -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,49 @@ 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_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 +497,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
@@ -519,6 +574,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,
@@ -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()
+46 -1
View File
@@ -2,13 +2,31 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional
from bot.infra.redis import cache_delete, redis_key
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
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,
@@ -55,6 +73,19 @@ def invalidate_local_webapp_user_payload(
cache.invalidate(key)
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
namespaces = set(_payload_namespaces(include_devices))
for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
if settings_id == id(settings) and cache_namespace in namespaces:
cache.invalidate()
async def invalidate_webapp_user_caches(
settings: Settings,
*user_ids: Optional[int],
@@ -79,3 +110,17 @@ async def invalidate_webapp_user_caches(
invalidate_local_webapp_user_payload(settings, "devices", user_id)
if keys:
await cache_delete(settings, *keys)
async def invalidate_all_webapp_user_payloads(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
invalidate_all_local_webapp_user_payloads(settings, include_devices=include_devices)
for namespace in _payload_namespaces(include_devices):
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
continue
+2 -20
View File
@@ -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:
+280
View File
@@ -0,0 +1,280 @@
# 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:
short_uuid = _normalize_short_uuid(request.match_info.get("short_uuid"))
if not short_uuid:
return web.json_response({"ok": False, "error": "invalid_short_uuid"}, status=404)
status = await _subscription_guides_status_shared(request.app)
subscription = await _public_subscription_payload(request, short_uuid)
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,
short_uuid: str,
) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app)
raw_link = ""
username = ""
resolved_short_uuid = 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_panel_subscription_uuid(
session,
short_uuid,
)
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 short_uuid).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,
"username": username,
"share_url": _public_install_url(request, resolved_short_uuid),
}
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 _normalize_short_uuid(value: Any) -> str:
short_uuid = str(value or "").strip()
if not re.fullmatch(r"[A-Za-z0-9_-]{8,128}", short_uuid):
return ""
return short_uuid
def _public_install_url(request: web.Request, short_uuid: 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('/')}/install/share/{quote(short_uuid)}"
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
headers = request.headers
host = headers.get("X-Forwarded-Host") or headers.get("Host") or request.host
proto = headers.get("X-Forwarded-Proto") or request.scheme or "https"
user_agent = headers.get(
"User-Agent",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari",
)
return {
"host": host,
"x-forwarded-host": host,
"x-forwarded-proto": proto,
"user-agent": user_agent,
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": headers.get("Accept-Language", "ru,en;q=0.9"),
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"upgrade-insecure-requests": "1",
}
+7
View File
@@ -6,6 +6,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/login/password", index_route)
app.router.add_get("/home", index_route)
app.router.add_get("/install", index_route)
app.router.add_get(r"/install/share/{short_uuid:[A-Za-z0-9_-]{8,128}}", 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/{short_uuid:[A-Za-z0-9_-]{8,128}}",
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)
+35 -1
View File
@@ -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
@@ -82,7 +83,7 @@ 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),
"referral": {
"code": referral_code,
"bot_link": referral_link,
@@ -132,6 +133,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,6 +181,7 @@ def _build_webapp_referral_link(
def _serialize_subscription(
request: web.Request,
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
@@ -192,6 +195,8 @@ def _serialize_subscription(
"days_left": 0,
"config_link": None,
"connect_url": None,
"panel_short_uuid": None,
"install_share_url": None,
}
end_date = active.get("end_date")
@@ -231,6 +236,7 @@ def _serialize_subscription(
can_topup_traffic = False
can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -240,6 +246,8 @@ 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_url": _build_install_share_link(request, settings, panel_short_uuid),
"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 +292,32 @@ def _serialize_subscription(
}
def _build_install_share_link(
request: web.Request,
settings: Settings,
short_uuid: str,
) -> Optional[str]:
short_uuid = str(short_uuid or "").strip()
if not short_uuid:
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('/')}/install/share/{quote(short_uuid)}"
def _serialize_plans(
settings: Settings,
lang: str,
+48
View File
@@ -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,
File diff suppressed because one or more lines are too long
+20
View File
@@ -323,6 +323,26 @@ 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_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()
+300 -2
View File
@@ -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",
+3
View File
@@ -38,5 +38,8 @@
"tailwindcss": "4.3.0",
"uplot": "^1.6.32",
"vite": "^8.0.12"
},
"dependencies": {
"qrcode": "^1.5.4"
}
}
+110 -4
View File
@@ -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,
publicInstallShortUuidFromPath,
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 publicInstallShortUuid = "";
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(
@@ -476,12 +484,27 @@
}
}
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 publicShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
if (publicShortUuid) {
void loadPublicInstall(publicShortUuid);
return;
}
if (mode === "publicInstall") {
void boot();
return;
}
const section = sectionFromPath(window.location.pathname);
if (mode === "login") {
setPasswordLoginMode(isPasswordLoginPath(), true);
@@ -508,14 +531,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);
@@ -741,6 +767,11 @@
}
async function boot() {
const shareShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
if (!MOCK && shareShortUuid) {
await loadPublicInstall(shareShortUuid);
return;
}
await runWebappBoot({
MOCK,
setMode: (next) => {
@@ -831,6 +862,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) {
@@ -846,7 +883,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) {
@@ -872,6 +909,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 });
@@ -912,6 +952,20 @@
}
}
async function loadPublicInstall(shortUuid) {
mode = "publicInstall";
screen = "install";
activeTab = "home";
publicInstallShortUuid = shortUuid;
publicInstallSubscription = {
panel_short_uuid: shortUuid,
share_url:
typeof window !== "undefined" ? `${window.location.origin}/install/share/${shortUuid}` : "",
};
const response = await installGuidesStore.loadPublic(shortUuid, true);
publicInstallSubscription = response?.subscription || publicInstallSubscription;
}
function showLogin() {
mode = "login";
screen = "login";
@@ -976,6 +1030,14 @@
openExternalLink(url);
}
function openInstallOrConnect() {
if (canUseInstallGuides()) {
goInstall();
return;
}
openConnectLink();
}
async function copyText(value, success = t("wa_copied")) {
if (!value) {
showToast(t("wa_unavailable"));
@@ -1057,6 +1119,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";
@@ -1193,6 +1267,7 @@
async function handleAdminPersistedSaved(options = {}) {
invalidateWebappTariffOptionCaches(billingStore);
installGuidesStore.reset();
try {
await loadData();
} catch {
@@ -1242,6 +1317,25 @@
<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 || { panel_short_uuid: publicInstallShortUuid }}
{goHome}
{openConnectLink}
{openExternalLink}
{copyText}
{t}
publicMode
/>
</div>
{:else if mode === "login"}
<AuthScreen
{screen}
@@ -1330,7 +1424,7 @@
{trafficMode}
{trialBusy}
{activateTrial}
{openConnectLink}
openConnectLink={openInstallOrConnect}
{openPaymentModal}
{openRegularTopupModal}
{openPremiumTopupModal}
@@ -1338,6 +1432,18 @@
{primaryPayActionLabel}
{t}
/>
{:else if screen === "install"}
<InstallGuideScreen
{currentLang}
telegramPlatform={tg?.platform || ""}
{user}
{subscription}
{goHome}
{openConnectLink}
{openExternalLink}
{copyText}
{t}
/>
{:else if screen === "invite"}
<InviteScreen
{referral}
@@ -1,5 +1,5 @@
<script>
import { ChevronRight, Eye, EyeOff, Search, X } from "$components/ui/icons.js";
import { ChevronRight, 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";
@@ -123,6 +123,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 groupSectionFields(section) {
const groups = new Map();
for (const field of section.fields || []) {
@@ -164,6 +175,7 @@
notifications: "Уведомления",
support: "Поддержка",
devices: "Устройства",
subscription_guides: "Connection guides",
};
return adminText(`settings_section_${id}`, {}, map[id] || id);
}
@@ -315,6 +327,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"
+2
View File
@@ -38,6 +38,7 @@ export {
Menu,
MessageSquare,
MessageSquarePlus,
Monitor,
MousePointerClick,
Paintbrush,
Plus,
@@ -50,6 +51,7 @@ export {
Send,
Server,
Settings,
Share2,
Shield,
Sliders,
Smartphone,
+1
View File
@@ -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",
+7
View File
@@ -604,6 +604,13 @@ 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/")) {
return {
...clone(DEV_MOCK.data.subscription_guides),
subscription: clone(DEV_MOCK.data.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" };
+191
View File
@@ -23,6 +23,178 @@ 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 +275,8 @@ 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_url: "https://app.example.com/install/share/preview-token",
traffic_used: "18.4 GB",
traffic_limit: "100 GB",
traffic_used_bytes: 19756849561,
@@ -120,6 +294,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 +407,7 @@ export const DEV_MOCK = {
trial_traffic_strategy: "NO_RESET",
subscription_purchase_description:
"Покупая или продлевая подписку, вы получаете доступ к VPN/прокси-сервису, который помогает защищать ваше соединение и поддерживать стабильный доступ к сети.",
subscription_guides_enabled: true,
email_auth_enabled: true,
},
},
@@ -250,6 +431,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;
+7
View File
@@ -6,6 +6,7 @@ export function normalizeSection(value) {
.toLowerCase();
if (
section === "invite" ||
section === "install" ||
section === "devices" ||
section === "support" ||
section === "settings" ||
@@ -28,6 +29,12 @@ export function sectionFromPath(pathname) {
return normalizeSection(section);
}
export function publicInstallShortUuidFromPath(pathname) {
const normalized = String(pathname || "").trim().replace(/\/+$/, "");
const match = normalized.match(/^\/install\/share\/([A-Za-z0-9_-]{8,128})$/);
return match ? match[1] : "";
}
export function adminSectionFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
@@ -0,0 +1,91 @@
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, 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(shortUuid, force = false) {
const encoded = encodeURIComponent(String(shortUuid || ""));
return fetchGuides(`/subscription-guides/public/${encoded}`, force);
}
function reset() {
inFlight = null;
state.set({
enabled: false,
config: null,
source: null,
subscription: null,
error: "",
loading: false,
loaded: false,
});
}
return {
subscribe: state.subscribe,
set: state.set,
update: state.update,
load,
loadPublic,
reset,
};
}
+25
View File
@@ -233,6 +233,31 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.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;
+47
View File
@@ -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;
@@ -2214,6 +2241,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);
+1 -1
View File
@@ -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} />
@@ -0,0 +1,932 @@
<script>
import { getContext, onMount } from "svelte";
import QRCode from "qrcode";
import {
ArrowLeft,
Check,
ChevronsUpDown,
Copy,
ExternalLink,
Monitor,
QrCode,
Share2,
Smartphone,
} from "$components/ui/icons.js";
import { AttentionDot } from "$components/ui/index.js";
import { Select } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
export let currentLang = "ru";
export let telegramPlatform = "";
export let user = {};
export let subscription = {};
export let goHome = () => {};
export let openConnectLink = () => {};
export let openExternalLink = () => {};
export let copyText = async () => {};
export let t = (key, _params = {}, fallback = "") => fallback || key;
export let publicMode = false;
const installGuidesStore = getContext("installGuidesStore");
const colorTokens = {
amber: "#f59e0b",
blue: "#3b82f6",
cyan: "#06b6d4",
emerald: "#10b981",
fuchsia: "#d946ef",
gray: "#6b7280",
green: "#22c55e",
indigo: "#6366f1",
lime: "#84cc16",
neutral: "#737373",
orange: "#f97316",
pink: "#ec4899",
purple: "#a855f7",
red: "#ef4444",
rose: "#f43f5e",
sky: "#0ea5e9",
slate: "#64748b",
stone: "#78716c",
teal: "#14b8a6",
violet: "#8b5cf6",
yellow: "#eab308",
zinc: "#71717a",
};
let selectedPlatformKey = "";
let selectedAppIndex = 0;
let qrDataUrl = "";
let lastQrValue = "";
let qrRequestId = 0;
onMount(() => {
if (!publicMode) installGuidesStore?.load();
});
$: guideState = $installGuidesStore;
$: config = guideState?.config || null;
$: platforms = Object.entries(config?.platforms || {})
.filter(([, platform]) => Array.isArray(platform?.apps) && platform.apps.length)
.map(([key, platform]) => ({ key, ...platform }));
$: platformOptions = platforms.map((platform) => ({
value: platform.key,
label: localized(platform.displayName, platform.key),
}));
$: detectedPlatformKey = detectPlatformKey(platforms.map((platform) => platform.key));
$: if (platforms.length && !selectedPlatformKey) {
selectedPlatformKey = detectedPlatformKey || platforms[0].key;
}
$: if (selectedPlatformKey && platforms.length && !platforms.some((p) => p.key === selectedPlatformKey)) {
selectedPlatformKey = platforms[0].key;
}
$: selectedPlatform =
platforms.find((platform) => platform.key === selectedPlatformKey) || platforms[0] || null;
$: selectedPlatformLabel = selectedPlatform
? localized(selectedPlatform.displayName, selectedPlatform.key)
: "";
$: apps = selectedPlatform?.apps || [];
$: if (selectedAppIndex >= apps.length) selectedAppIndex = 0;
$: selectedApp = apps[selectedAppIndex] || apps[0] || null;
$: guideSubscription = guideState?.subscription || subscription || {};
$: finalSubscriptionLink =
guideSubscription?.config_link || guideSubscription?.connect_url || subscription?.config_link || "";
$: shareUrl = guideSubscription?.share_url || subscription?.install_share_url || "";
$: if (finalSubscriptionLink !== lastQrValue) {
lastQrValue = finalSubscriptionLink;
updateQr(finalSubscriptionLink);
}
function localized(value, fallback = "") {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return fallback;
const lang = String(currentLang || "ru").split("-")[0].toLowerCase();
return (
value[lang] ||
value.ru ||
value.en ||
Object.values(value).find((item) => typeof item === "string" && item.trim()) ||
fallback
);
}
function iconSvg(key) {
const iconKey = String(key || "").trim();
return iconKey ? config?.svgLibrary?.[iconKey] || "" : "";
}
function iconColorStyle(color) {
const raw = String(color || "").trim();
const value = colorTokens[raw] || raw;
return value ? `--install-icon-color:${value};` : "";
}
function selectPlatform(key) {
selectedPlatformKey = key;
selectedAppIndex = 0;
}
function platformFallbackIcon(key) {
return key === "ios" || key === "android" || key === "androidTV" ? Smartphone : Monitor;
}
function detectPlatformKey(availableKeys) {
const available = new Set(availableKeys || []);
const tgPlatform = String(telegramPlatform || "").toLowerCase();
const nav = typeof navigator === "undefined" ? {} : navigator;
const userAgentDataPlatform = String(nav?.userAgentData?.platform || "").toLowerCase();
const ua = String(nav?.userAgent || "").toLowerCase();
const candidates = [];
if (tgPlatform.includes("ios")) candidates.push("ios");
if (tgPlatform.includes("android")) candidates.push("android");
if (tgPlatform.includes("mac")) candidates.push("macos");
if (tgPlatform.includes("windows")) candidates.push("windows");
if (tgPlatform.includes("linux")) candidates.push("linux");
if (userAgentDataPlatform.includes("android")) candidates.push("android");
if (userAgentDataPlatform.includes("ios")) candidates.push("ios");
if (userAgentDataPlatform.includes("mac")) candidates.push("macos");
if (userAgentDataPlatform.includes("win")) candidates.push("windows");
if (userAgentDataPlatform.includes("linux")) candidates.push("linux");
if (ua.includes("apple tv")) candidates.push("appleTV");
if (ua.includes("android") && /\btv\b|aft|bravia|shield/i.test(ua)) candidates.push("androidTV");
if (/iphone|ipad|ipod/.test(ua)) candidates.push("ios");
if (ua.includes("android")) candidates.push("android");
if (ua.includes("windows")) candidates.push("windows");
if (ua.includes("macintosh") || ua.includes("mac os")) candidates.push("macos");
if (ua.includes("linux") || ua.includes("x11")) candidates.push("linux");
return candidates.find((candidate) => available.has(candidate)) || "";
}
function templateValues() {
const subscriptionLink = subscription?.config_link || subscription?.connect_url || "";
const username = user?.username || user?.first_name || user?.id || "";
return {
HAPP_CRYPT3_LINK: subscriptionLink,
HAPP_CRYPT4_LINK: subscriptionLink,
SUBSCRIPTION_LINK: subscriptionLink,
USERNAME: username,
};
}
function resolveTemplate(value) {
const replacements = templateValues();
return String(value || "").replace(/\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (_match, key) =>
Object.prototype.hasOwnProperty.call(replacements, key) ? replacements[key] : ""
);
}
function isUnsafeUrl(value) {
const url = String(value || "").trim().toLowerCase();
return !url || hasControlChars(url) || /^(javascript|data|vbscript):/.test(url);
}
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function openResolvedLink(url) {
if (isUnsafeUrl(url)) {
openConnectLink();
return;
}
if (/^https?:\/\//i.test(url)) {
openExternalLink(url);
return;
}
window.location.assign(url);
}
async function handleButton(button) {
const value = resolveTemplate(button?.link);
if (button?.type === "copyButton") {
await copyText(
value,
localized(config?.baseTranslations?.linkCopiedToClipboard, t("wa_copied", {}, "Copied"))
);
return;
}
openResolvedLink(value);
}
async function updateQr(value) {
const link = String(value || "").trim();
const requestId = ++qrRequestId;
if (!link) {
qrDataUrl = "";
return;
}
try {
const url = await QRCode.toDataURL(link, {
errorCorrectionLevel: "M",
margin: 1,
width: 640,
color: {
dark: "#000000",
light: "#00000000",
},
});
if (requestId === qrRequestId) qrDataUrl = url;
} catch (_error) {
if (requestId === qrRequestId) qrDataUrl = "";
}
}
async function copySubscriptionLink() {
await copyText(finalSubscriptionLink, t("wa_install_link_copied", {}, "Link copied"));
}
async function shareInstallGuide() {
const url = shareUrl || (typeof window !== "undefined" ? window.location.href : "");
if (!url) return;
if (typeof navigator !== "undefined" && typeof navigator.share === "function") {
try {
await navigator.share({
title: localized(config?.baseTranslations?.installationGuideHeader, brandTitleFallback()),
url,
});
return;
} catch (_error) {
void _error;
}
}
await copyText(url, t("wa_install_share_copied", {}, "Share link copied"));
}
function brandTitleFallback() {
return t("wa_install_title", {}, "Install");
}
</script>
<main class="install-layout">
<div class="install-topbar" class:public={publicMode}>
{#if !publicMode}
<Button
class="install-back-btn"
variant="secondary"
size="icon"
onclick={goHome}
aria-label={t("wa_back", {}, "Back")}
>
<ArrowLeft size={21} />
</Button>
{/if}
<div>
<h1>{localized(config?.baseTranslations?.installationGuideHeader, t("wa_install_title", {}, "Install"))}</h1>
<p>{t("wa_install_subtitle", {}, "Choose your platform and app.")}</p>
</div>
{#if guideState?.enabled && config && platforms.length}
<div class="install-platform-topbar">
<Select.Root
type="single"
value={selectedPlatformKey}
items={platformOptions}
onValueChange={selectPlatform}
>
<Select.Trigger
class="install-platform-trigger"
aria-label={t("wa_install_platform", {}, "Platform")}
>
<span class="install-platform-trigger-main">
{#if selectedPlatform}
{@const SelectedFallbackIcon = platformFallbackIcon(selectedPlatform.key)}
{#if iconSvg(selectedPlatform.svgIconKey)}
<span class="install-svg" aria-hidden="true">{@html iconSvg(selectedPlatform.svgIconKey)}</span>
{:else}
<svelte:component this={SelectedFallbackIcon} size={19} />
{/if}
{/if}
<span>{selectedPlatformLabel}</span>
</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content class="install-platform-content" side="bottom" align="start" sideOffset={6}>
<Select.Viewport class="install-platform-viewport">
{#each platforms as platform}
{@const PlatformFallbackIcon = platformFallbackIcon(platform.key)}
<Select.Item
value={platform.key}
label={localized(platform.displayName, platform.key)}
class="install-platform-item"
>
<span class="install-platform-item-main">
{#if iconSvg(platform.svgIconKey)}
<span class="install-svg" aria-hidden="true">{@html iconSvg(platform.svgIconKey)}</span>
{:else}
<svelte:component this={PlatformFallbackIcon} size={18} />
{/if}
<span>{localized(platform.displayName, platform.key)}</span>
</span>
<Check size={15} class="install-platform-item-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
{#if guideState?.loading && !guideState?.loaded}
<Card class="install-empty">
<p>{t("wa_install_loading", {}, "Loading instructions...")}</p>
</Card>
{:else if !guideState?.enabled || !config || !platforms.length}
<Card class="install-empty">
<p>{t("wa_install_unavailable", {}, "Instructions are unavailable.")}</p>
<Button class="wide" onclick={openConnectLink}>
<ExternalLink size={18} />
{t("wa_install_and_configure")}
</Button>
</Card>
{:else}
{#if apps.length > 1}
<section class="install-selector-block" aria-label={t("wa_install_app", {}, "App")}>
<div class="install-section-title">
<span>{t("wa_install_app", {}, "App")}</span>
</div>
<div
class="install-apps"
class:apps-mobile-remainder-one={apps.length % 2 === 1}
class:apps-remainder-one={apps.length % 3 === 1}
class:apps-remainder-two={apps.length % 3 === 2}
>
{#each apps as app, index}
<button
class="install-app-button attention-wrap"
class:active={selectedAppIndex === index}
class:featured={app.featured}
type="button"
onclick={() => (selectedAppIndex = index)}
>
{#if app.featured}
<AttentionDot class="install-feature-star" />
{/if}
{#if iconSvg(app.svgIconKey)}
<span class="install-svg" aria-hidden="true">{@html iconSvg(app.svgIconKey)}</span>
{/if}
<span>{app.name}</span>
</button>
{/each}
</div>
</section>
{/if}
{#if selectedApp}
<section class="install-steps" aria-label={selectedApp.name}>
{#each selectedApp.blocks as block}
<Card class="install-step">
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
{#if iconSvg(block.svgIconKey)}
{@html iconSvg(block.svgIconKey)}
{:else}
<Check size={19} />
{/if}
</div>
<div class="install-step-body">
<h2>{localized(block.title)}</h2>
<p>{localized(block.description)}</p>
{#if block.buttons?.length}
<div class="install-actions">
{#each block.buttons as button}
<Button
variant={button.type === "copyButton" ? "secondary" : "default"}
onclick={() => handleButton(button)}
>
{#if button.type === "copyButton"}
<Copy size={16} />
{:else}
<ExternalLink size={16} />
{/if}
{localized(button.text)}
</Button>
{/each}
</div>
{/if}
</div>
</Card>
{/each}
</section>
{#if finalSubscriptionLink && !publicMode}
<div class="install-qr-divider" aria-hidden="true">
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
<path
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
/>
</svg>
</div>
<Card class="install-subscription-card">
<div class="install-subscription-header">
<div class="install-subscription-header-icon" aria-hidden="true">
<QrCode size={20} />
</div>
<div class="install-subscription-heading">
<h2>{t("wa_install_subscription_link", {}, "Subscription link")}</h2>
<p>{t("wa_install_subscription_link_hint", {}, "Scan the QR code or copy the link.")}</p>
</div>
</div>
<div class="install-subscription-body">
{#if qrDataUrl}
<div class="install-qr-wrap">
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
</div>
{/if}
<div class="install-actions install-subscription-actions">
<Button variant="secondary" onclick={copySubscriptionLink}>
<Copy size={16} />
{t("wa_install_copy_subscription_link", {}, "Copy link")}
</Button>
<Button onclick={shareInstallGuide}>
<Share2 size={16} />
{t("wa_install_share", {}, "Share")}
</Button>
</div>
</div>
</Card>
{/if}
{/if}
{/if}
</main>
<style>
.install-layout {
display: grid;
gap: 16px;
padding: 18px 16px 96px;
}
.install-topbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.install-topbar.public {
grid-template-columns: minmax(0, 1fr);
}
:global(.install-back-btn) {
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border-color: var(--border-strong);
color: var(--text);
}
:global(.install-back-btn svg) {
width: 21px;
height: 21px;
stroke-width: 2.6;
}
.install-topbar h1 {
margin: 0;
color: var(--text);
font-size: 22px;
line-height: 1.15;
}
.install-platform-topbar {
grid-column: 1 / -1;
min-width: 0;
}
.install-topbar p,
:global(.install-empty) p,
.install-step-body p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
:global(.install-empty) {
display: grid;
gap: 14px;
}
.install-selector-block {
display: grid;
gap: 9px;
}
.install-section-title {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.install-apps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.install-apps.apps-mobile-remainder-one button:last-child {
grid-column: 1 / -1;
}
.install-apps button {
display: flex;
min-height: 48px;
align-items: center;
gap: 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 88%, transparent);
color: var(--text);
padding: 10px;
font: inherit;
text-align: left;
}
.install-apps button.active {
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
:global(.install-platform-trigger) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 88%, transparent);
color: var(--text);
padding: 0 12px;
font: inherit;
text-align: left;
box-shadow: var(--shadow-soft);
}
:global(.install-platform-trigger:focus-visible),
:global(.install-platform-trigger[data-state="open"]) {
outline: 0;
border-color: color-mix(in srgb, var(--accent) 68%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
:global(.install-platform-trigger > svg) {
color: var(--muted);
}
.install-platform-trigger-main,
.install-platform-item-main {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.install-platform-trigger-main > span:last-child,
.install-platform-item-main > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.install-platform-content) {
z-index: 140;
width: min(300px, calc(100vw - 32px));
min-width: min(300px, calc(100vw - 32px));
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel-3);
box-shadow: var(--shadow-popover);
overflow: hidden;
box-sizing: border-box;
animation: dropdown-enter 0.16s ease-out both;
}
:global(.install-platform-viewport) {
max-height: min(290px, 48dvh);
padding: 6px;
}
:global(.install-platform-item) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
border-radius: 6px;
padding: 8px 9px;
color: var(--text);
font-size: 13px;
cursor: pointer;
}
:global(.install-platform-item[data-highlighted]) {
background: var(--surface-hover);
}
:global(.install-platform-item-check) {
flex: 0 0 auto;
color: var(--accent);
opacity: 0;
}
:global(.install-platform-item[data-selected] .install-platform-item-check) {
opacity: 1;
}
.install-apps button {
position: relative;
align-items: flex-start;
flex-direction: column;
gap: 5px;
overflow: visible;
}
.install-apps button > span {
display: flex;
align-items: center;
gap: 8px;
}
:global(.install-feature-star.attention-dot) {
top: 8px;
right: 8px;
width: 16px;
min-width: 16px;
height: 16px;
border-radius: 0;
background: #facc15;
clip-path: polygon(
50% 0%,
61% 35%,
98% 35%,
68% 56%,
79% 91%,
50% 70%,
21% 91%,
32% 56%,
2% 35%,
39% 35%
);
transform: none;
animation: install-star-pulse 1.6s ease-out infinite;
}
.install-svg,
.install-step-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--install-icon-color, var(--accent));
}
.install-svg :global(svg),
.install-step-icon :global(svg) {
width: 19px;
height: 19px;
color: currentColor;
}
.install-steps {
display: grid;
gap: 10px;
}
:global(.install-step) {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
}
.install-qr-divider {
display: grid;
place-items: center;
height: 24px;
color: var(--border-strong);
opacity: 0.72;
}
.install-qr-divider svg {
display: block;
width: 100%;
height: 18px;
overflow: visible;
}
.install-qr-divider path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
}
:global(.install-subscription-card) {
display: grid;
gap: 12px;
justify-self: stretch;
}
.install-subscription-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 12px;
padding: 2px 0 12px;
border-bottom: 1px solid var(--border);
}
.install-subscription-header-icon {
display: inline-flex;
width: 38px;
height: 38px;
align-items: center;
justify-content: center;
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.install-subscription-header-icon :global(svg) {
width: 20px;
height: 20px;
}
.install-subscription-heading {
min-width: 0;
}
.install-subscription-heading h2 {
margin: 0 0 4px;
color: var(--text);
font-size: 16px;
line-height: 1.25;
}
.install-subscription-heading p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.install-subscription-body {
display: grid;
gap: 10px;
}
.install-qr-wrap {
display: grid;
width: 60%;
aspect-ratio: 1;
place-items: center;
justify-self: center;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
box-sizing: border-box;
}
.install-qr-wrap img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
:global(.theme-dark) .install-qr-wrap img {
filter: brightness(0) invert(1);
}
.install-step-icon {
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, currentColor 38%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.install-step-body {
display: grid;
align-content: center;
gap: 7px;
min-width: 0;
}
.install-step-body h2 {
margin: 0;
color: var(--text);
font-size: 16px;
line-height: 1.25;
}
.install-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 3px;
}
.install-actions :global(.btn) {
flex: 1 1 150px;
}
.install-subscription-actions {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
align-content: start;
padding-top: 0;
}
.install-subscription-actions :global(.btn) {
width: 100%;
flex: 0 0 auto;
}
@media (min-width: 520px) {
.install-apps {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.install-apps button,
.install-apps.apps-mobile-remainder-one button:last-child {
grid-column: span 2;
}
.install-apps.apps-remainder-one button:last-child {
grid-column: 1 / -1;
}
.install-apps.apps-remainder-two button:nth-last-child(-n + 2) {
grid-column: span 3;
}
}
@media (min-width: 1024px) {
.install-topbar {
grid-template-columns: auto minmax(0, 1fr) minmax(260px, 340px);
}
.install-topbar.public {
grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
}
.install-platform-topbar {
grid-column: auto;
}
.install-platform-topbar :global(.install-platform-trigger) {
min-height: 44px;
}
:global(.install-subscription-card) {
width: fit-content;
max-width: 100%;
justify-self: center;
padding: 16px;
}
.install-subscription-header,
.install-subscription-body {
width: clamp(300px, 28vw, 340px);
max-width: 100%;
}
.install-qr-wrap {
justify-self: center;
}
}
@keyframes install-star-pulse {
0% {
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0.72));
transform: scale(1);
}
65% {
filter: drop-shadow(0 0 9px rgba(250, 204, 21, 0));
transform: scale(1.18);
}
100% {
filter: drop-shadow(0 0 0 rgba(250, 204, 21, 0));
transform: scale(1);
}
}
</style>
+29 -1
View File
@@ -1594,5 +1594,33 @@
"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_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"
}
+29 -1
View File
@@ -1594,5 +1594,33 @@
"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_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,14 @@ SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
)
SUBSCRIPTION_GUIDE_SETTINGS = (
"SUBSCRIPTION_GUIDES_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()}
@@ -68,3 +76,20 @@ def test_subscription_purchase_description_settings_i18n_keys_exist():
assert field["section"] == "pricing"
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
def test_subscription_guide_settings_i18n_keys_exist():
manifest = _manifest_by_key()
assert manifest["SUBSCRIPTION_GUIDES_ENABLED"]["section"] == "subscription_guides"
assert manifest["SUBSCRIPTION_GUIDES_ENABLED"]["section_order"] == 10
assert manifest["SUBSCRIPTION_PAGE_CONFIG_JSON"]["type"] == "json"
for language in ("ru", "en"):
messages = _locale(language)
assert "admin_settings_section_subscription_guides" in messages
for setting_key in SUBSCRIPTION_GUIDE_SETTINGS:
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
+67
View File
@@ -0,0 +1,67 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.app.web.admin_api_impl import webapp_runtime
class AdminWebappRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_refresh_resets_settings_cache_and_invalidates_user_payloads(self):
settings = SimpleNamespace()
request = SimpleNamespace(
app={
"settings": settings,
"webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}},
"subscription_guides_config_cache": {
"fingerprint": ("stale",),
"status": {"enabled": True},
},
}
)
with patch.object(
webapp_runtime,
"invalidate_all_webapp_user_payloads",
AsyncMock(),
) as invalidate_mock:
await webapp_runtime.refresh_webapp_runtime_after_settings_change(
request,
updates={"SUBSCRIPTION_GUIDES_ENABLED": True},
deletes=[],
)
self.assertEqual(request.app["webapp_settings_cache"], {"ts": 0.0, "data": {}})
self.assertEqual(
request.app["subscription_guides_config_cache"],
{"fingerprint": None, "status": None},
)
invalidate_mock.assert_awaited_once_with(settings, include_devices=False)
async def test_refresh_clears_logo_cache_for_appearance_settings(self):
settings = SimpleNamespace()
request = SimpleNamespace(
app={
"settings": settings,
"webapp_settings_cache": {"ts": 123.0, "data": {"stale": True}},
"webapp_logo_cache": ("url", b"body", "image/png"),
}
)
with (
patch.object(
webapp_runtime,
"invalidate_all_webapp_user_payloads",
AsyncMock(),
),
patch(
"bot.app.web.admin_api_impl.themes.prune_unused_appearance_assets"
) as prune_mock,
):
await webapp_runtime.refresh_webapp_runtime_after_settings_change(
request,
updates={"WEBAPP_LOGO_URL": "/webapp-uploaded-logo/logo.png"},
deletes=[],
)
self.assertIsNone(request.app["webapp_logo_cache"])
prune_mock.assert_called_once_with(settings)
+46
View File
@@ -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
+17
View File
@@ -32,6 +32,23 @@ 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_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,
+310
View File
@@ -0,0 +1,310 @@
import json
from types import SimpleNamespace
import pytest
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
default_subscription_guides_config_text,
extract_subscription_guides_config_from_panel,
load_subscription_guides_config,
panel_subscription_page_allowed,
subscription_guides_admin_config_json,
validate_panel_subscription_guides_config,
validate_subscription_guides_config,
validate_subscription_guides_config_text,
)
BASE_TRANSLATION_KEYS = (
"active",
"bandwidth",
"connectionKeysHeader",
"copyLink",
"expired",
"expires",
"expiresIn",
"getLink",
"inactive",
"indefinitely",
"installationGuideHeader",
"linkCopied",
"linkCopiedToClipboard",
"name",
"scanQrCode",
"scanQrCodeDescription",
"scanToImport",
"status",
"unknown",
)
def _localized(text):
return {"ru": text, "en": text}
def _config(app_name="Streisand"):
return {
"version": "1",
"locales": ["ru", "en"],
"brandingSettings": {
"title": "Demo",
"logoUrl": "https://example.com/logo.svg",
"supportUrl": "https://t.me/support",
},
"uiConfig": {
"subscriptionInfoBlockType": "collapsed",
"installationGuidesBlockType": "cards",
},
"baseSettings": {
"metaTitle": "Subscription",
"metaDescription": "Subscription",
"showConnectionKeys": False,
"hideGetLinkButton": False,
},
"baseTranslations": {key: _localized(key) for key in BASE_TRANSLATION_KEYS},
"svgLibrary": {
"App": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Copy": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Download": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
"Phone": '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"></svg>',
},
"platforms": {
"ios": {
"displayName": "iOS",
"svgIconKey": "Phone",
"apps": [
{
"name": app_name,
"svgIconKey": "App",
"featured": True,
"blocks": [
{
"svgIconKey": "Download",
"svgIconColor": "green",
"title": _localized("Install app"),
"description": _localized("Install and import the subscription."),
"buttons": [
{
"type": "external",
"link": "https://apps.apple.com/app/example",
"text": _localized("Open store"),
"svgIconKey": "Download",
},
{
"type": "copyButton",
"link": "{{SUBSCRIPTION_LINK}}",
"text": _localized("Copy link"),
"svgIconKey": "Copy",
},
],
}
],
}
],
}
},
}
def test_valid_multiapp_like_config_is_normalized():
config = validate_subscription_guides_config(_config())
assert config["version"] == "1"
assert config["locales"] == ["ru", "en"]
assert config["platforms"]["ios"]["apps"][0]["name"] == "Streisand"
def test_bundled_default_multiapp_config_is_valid():
config = validate_subscription_guides_config_text(default_subscription_guides_config_text())
assert set(config["platforms"]) == {
"android",
"androidTV",
"appleTV",
"ios",
"linux",
"macos",
"windows",
}
assert config["platforms"]["ios"]["displayName"]["ru"] == "iOS"
def test_missing_locale_string_is_rejected():
config = _config()
del config["platforms"]["ios"]["apps"][0]["blocks"][0]["title"]["en"]
with pytest.raises(SubscriptionGuidesConfigError, match="title.en"):
validate_subscription_guides_config(config)
def test_bad_platform_is_rejected():
config = _config()
config["platforms"]["bsd"] = config["platforms"].pop("ios")
with pytest.raises(SubscriptionGuidesConfigError, match="Unsupported platform"):
validate_subscription_guides_config(config)
def test_missing_svg_key_is_rejected():
config = _config()
config["platforms"]["ios"]["svgIconKey"] = "Missing"
with pytest.raises(SubscriptionGuidesConfigError, match="missing svgLibrary key"):
validate_subscription_guides_config(config)
def test_unsafe_svg_is_rejected():
config = _config()
config["svgLibrary"]["App"] = '<svg viewBox="0 0 24 24" onload="alert(1)"></svg>'
with pytest.raises(SubscriptionGuidesConfigError, match="unsafe SVG"):
validate_subscription_guides_config(config)
def test_unsafe_external_link_is_rejected():
config = _config()
config["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0][
"link"
] = "javascript:alert(1)"
with pytest.raises(SubscriptionGuidesConfigError, match="unsafe URL scheme"):
validate_subscription_guides_config(config)
def test_external_custom_scheme_is_allowed_for_multiapp_compatibility():
config = _config()
config["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0][
"link"
] = "streisand://import/demo"
validated = validate_subscription_guides_config(config)
assert (
validated["platforms"]["ios"]["apps"][0]["blocks"][0]["buttons"][0]["link"]
== "streisand://import/demo"
)
def test_panel_response_config_wrapper_is_supported():
payload = {"response": {"config": json.dumps(_config(app_name="Panel App"))}}
validated = validate_panel_subscription_guides_config(payload)
assert validated["platforms"]["ios"]["apps"][0]["name"] == "Panel App"
def test_panel_response_direct_v1_config_is_supported():
payload = {"response": _config(app_name="Panel Direct App")}
extracted = extract_subscription_guides_config_from_panel(payload)
validated = validate_panel_subscription_guides_config(payload)
assert extracted["version"] == "1"
assert validated["platforms"]["ios"]["apps"][0]["name"] == "Panel Direct App"
def test_panel_response_without_v1_config_is_rejected():
with pytest.raises(SubscriptionGuidesConfigError, match="does not contain"):
validate_panel_subscription_guides_config({"response": {"config": {"version": "2"}}})
def test_panel_response_with_allowed_default_uses_bundled_config():
validated = validate_panel_subscription_guides_config(
{"response": {"subpageConfigUuid": None, "webpageAllowed": True}},
allow_default_when_missing=True,
)
assert panel_subscription_page_allowed({"response": {"webpageAllowed": True}})
assert validated["version"] == "1"
assert set(validated["platforms"]) >= {"ios", "android", "windows"}
def test_admin_json_overrides_file_path(tmp_path):
file_config = _config(app_name="File App")
json_config = _config(app_name="JSON App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=True,
SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(json_config),
)
loaded, source = load_subscription_guides_config(settings)
assert source == "admin_json"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "JSON App"
def test_admin_json_is_ignored_when_override_switch_is_disabled(tmp_path):
file_config = _config(app_name="File App")
json_config = _config(app_name="JSON App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON_OVERRIDE_ENABLED=False,
SUBSCRIPTION_PAGE_CONFIG_JSON=json.dumps(json_config),
)
loaded, source = load_subscription_guides_config(settings)
assert source == "file"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "File App"
def test_file_path_is_used_when_admin_json_is_empty(tmp_path):
file_config = _config(app_name="File App")
config_path = tmp_path / "multiapp.json"
config_path.write_text(json.dumps(file_config), encoding="utf-8")
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
loaded, source = load_subscription_guides_config(settings)
assert source == "file"
assert loaded["platforms"]["ios"]["apps"][0]["name"] == "File App"
def test_missing_file_path_is_not_created_implicitly(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
with pytest.raises(SubscriptionGuidesConfigError, match="does not exist"):
load_subscription_guides_config(settings)
assert not config_path.exists()
def test_admin_json_editor_is_empty_when_override_is_empty(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON="",
)
raw, source = subscription_guides_admin_config_json(settings)
assert source == "empty"
assert raw == ""
assert not config_path.exists()
def test_admin_json_editor_keeps_admin_override_without_creating_file(tmp_path):
config_path = tmp_path / "subpage-config" / "multiapp.json"
override = json.dumps(_config(app_name="JSON App"))
settings = SimpleNamespace(
SUBSCRIPTION_PAGE_CONFIG_PATH=str(config_path),
SUBSCRIPTION_PAGE_CONFIG_JSON=override,
)
raw, source = subscription_guides_admin_config_json(settings)
assert not config_path.exists()
assert source == "admin_json"
assert json.loads(raw)["platforms"]["ios"]["apps"][0]["name"] == "JSON App"
+208
View File
@@ -0,0 +1,208 @@
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"
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={"short_uuid": "share-short"},
)
local_sub = SimpleNamespace(
panel_user_uuid="panel-user",
is_active=True,
end_date=datetime.now(timezone.utc) + timedelta(days=3),
)
with patch.object(
guides.subscription_dal,
"get_subscription_by_panel_subscription_uuid",
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"],
"https://app.example.test/install/share/share-short",
)
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-user")
if __name__ == "__main__":
unittest.main()
+22 -3
View File
@@ -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()
+7
View File
@@ -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", "/install/share/{short_uuid}"): "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/{short_uuid}",
): "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",