diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index f7c0391..0e2ccea 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -1193,6 +1193,130 @@ async def index_route(request: web.Request) -> web.Response: return response +async def app_deeplink_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not getattr(settings, "WEBAPP_ENABLED", True): + raise web.HTTPNotFound(text="webapp_disabled") + + nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) + title = html.escape(str(getattr(settings, "WEBAPP_TITLE", "") or "Subscription"), quote=True) + html_text = f""" + + + + + {title} - Opening app + + + +
+

Opening app

+

If nothing happened, tap the button below.

+ Open app +
+ + +""" + response = web.Response(text=html_text, content_type="text/html", charset="utf-8") + response.headers["Cache-Control"] = "no-store" + return response + + async def _serve_template_asset( request: web.Request, filename: str, diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index e8fd392..99db843 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -7,6 +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("/open-app", app_deeplink_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) diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index d3704ac..0f931ce 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -41,6 +41,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /open-app { + proxy_pass http://backend:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /webapp-logo { proxy_pass http://backend:8081; proxy_http_version 1.1; diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 75a922a..af5f68b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -39,6 +39,14 @@ readJsonScript, structuredCloneSafe, } from "./lib/webapp/browser.js"; + import { + buildExternalAppLaunchUrl, + hasControlChars, + isExternalAppLaunchPath, + isHttpUrl, + openUrlWithHiddenAnchor, + readExternalAppLaunchTarget, + } from "./lib/webapp/appLinks.js"; import { createApiClient } from "./lib/webapp/publicApi.js"; import { createI18n } from "./lib/webapp/i18n.js"; import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js"; @@ -80,6 +88,7 @@ } from "./lib/webapp/routes.js"; const query = new URLSearchParams(window.location.search); + const isAppLaunchRoute = isExternalAppLaunchPath(window.location.pathname); applyPreviewMock(query.get("mock")); const isPreviewBoard = query.get("preview") === "all"; const injectedConfig = readJsonScript("webapp-config"); @@ -98,10 +107,11 @@ let telegramSdkStatus = "idle"; let telegramMiniAppInitData = ""; - let mode = isPreviewBoard ? "preview" : "loading"; + let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading"; let activeTab = "home"; let screen = "home"; let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null; + let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : ""; let publicInstallSubscription = null; let publicInstallToken = ""; let trialBusy = false; @@ -493,8 +503,23 @@ return Boolean(enabled && sub?.active); } + function refreshAppLaunchTarget() { + appLaunchTarget = readExternalAppLaunchTarget(); + return appLaunchTarget; + } + + function openAppLaunchTarget() { + const target = refreshAppLaunchTarget(); + if (!target) return; + openUrlWithHiddenAnchor(target); + } + onMount(() => { if (isPreviewBoard) return; + if (isAppLaunchRoute) { + window.setTimeout(openAppLaunchTarget, 80); + return; + } const onAnyPointerDown = () => { if (mode === "login") loginEmailTooltipOpen = false; }; @@ -1057,42 +1082,43 @@ window.location.assign(url); } - function hasControlChars(value) { - return Array.from(String(value || "")).some((char) => { - const code = char.charCodeAt(0); - return code <= 31 || code === 127; - }); - } - function openAppLink(url) { const raw = String(url || "").trim(); if (!raw || hasControlChars(raw) || /^(javascript|data|vbscript):/i.test(raw)) { return; } - if (/^https?:\/\//i.test(raw)) { + if (isHttpUrl(raw)) { openExternalLink(raw); return; } - if (/^tg:\/\//i.test(raw) && tg?.openTelegramLink) { + + const isTelegramMiniApp = hasTelegramLaunchParams(); + const currentTg = tg || telegramSdk.refresh(); + const gatewayUrl = isTelegramMiniApp ? buildExternalAppLaunchUrl(raw) : ""; + if (gatewayUrl) { + if (currentTg?.openLink) { + try { + tg = currentTg; + currentTg.openLink(gatewayUrl); + return; + } catch { + // Fall back to regular browser navigation below. + } + } + window.location.assign(gatewayUrl); + return; + } + + if (/^tg:\/\//i.test(raw) && currentTg?.openTelegramLink) { try { - tg.openTelegramLink(raw); + tg = currentTg; + currentTg.openTelegramLink(raw); return; } catch { // Fall back to the generic deeplink path below. } } - try { - const anchor = document.createElement("a"); - anchor.href = raw; - anchor.target = "_self"; - anchor.rel = "noreferrer"; - anchor.style.display = "none"; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - } catch { - window.location.assign(raw); - } + openUrlWithHiddenAnchor(raw); } function openConnectLink() { @@ -1400,6 +1426,40 @@
{t("wa_loading")}
+ {:else if mode === "appLaunch"} +
+
+ + {#if appLaunchTarget} +

{t("wa_app_launch_title", {}, "Opening app")}

+

+ {t( + "wa_app_launch_hint", + {}, + "If the app did not open automatically, tap the button below." + )} +

+ { + event.preventDefault(); + openAppLaunchTarget(); + }} + > + {t("wa_app_launch_button", {}, "Open app")} + + {:else} +

{t("wa_app_launch_unavailable_title", {}, "App link unavailable")}

+

+ {t("wa_app_launch_unavailable_hint", {}, "Return to Telegram and try again.")} +

+ {/if} +
+
{:else if mode === "publicInstall"}
diff --git a/frontend/src/lib/webapp/appLinks.js b/frontend/src/lib/webapp/appLinks.js new file mode 100644 index 0000000..f61cfcc --- /dev/null +++ b/frontend/src/lib/webapp/appLinks.js @@ -0,0 +1,61 @@ +export function hasControlChars(value) { + return Array.from(String(value || "")).some((char) => { + const code = char.charCodeAt(0); + return code <= 31 || code === 127; + }); +} + +export function isUnsafeAppUrl(value) { + return ( + !String(value || "").trim() || + hasControlChars(value) || + /^(?:javascript|data|vbscript):/i.test(String(value || "").trim()) + ); +} + +export function isHttpUrl(value) { + return /^https?:\/\//i.test(String(value || "").trim()); +} + +export function isExternalAppLaunchPath(pathname) { + const normalized = String(pathname || "") + .trim() + .replace(/\/+$/, ""); + return normalized === "/open-app"; +} + +export function readExternalAppLaunchTarget(locationRef = null) { + const ref = locationRef || (typeof window === "undefined" ? null : window.location); + if (!ref?.hash) return ""; + + const target = String(new URLSearchParams(ref.hash.replace(/^#/, "")).get("url") || "").trim(); + if (isUnsafeAppUrl(target) || isHttpUrl(target)) return ""; + return target; +} + +export function buildExternalAppLaunchUrl(value, locationRef = null) { + const target = String(value || "").trim(); + if (isUnsafeAppUrl(target) || isHttpUrl(target)) return ""; + + const ref = locationRef || (typeof window === "undefined" ? null : window.location); + if (!ref?.href) return ""; + + const url = new URL("/open-app", ref.href); + url.hash = new URLSearchParams({ url: target }).toString(); + return url.href; +} + +export function openUrlWithHiddenAnchor(url) { + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.target = "_self"; + anchor.rel = "noreferrer"; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } catch { + window.location.assign(url); + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 7472361..7f1927a 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,6 +2,7 @@ import { mount } from "svelte"; import App from "./App.svelte"; import "./styles.css"; +import { isExternalAppLaunchPath } from "./lib/webapp/appLinks.js"; async function loadBootstrap() { if (document.getElementById("webapp-config")) return; @@ -28,9 +29,10 @@ async function loadBootstrap() { } const target = document.getElementById("app"); +const skipBootstrap = isExternalAppLaunchPath(window.location.pathname); if (target) { - loadBootstrap().finally(() => { + (skipBootstrap ? Promise.resolve() : loadBootstrap()).finally(() => { target.replaceChildren(); mount(App, { target }); }); diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index f917cdc..aedeb6e 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -53,6 +53,57 @@ a { line-height: 1.2; } +.app-launch-shell { + display: grid; + min-height: 100dvh; + place-items: center; + padding: max(24px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) + max(24px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); + box-sizing: border-box; +} + +.app-launch-panel { + display: grid; + width: min(100%, 420px); + gap: 14px; + justify-items: center; + text-align: center; +} + +.app-launch-brand { + margin-bottom: 4px; +} + +.app-launch-panel h1 { + margin: 0; + color: var(--text); + font-size: 24px; + line-height: 1.18; +} + +.app-launch-panel p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.55; +} + +.app-launch-button { + display: inline-flex; + width: 100%; + min-height: 48px; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--accent); + color: var(--accent-contrast); + padding: 0 18px; + box-sizing: border-box; + font-size: 15px; + font-weight: 850; + text-decoration: none; +} + .loader { display: grid; min-height: 100dvh; diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 4099042..a7060da 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -51,6 +51,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/login/password"): "index_route", ("GET", "/home"): "index_route", ("GET", "/install"): "index_route", + ("GET", "/open-app"): "app_deeplink_route", ("GET", "/s/{share_token}"): "index_route", ("GET", "/invite"): "index_route", ("GET", "/devices"): "index_route", @@ -195,6 +196,27 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(match_info.handler.__name__, "webapp_favicon_route") + def test_app_deeplink_gateway_keeps_target_in_fragment(self): + request = _Request( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_TITLE="/minishop", + ) + } + ) + request["csp_nonce"] = "nonce-value" + + response = asyncio.run(subscription_webapp.app_deeplink_route(request)) + + self.assertEqual(response.status, 200) + self.assertEqual(response.headers["Cache-Control"], "no-store") + self.assertIn('nonce="nonce-value"', response.text) + self.assertIn("window.location.hash", response.text) + self.assertIn("URLSearchParams", response.text) + self.assertIn("The app link is unavailable.", response.text) + self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text) + class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): def _settings(self): diff --git a/tests/test_webapp_telegram_logout.py b/tests/test_webapp_telegram_logout.py index eb5a262..3cafcfe 100644 --- a/tests/test_webapp_telegram_logout.py +++ b/tests/test_webapp_telegram_logout.py @@ -33,3 +33,27 @@ def test_logout_handler_is_noop_inside_telegram_mini_app(): mark_logout_pos = source.index("markManualLogout();") assert guard_pos < mark_logout_pos + + +def test_open_app_route_skips_bootstrap_and_auth_flow(): + main_source = _read("frontend/src/main.js") + app_source = _read("frontend/src/App.svelte") + + assert "skipBootstrap = isExternalAppLaunchPath(window.location.pathname)" in main_source + assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source + + launch_guard_pos = app_source.index("if (isAppLaunchRoute) {") + boot_pos = app_source.index("boot();", launch_guard_pos) + + assert launch_guard_pos < boot_pos + + +def test_frontend_nginx_proxies_open_app_gateway_to_backend(): + source = _read("deploy/docker/frontend/nginx.conf") + + open_app_pos = source.index("location = /open-app") + fallback_pos = source.index("location / {") + block = source[open_app_pos:fallback_pos] + + assert open_app_pos < fallback_pos + assert "proxy_pass http://backend:8081;" in block