fix: open install guide deeplinks via external app gateway
This commit is contained in:
@@ -1193,6 +1193,130 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
return 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"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title} - Opening app</title>
|
||||||
|
<style nonce="{nonce}">
|
||||||
|
:root {{
|
||||||
|
color-scheme: dark light;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", sans-serif;
|
||||||
|
background: #0b1017;
|
||||||
|
color: #f7fafc;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
|
||||||
|
main {{
|
||||||
|
width: min(100%, 420px);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h1 {{
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}}
|
||||||
|
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #aeb8c5;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}}
|
||||||
|
|
||||||
|
a.button {{
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 46px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #14b86f;
|
||||||
|
color: #03120b;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
a.button[aria-disabled="true"] {{
|
||||||
|
pointer-events: none;
|
||||||
|
background: #344052;
|
||||||
|
color: #aeb8c5;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Opening app</h1>
|
||||||
|
<p id="status">If nothing happened, tap the button below.</p>
|
||||||
|
<a id="open-link" class="button" href="#" rel="noreferrer">Open app</a>
|
||||||
|
</main>
|
||||||
|
<script nonce="{nonce}">
|
||||||
|
(() => {{
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const openLink = document.getElementById("open-link");
|
||||||
|
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||||
|
const target = String(params.get("url") || "").trim();
|
||||||
|
const isUnsafe =
|
||||||
|
!target ||
|
||||||
|
hasControlChars(target) ||
|
||||||
|
/^(?:javascript|data|vbscript|https?):/i.test(target);
|
||||||
|
|
||||||
|
function hasControlChars(value) {{
|
||||||
|
return Array.from(String(value || "")).some((char) => {{
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
return code <= 31 || code === 127;
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function openTarget() {{
|
||||||
|
if (isUnsafe) return;
|
||||||
|
window.location.href = target;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (isUnsafe) {{
|
||||||
|
statusEl.textContent = "The app link is unavailable.";
|
||||||
|
openLink.setAttribute("aria-disabled", "true");
|
||||||
|
openLink.removeAttribute("href");
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
openLink.href = target;
|
||||||
|
openLink.addEventListener("click", (event) => {{
|
||||||
|
event.preventDefault();
|
||||||
|
openTarget();
|
||||||
|
}});
|
||||||
|
|
||||||
|
window.setTimeout(openTarget, 80);
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
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(
|
async def _serve_template_asset(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
filename: str,
|
filename: str,
|
||||||
|
|||||||
@@ -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("/login/password", index_route)
|
||||||
app.router.add_get("/home", index_route)
|
app.router.add_get("/home", index_route)
|
||||||
app.router.add_get("/install", 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(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||||
app.router.add_get("/invite", index_route)
|
app.router.add_get("/invite", index_route)
|
||||||
app.router.add_get("/devices", index_route)
|
app.router.add_get("/devices", index_route)
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 {
|
location = /webapp-logo {
|
||||||
proxy_pass http://backend:8081;
|
proxy_pass http://backend:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
+83
-23
@@ -39,6 +39,14 @@
|
|||||||
readJsonScript,
|
readJsonScript,
|
||||||
structuredCloneSafe,
|
structuredCloneSafe,
|
||||||
} from "./lib/webapp/browser.js";
|
} 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 { createApiClient } from "./lib/webapp/publicApi.js";
|
||||||
import { createI18n } from "./lib/webapp/i18n.js";
|
import { createI18n } from "./lib/webapp/i18n.js";
|
||||||
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
|
||||||
@@ -80,6 +88,7 @@
|
|||||||
} from "./lib/webapp/routes.js";
|
} from "./lib/webapp/routes.js";
|
||||||
|
|
||||||
const query = new URLSearchParams(window.location.search);
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
const isAppLaunchRoute = isExternalAppLaunchPath(window.location.pathname);
|
||||||
applyPreviewMock(query.get("mock"));
|
applyPreviewMock(query.get("mock"));
|
||||||
const isPreviewBoard = query.get("preview") === "all";
|
const isPreviewBoard = query.get("preview") === "all";
|
||||||
const injectedConfig = readJsonScript("webapp-config");
|
const injectedConfig = readJsonScript("webapp-config");
|
||||||
@@ -98,10 +107,11 @@
|
|||||||
let telegramSdkStatus = "idle";
|
let telegramSdkStatus = "idle";
|
||||||
let telegramMiniAppInitData = "";
|
let telegramMiniAppInitData = "";
|
||||||
|
|
||||||
let mode = isPreviewBoard ? "preview" : "loading";
|
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
|
||||||
let activeTab = "home";
|
let activeTab = "home";
|
||||||
let screen = "home";
|
let screen = "home";
|
||||||
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
||||||
|
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
|
||||||
let publicInstallSubscription = null;
|
let publicInstallSubscription = null;
|
||||||
let publicInstallToken = "";
|
let publicInstallToken = "";
|
||||||
let trialBusy = false;
|
let trialBusy = false;
|
||||||
@@ -493,8 +503,23 @@
|
|||||||
return Boolean(enabled && sub?.active);
|
return Boolean(enabled && sub?.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshAppLaunchTarget() {
|
||||||
|
appLaunchTarget = readExternalAppLaunchTarget();
|
||||||
|
return appLaunchTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAppLaunchTarget() {
|
||||||
|
const target = refreshAppLaunchTarget();
|
||||||
|
if (!target) return;
|
||||||
|
openUrlWithHiddenAnchor(target);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (isPreviewBoard) return;
|
if (isPreviewBoard) return;
|
||||||
|
if (isAppLaunchRoute) {
|
||||||
|
window.setTimeout(openAppLaunchTarget, 80);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const onAnyPointerDown = () => {
|
const onAnyPointerDown = () => {
|
||||||
if (mode === "login") loginEmailTooltipOpen = false;
|
if (mode === "login") loginEmailTooltipOpen = false;
|
||||||
};
|
};
|
||||||
@@ -1057,42 +1082,43 @@
|
|||||||
window.location.assign(url);
|
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) {
|
function openAppLink(url) {
|
||||||
const raw = String(url || "").trim();
|
const raw = String(url || "").trim();
|
||||||
if (!raw || hasControlChars(raw) || /^(javascript|data|vbscript):/i.test(raw)) {
|
if (!raw || hasControlChars(raw) || /^(javascript|data|vbscript):/i.test(raw)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/^https?:\/\//i.test(raw)) {
|
if (isHttpUrl(raw)) {
|
||||||
openExternalLink(raw);
|
openExternalLink(raw);
|
||||||
return;
|
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 {
|
try {
|
||||||
tg.openTelegramLink(raw);
|
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 = currentTg;
|
||||||
|
currentTg.openTelegramLink(raw);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to the generic deeplink path below.
|
// Fall back to the generic deeplink path below.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
openUrlWithHiddenAnchor(raw);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openConnectLink() {
|
function openConnectLink() {
|
||||||
@@ -1400,6 +1426,40 @@
|
|||||||
<BrandMark {brand} size="md" />
|
<BrandMark {brand} size="md" />
|
||||||
<div>{t("wa_loading")}</div>
|
<div>{t("wa_loading")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if mode === "appLaunch"}
|
||||||
|
<div class="app-launch-shell">
|
||||||
|
<main class="app-launch-panel">
|
||||||
|
<div class="app-launch-brand" aria-hidden="true">
|
||||||
|
<BrandMark {brand} size="md" />
|
||||||
|
</div>
|
||||||
|
{#if appLaunchTarget}
|
||||||
|
<h1>{t("wa_app_launch_title", {}, "Opening app")}</h1>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"wa_app_launch_hint",
|
||||||
|
{},
|
||||||
|
"If the app did not open automatically, tap the button below."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
class="app-launch-button"
|
||||||
|
href={appLaunchTarget}
|
||||||
|
rel="noreferrer"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openAppLaunchTarget();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("wa_app_launch_button", {}, "Open app")}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<h1>{t("wa_app_launch_unavailable_title", {}, "App link unavailable")}</h1>
|
||||||
|
<p>
|
||||||
|
{t("wa_app_launch_unavailable_hint", {}, "Return to Telegram and try again.")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
{:else if mode === "publicInstall"}
|
{:else if mode === "publicInstall"}
|
||||||
<div class="public-install-shell">
|
<div class="public-install-shell">
|
||||||
<a class="public-install-brand" href="/" aria-label={brandTitle}>
|
<a class="public-install-brand" href="/" aria-label={brandTitle}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { mount } from "svelte";
|
|||||||
|
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import { isExternalAppLaunchPath } from "./lib/webapp/appLinks.js";
|
||||||
|
|
||||||
async function loadBootstrap() {
|
async function loadBootstrap() {
|
||||||
if (document.getElementById("webapp-config")) return;
|
if (document.getElementById("webapp-config")) return;
|
||||||
@@ -28,9 +29,10 @@ async function loadBootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const target = document.getElementById("app");
|
const target = document.getElementById("app");
|
||||||
|
const skipBootstrap = isExternalAppLaunchPath(window.location.pathname);
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
loadBootstrap().finally(() => {
|
(skipBootstrap ? Promise.resolve() : loadBootstrap()).finally(() => {
|
||||||
target.replaceChildren();
|
target.replaceChildren();
|
||||||
mount(App, { target });
|
mount(App, { target });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,57 @@ a {
|
|||||||
line-height: 1.2;
|
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 {
|
.loader {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/login/password"): "index_route",
|
("GET", "/login/password"): "index_route",
|
||||||
("GET", "/home"): "index_route",
|
("GET", "/home"): "index_route",
|
||||||
("GET", "/install"): "index_route",
|
("GET", "/install"): "index_route",
|
||||||
|
("GET", "/open-app"): "app_deeplink_route",
|
||||||
("GET", "/s/{share_token}"): "index_route",
|
("GET", "/s/{share_token}"): "index_route",
|
||||||
("GET", "/invite"): "index_route",
|
("GET", "/invite"): "index_route",
|
||||||
("GET", "/devices"): "index_route",
|
("GET", "/devices"): "index_route",
|
||||||
@@ -195,6 +196,27 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(match_info.handler.__name__, "webapp_favicon_route")
|
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):
|
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def _settings(self):
|
def _settings(self):
|
||||||
|
|||||||
@@ -33,3 +33,27 @@ def test_logout_handler_is_noop_inside_telegram_mini_app():
|
|||||||
mark_logout_pos = source.index("markManualLogout();")
|
mark_logout_pos = source.index("markManualLogout();")
|
||||||
|
|
||||||
assert guard_pos < mark_logout_pos
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user