From 5918a6cc713656beb76cad08d912cdf230a9f423 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Sat, 23 May 2026 23:07:42 +0300
Subject: [PATCH] fix: open install guide deeplinks via external app gateway
---
backend/bot/app/web/webapp/assets.py | 124 +++++++++++++++++++++++++++
backend/bot/app/web/webapp/routes.py | 1 +
deploy/docker/frontend/nginx.conf | 9 ++
frontend/src/App.svelte | 106 ++++++++++++++++++-----
frontend/src/lib/webapp/appLinks.js | 61 +++++++++++++
frontend/src/main.js | 4 +-
frontend/src/styles/webapp.css | 51 +++++++++++
tests/test_webapp_route_contract.py | 22 +++++
tests/test_webapp_telegram_logout.py | 24 ++++++
9 files changed, 378 insertions(+), 24 deletions(-)
create mode 100644 frontend/src/lib/webapp/appLinks.js
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