Files
remnawave-minishop/backend/config/subscription_guides_config.py
T

670 lines
22 KiB
Python

"""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()