diff --git a/backend/bot/app/web/webapp/guides.py b/backend/bot/app/web/webapp/guides.py index bd20656..071fbb3 100644 --- a/backend/bot/app/web/webapp/guides.py +++ b/backend/bot/app/web/webapp/guides.py @@ -32,12 +32,14 @@ async def subscription_guides_route(request: web.Request) -> web.Response: 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) + share_token = subscription_dal.normalize_install_share_token( + request.match_info.get("share_token") + ) + if not share_token: + return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404) status = await _subscription_guides_status_shared(request.app) - subscription = await _public_subscription_payload(request, short_uuid) + subscription = await _public_subscription_payload(request, share_token) payload = { "enabled": bool(status.get("enabled")), "config": status.get("config") if status.get("enabled") else None, @@ -160,19 +162,19 @@ async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> st async def _public_subscription_payload( request: web.Request, - short_uuid: str, + share_token: str, ) -> Dict[str, Any]: settings: Settings = request.app["settings"] panel_service = _panel_service_from_app(request.app) raw_link = "" username = "" - resolved_short_uuid = short_uuid + resolved_short_uuid = "" async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: - local_sub = await subscription_dal.get_subscription_by_panel_subscription_uuid( + local_sub = await subscription_dal.get_subscription_by_install_share_token( session, - short_uuid, + share_token, ) if ( @@ -185,16 +187,17 @@ async def _public_subscription_payload( 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() + resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip() display_link, connect_url = await prepare_config_links(settings, raw_link) return { "active": bool(display_link), "config_link": display_link, "connect_url": connect_url or display_link, - "panel_short_uuid": resolved_short_uuid, + "panel_short_uuid": resolved_short_uuid or None, + "install_share_token": share_token, "username": username, - "share_url": _public_install_url(request, resolved_short_uuid), + "share_url": _public_install_url(request, share_token), } @@ -231,14 +234,7 @@ def _local_subscription_is_publicly_active(subscription: Any) -> bool: ) -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: +def _public_install_url(request: web.Request, share_token: str) -> str: settings: Settings = request.app["settings"] configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() if configured_base: @@ -255,7 +251,7 @@ def _public_install_url(request: web.Request, short_uuid: str) -> str: ) 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)}" + return f"{base.rstrip('/')}/s/{quote(share_token)}" def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]: diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 33001ca..e8fd392 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -7,7 +7,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: 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(r"/s/{share_token:[a-f0-9]{32}}", index_route) app.router.add_get("/invite", index_route) app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) @@ -64,7 +64,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: 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}}", + r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}", public_subscription_guides_route, ) app.router.add_get("/api/account/avatar", account_avatar_route) diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 1e68646..53cc5c8 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -53,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A if db_user.panel_user_uuid else None ) + install_share_token = ( + await subscription_dal.ensure_install_share_token(session, local_sub) + if active and local_sub + else None + ) trial_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 @@ -83,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "language_code": lang, "is_admin": is_admin, }, - "subscription": _serialize_subscription(request, settings, active, local_sub, lang), + "subscription": _serialize_subscription( + request, + settings, + active, + local_sub, + lang, + install_share_token=install_share_token, + ), "referral": { "code": referral_code, "bot_link": referral_link, @@ -181,12 +193,26 @@ def _build_webapp_referral_link( def _serialize_subscription( - request: web.Request, - settings: Settings, - active: Optional[Dict[str, Any]], - local_sub: Optional[Any], - lang: str, + request_or_settings: Any, + settings_or_active: Any, + active_or_local_sub: Optional[Any] = None, + local_sub_or_lang: Optional[Any] = None, + lang: Optional[str] = None, + *, + install_share_token: Optional[str] = None, ) -> Dict[str, Any]: + if lang is None: + request = None + settings = request_or_settings + active = settings_or_active + local_sub = active_or_local_sub + lang = str(local_sub_or_lang or "ru") + else: + request = request_or_settings + settings = settings_or_active + active = active_or_local_sub + local_sub = local_sub_or_lang + if not active: return { "active": False, @@ -196,6 +222,7 @@ def _serialize_subscription( "config_link": None, "connect_url": None, "panel_short_uuid": None, + "install_share_token": None, "install_share_url": None, } @@ -237,6 +264,9 @@ def _serialize_subscription( can_topup_devices = False panel_short_uuid = str(active.get("panel_short_uuid") or "").strip() + share_token = str( + install_share_token or getattr(local_sub, "install_share_token", "") or "" + ).strip() return { "active": seconds_left > 0, "status": active.get("status_from_panel") or "UNKNOWN", @@ -247,7 +277,8 @@ def _serialize_subscription( "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), + "install_share_token": subscription_dal.normalize_install_share_token(share_token) or None, + "install_share_url": _build_install_share_link(request, settings, share_token), "traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True), "traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), @@ -293,12 +324,12 @@ def _serialize_subscription( def _build_install_share_link( - request: web.Request, + request: Optional[web.Request], settings: Settings, - short_uuid: str, + share_token: str, ) -> Optional[str]: - short_uuid = str(short_uuid or "").strip() - if not short_uuid: + share_token = subscription_dal.normalize_install_share_token(share_token) + if not share_token or request is None: return None configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() if configured_base: @@ -315,7 +346,7 @@ def _build_install_share_link( ) 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)}" + return f"{base.rstrip('/')}/s/{quote(share_token)}" def _serialize_plans( diff --git a/backend/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py index e168c31..f02ee33 100644 --- a/backend/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -1,4 +1,6 @@ import logging +import re +import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional @@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload from db.models import Subscription +INSTALL_SHARE_TOKEN_BYTES = 16 + def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: model_columns = Subscription.__mapper__.columns.keys() @@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid( return result.scalar_one_or_none() +def normalize_install_share_token(value: Any) -> str: + token = str(value or "").strip().lower() + if not re.fullmatch(r"[a-f0-9]{32}", token): + return "" + return token + + +async def get_subscription_by_install_share_token( + session: AsyncSession, + token: str, +) -> Optional[Subscription]: + normalized = normalize_install_share_token(token) + if not normalized: + return None + stmt = select(Subscription).where(Subscription.install_share_token == normalized) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def ensure_install_share_token( + session: AsyncSession, + subscription: Subscription, +) -> str: + raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip() + existing = normalize_install_share_token(raw_existing) + if existing: + if existing != getattr(subscription, "install_share_token", None): + subscription.install_share_token = existing + await session.flush() + return existing + + subscription_id = getattr(subscription, "subscription_id", None) + for _attempt in range(10): + token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES) + if await get_subscription_by_install_share_token(session, token): + continue + if subscription_id: + result = await session.execute( + update(Subscription) + .where( + Subscription.subscription_id == subscription_id, + or_( + Subscription.install_share_token.is_(None), + Subscription.install_share_token == "", + Subscription.install_share_token == raw_existing, + ), + ) + .values(install_share_token=token) + ) + await session.flush() + if result.rowcount: + await session.refresh(subscription) + return normalize_install_share_token( + getattr(subscription, "install_share_token", None) + ) or token + + await session.refresh(subscription) + raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip() + existing = normalize_install_share_token(raw_existing) + if existing: + return existing + continue + + subscription.install_share_token = token + await session.flush() + await session.refresh(subscription) + return token + + raise RuntimeError("Failed to generate a unique install share token") + + async def get_active_subscriptions_for_user( session: AsyncSession, user_id: int ) -> List[Subscription]: diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 3ef6a0c..7ec884b 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -882,6 +882,26 @@ def _migration_0026_add_lifetime_traffic_synced_at(connection: Connection) -> No ) +def _migration_0027_add_subscription_install_share_token(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + + if "install_share_token" not in columns: + connection.execute( + text("ALTER TABLE subscriptions ADD COLUMN install_share_token VARCHAR(32)") + ) + + connection.execute( + text( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_subscriptions_install_share_token + ON subscriptions (install_share_token) + WHERE install_share_token IS NOT NULL + """ + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [ description="Track when lifetime traffic usage was last synced from panel", upgrade=_migration_0026_add_lifetime_traffic_synced_at, ), + Migration( + id="0027_add_subscription_install_share_token", + description="Add stable public share tokens for install instructions", + upgrade=_migration_0027_add_subscription_install_share_token, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index bb8b78b..629ceee 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -106,6 +106,7 @@ class Subscription(Base): user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) panel_user_uuid = Column(String, nullable=False, index=True) panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True) + install_share_token = Column(String(32), unique=True, index=True, nullable=True) start_date = Column(DateTime(timezone=True), nullable=True) end_date = Column(DateTime(timezone=True), nullable=False, index=True) duration_months = Column(Integer, nullable=True) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 289e641..b86f5db 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -73,7 +73,7 @@ adminSectionFromPath, adminUserIdFromPath, normalizeSection, - publicInstallShortUuidFromPath, + publicInstallTokenFromPath, sectionFromPath, supportTicketIdFromPath, syncSectionPath, @@ -103,7 +103,7 @@ let screen = "home"; let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null; let publicInstallSubscription = null; - let publicInstallShortUuid = ""; + let publicInstallToken = ""; let trialBusy = false; let promoCode = ""; let promoBusy = false; @@ -497,9 +497,9 @@ if (mode === "login") loginEmailTooltipOpen = false; }; const onPopState = () => { - const publicShortUuid = publicInstallShortUuidFromPath(window.location.pathname); - if (publicShortUuid) { - void loadPublicInstall(publicShortUuid); + const shareToken = publicInstallTokenFromPath(window.location.pathname); + if (shareToken) { + void loadPublicInstall(shareToken); return; } if (mode === "publicInstall") { @@ -802,9 +802,9 @@ } async function boot() { - const shareShortUuid = publicInstallShortUuidFromPath(window.location.pathname); - if (!MOCK && shareShortUuid) { - await loadPublicInstall(shareShortUuid); + const shareToken = publicInstallTokenFromPath(window.location.pathname); + if (shareToken) { + await loadPublicInstall(shareToken); return; } await runWebappBoot({ @@ -987,17 +987,16 @@ } } - async function loadPublicInstall(shortUuid) { + async function loadPublicInstall(shareToken) { mode = "publicInstall"; screen = "install"; activeTab = "home"; - publicInstallShortUuid = shortUuid; + publicInstallToken = shareToken; publicInstallSubscription = { - panel_short_uuid: shortUuid, - share_url: - typeof window !== "undefined" ? `${window.location.origin}/install/share/${shortUuid}` : "", + install_share_token: shareToken, + share_url: typeof window !== "undefined" ? `${window.location.origin}/s/${shareToken}` : "", }; - const response = await installGuidesStore.loadPublic(shortUuid, true); + const response = await installGuidesStore.loadPublic(shareToken, true); publicInstallSubscription = response?.subscription || publicInstallSubscription; } @@ -1362,7 +1361,7 @@ {currentLang} telegramPlatform={tg?.platform || ""} user={{}} - subscription={publicInstallSubscription || { panel_short_uuid: publicInstallShortUuid }} + subscription={publicInstallSubscription || { install_share_token: publicInstallToken }} {goHome} {openConnectLink} {openExternalLink} diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index a226f3e..34325ba 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -606,9 +606,13 @@ export async function mockApi(path, options = {}, context = {}) { if (path === "/me") return clone(DEV_MOCK.data); if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides); if (cleanPath.startsWith("/subscription-guides/public/")) { + const shareToken = decodeURIComponent(cleanPath.split("/").pop() || ""); + const subscription = clone(DEV_MOCK.data.subscription); + subscription.install_share_token = shareToken; + subscription.share_url = `${window.location.origin}/s/${shareToken}`; return { ...clone(DEV_MOCK.data.subscription_guides), - subscription: clone(DEV_MOCK.data.subscription), + subscription, }; } if (path === "/auth/email/request") return { ok: true }; diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index b9e8501..dc9299c 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -276,7 +276,8 @@ export const DEV_MOCK = { 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", + install_share_token: "8f559061460e8fede78ef18dce887236", + install_share_url: "https://app.example.com/s/8f559061460e8fede78ef18dce887236", traffic_used: "18.4 GB", traffic_limit: "100 GB", traffic_used_bytes: 19756849561, diff --git a/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js index 1a1e2a5..1f6846e 100644 --- a/frontend/src/lib/webapp/routes.js +++ b/frontend/src/lib/webapp/routes.js @@ -29,10 +29,10 @@ export function sectionFromPath(pathname) { return normalizeSection(section); } -export function publicInstallShortUuidFromPath(pathname) { +export function publicInstallTokenFromPath(pathname) { const normalized = String(pathname || "").trim().replace(/\/+$/, ""); - const match = normalized.match(/^\/install\/share\/([A-Za-z0-9_-]{8,128})$/); - return match ? match[1] : ""; + const match = normalized.match(/^\/s\/([a-f0-9]{32})$/i); + return match ? match[1].toLowerCase() : ""; } export function adminSectionFromPath(pathname) { diff --git a/frontend/src/lib/webapp/stores/installGuidesStore.js b/frontend/src/lib/webapp/stores/installGuidesStore.js index f18ae7d..be91b5e 100644 --- a/frontend/src/lib/webapp/stores/installGuidesStore.js +++ b/frontend/src/lib/webapp/stores/installGuidesStore.js @@ -62,8 +62,8 @@ export function createInstallGuidesStore({ api, t, showToast }) { return fetchGuides("/subscription-guides", force); } - async function loadPublic(shortUuid, force = false) { - const encoded = encodeURIComponent(String(shortUuid || "")); + async function loadPublic(shareToken, force = false) { + const encoded = encodeURIComponent(String(shareToken || "")); return fetchGuides(`/subscription-guides/public/${encoded}`, force); } diff --git a/frontend/src/webapp/screens/InstallGuideScreen.svelte b/frontend/src/webapp/screens/InstallGuideScreen.svelte index e6ef5f7..0656143 100644 --- a/frontend/src/webapp/screens/InstallGuideScreen.svelte +++ b/frontend/src/webapp/screens/InstallGuideScreen.svelte @@ -1,5 +1,7 @@