feat: complete TASK-WP0-03 browser gate

This commit is contained in:
suyx
2026-07-27 18:23:20 +08:00
parent dbe3e73b91
commit 878788b1e2
25 changed files with 3227 additions and 21 deletions
+184 -4
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
BootstrapResponseSchema,
@@ -11,13 +13,28 @@ import {
SseEventSchema,
StableEngineeringErrorCodeSchema,
StateSseEventSchema,
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import Fastify from "fastify";
import Fastify, { type FastifyReply } from "fastify";
import {
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BrowserUnsupportedReasonSchema,
browserSupportCookieMaxAgeSeconds,
browserSupportCookieName,
checkBrowserSupport,
createBrowserSupportCookie,
supportedBrowserSummary,
verifyBrowserSupportCookie,
type BrowserSupportRelease,
type BrowserUnsupportedReason,
} from "./browser-support.js";
import { EventHub } from "./event-hub.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -33,18 +50,80 @@ const defaultBootstrap: BootstrapResponse = {
export interface CreateAppOptions {
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
browserGate?: boolean;
browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer;
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
}
const supportGateDirectory = resolve("apps/web/support-gate");
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
const contentSecurityPolicy = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' blob:",
"font-src 'self' blob:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join("; ");
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
const header = headers["x-correlation-id"];
const candidate = Array.isArray(header) ? header[0] : header;
return isCorrelationId(candidate) ? candidate : randomUUID();
}
function headerValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
return (
path === "/" ||
path === "/app" ||
path.startsWith("/app/") ||
path === "/admin" ||
path.startsWith("/admin/") ||
path === "/support-gate.css" ||
path === "/support-gate.js" ||
path === "/RELEASE.json" ||
path === "/healthz"
);
}
function sendBrowserUnsupported(
reply: FastifyReply,
correlationId: string,
reason: BrowserUnsupportedReason,
release: BrowserSupportRelease | undefined,
) {
return reply.code(426).send(
createErrorEnvelope({
code: "BROWSER_UNSUPPORTED",
correlationId,
details: {
reason,
supported_browsers: supportedBrowserSummary(release),
},
}),
);
}
export async function createApp(options: CreateAppOptions = {}) {
const eventHub = options.eventHub ?? new EventHub();
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
const browserGate = options.browserGate ?? true;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease;
const app = Fastify({
genReqId: (request) => requestCorrelationId(request.headers),
logger: false,
@@ -70,6 +149,9 @@ export async function createApp(options: CreateAppOptions = {}) {
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
@@ -79,11 +161,107 @@ export async function createApp(options: CreateAppOptions = {}) {
app.addSchema(schema);
}
app.addHook("onRequest", (request, reply, done) => {
app.addHook("onRequest", async (request, reply) => {
reply.header("X-Correlation-Id", request.id);
done();
reply.header("Accept-CH", clientHints);
reply.header("Cache-Control", "no-store");
reply.header("Content-Security-Policy", contentSecurityPolicy);
reply.header("X-Content-Type-Options", "nosniff");
const host = headerValue(request.headers.host);
const origin = headerValue(request.headers.origin);
if (!isAllowedNetworkRequest({ host, method: request.method, origin }, options.networkBoundary)) {
return sendBrowserUnsupported(reply, request.id, "identity_unavailable", browserSupportRelease);
}
const path = request.url.split("?", 1)[0] ?? "/";
if (!browserGate || isSupportGateRequest(request.method, path)) return;
const verified = verifyBrowserSupportCookie({
cookieHeader: headerValue(request.headers.cookie),
release: browserSupportRelease,
secChUa: headerValue(request.headers["sec-ch-ua"]),
secret: browserSupportSecret,
});
if (!verified.supported) {
return sendBrowserUnsupported(reply, request.id, verified.reason, browserSupportRelease);
}
});
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/html; charset=utf-8");
return supportGateHtml;
});
}
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/css; charset=utf-8");
return supportGateCss;
});
app.get("/support-gate.js", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/javascript; charset=utf-8");
return supportGateJavaScript;
});
app.get("/RELEASE.json", { schema: { hide: true } }, async () => ({
app_version: browserSupportRelease?.appVersion ?? null,
browsers: supportedBrowserSummary(browserSupportRelease),
}));
app.get("/healthz", { schema: { hide: true } }, async () => ({
bind_scope: "loopback",
port: 43121,
status: "ready",
}));
app.post(
"/api/v1/support/check",
{
attachValidation: true,
schema: {
body: BrowserSupportRequestSchema,
operationId: "checkBrowserSupport",
response: {
200: BrowserSupportSuccessSchema,
426: ErrorEnvelopeSchema,
},
tags: ["Browser support"],
},
},
async (request, reply) => {
const checked = checkBrowserSupport(
request.validationError ? undefined : request.body,
{
secChUa: headerValue(request.headers["sec-ch-ua"]),
secChUaFullVersionList: headerValue(request.headers["sec-ch-ua-full-version-list"]),
secChUaPlatform: headerValue(request.headers["sec-ch-ua-platform"]),
},
browserSupportRelease,
);
if (!checked.supported || !browserSupportRelease) {
return sendBrowserUnsupported(
reply,
request.id,
checked.supported ? "version_unsupported" : checked.reason,
browserSupportRelease,
);
}
const cookie = createBrowserSupportCookie(
browserSupportSecret,
browserSupportRelease,
checked.identity,
);
reply.header(
"Set-Cookie",
`${browserSupportCookieName}=${cookie}; Max-Age=${browserSupportCookieMaxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
app_version: browserSupportRelease.appVersion,
browser: checked.identity,
status: "supported" as const,
supported_browsers: supportedBrowserSummary(browserSupportRelease),
};
},
);
app.get(
"/api/v1/bootstrap",
{
@@ -91,6 +269,7 @@ export async function createApp(options: CreateAppOptions = {}) {
operationId: "getBootstrap",
response: {
200: BootstrapResponseSchema,
426: ErrorEnvelopeSchema,
},
tags: ["Bootstrap"],
},
@@ -112,6 +291,7 @@ export async function createApp(options: CreateAppOptions = {}) {
},
description: "Non-sensitive state change hints. REST remains authoritative.",
},
426: ErrorEnvelopeSchema,
},
tags: ["State events"],
},
+275
View File
@@ -0,0 +1,275 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { Type, type Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
export const browserSupportCookieName = "dada_browser_support";
export const browserSupportCookieMaxAgeSeconds = 24 * 60 * 60;
export const BrowserUnsupportedReasonSchema = Type.Union(
[
Type.Literal("platform_unsupported"),
Type.Literal("brand_unsupported"),
Type.Literal("version_unsupported"),
Type.Literal("identity_unavailable"),
],
{ $id: "BrowserUnsupportedReason" },
);
const BrowserBrandVersionSchema = Type.Object(
{
brand: Type.String({ maxLength: 80 }),
version: Type.String({ maxLength: 80 }),
},
{ additionalProperties: false },
);
const SupportedBrowserSummarySchema = Type.Object(
{
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
major: Type.Integer({ minimum: 1 }),
},
{ additionalProperties: false },
);
export const BrowserSupportRequestSchema = Type.Object(
{
brands: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }),
full_version_list: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }),
platform: Type.String({ maxLength: 40 }),
},
{ additionalProperties: false, $id: "BrowserSupportRequest" },
);
export const BrowserSupportSuccessSchema = Type.Object(
{
app_version: Type.String({ maxLength: 80 }),
browser: SupportedBrowserSummarySchema,
status: Type.Literal("supported"),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
},
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
);
export type BrowserUnsupportedReason = Static<typeof BrowserUnsupportedReasonSchema>;
export type BrowserSupportRequest = Static<typeof BrowserSupportRequestSchema>;
type SupportedBrand = "Google Chrome" | "Microsoft Edge";
export interface BrowserSupportRelease {
appVersion: string;
browsers: ReadonlyArray<{
brand: SupportedBrand;
fullVersion: string;
}>;
}
interface BrowserIdentity {
brand: SupportedBrand;
major: number;
}
type BrowserSupportResult =
| { identity: BrowserIdentity; supported: true }
| { reason: BrowserUnsupportedReason; supported: false };
const supportedBrands = new Set<SupportedBrand>(["Google Chrome", "Microsoft Edge"]);
const fullVersionPattern = /^[1-9][0-9]*\.[0-9]+\.[0-9]+\.[0-9]+$/;
function major(version: string) {
if (!/^[1-9][0-9]*(?:\.[0-9]+)*$/.test(version)) return undefined;
const first = version.split(".")[0];
if (!first) return undefined;
const value = Number.parseInt(first, 10);
return Number.isSafeInteger(value) ? value : undefined;
}
function parsedHeaderList(value: string | undefined) {
if (!value) return undefined;
const entries: Array<{ brand: string; version: string }> = [];
const remainder = value.replace(/"([^"]+)"\s*;\s*v="([^"]+)"/g, (_match, brand, version) => {
entries.push({ brand, version });
return "";
});
if (entries.length === 0 || !/^[\s,]*$/.test(remainder)) return undefined;
return entries;
}
function parsedPlatform(value: string | undefined) {
const match = value?.match(/^"([^"]+)"$/);
return match?.[1];
}
function normalizedList(entries: Array<{ brand: string; version: string }>) {
return [...entries]
.sort((left, right) => left.brand.localeCompare(right.brand) || left.version.localeCompare(right.version))
.map(({ brand, version }) => `${brand}\u0000${version}`)
.join("\u0001");
}
function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
const matches = entries.filter(({ brand }) => supportedBrands.has(brand as SupportedBrand));
if (matches.length !== 1) return undefined;
const entry = matches[0];
if (!entry) return undefined;
const parsedMajor = major(entry.version);
if (!parsedMajor) return undefined;
return { brand: entry.brand as SupportedBrand, major: parsedMajor };
}
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
if (!release) return [];
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
}
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
if (!value || typeof value !== "object") return false;
const release = value as BrowserSupportRelease;
if (typeof release.appVersion !== "string" || release.appVersion.length === 0 || release.appVersion.length > 80) {
return false;
}
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
const brands = new Set(release.browsers.map(({ brand }) => brand));
return (
brands.size === 2 &&
brands.has("Google Chrome") &&
brands.has("Microsoft Edge") &&
release.browsers.every(
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
)
);
}
export function readBrowserSupportRelease(path: string) {
try {
const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
return validateBrowserSupportRelease(value) ? value : undefined;
} catch {
return undefined;
}
}
export function checkBrowserSupport(
body: unknown,
headers: {
secChUa: string | undefined;
secChUaFullVersionList: string | undefined;
secChUaPlatform: string | undefined;
},
release: BrowserSupportRelease | undefined,
): BrowserSupportResult {
if (!Value.Check(BrowserSupportRequestSchema, body)) {
return { reason: "identity_unavailable", supported: false };
}
const request = body as BrowserSupportRequest;
const headerBrands = parsedHeaderList(headers.secChUa);
const headerFullVersions = parsedHeaderList(headers.secChUaFullVersionList);
const headerPlatform = parsedPlatform(headers.secChUaPlatform);
if (!headerBrands || !headerFullVersions || !headerPlatform) {
return { reason: "identity_unavailable", supported: false };
}
if (
normalizedList(headerBrands) !== normalizedList(request.brands) ||
normalizedList(headerFullVersions) !== normalizedList(request.full_version_list) ||
headerPlatform !== request.platform
) {
return { reason: "identity_unavailable", supported: false };
}
if (request.platform !== "Windows") return { reason: "platform_unsupported", supported: false };
const lowIdentity = supportedIdentity(request.brands);
const fullIdentity = supportedIdentity(request.full_version_list);
if (!lowIdentity && request.brands.every(({ brand }) => !supportedBrands.has(brand as SupportedBrand))) {
return { reason: "brand_unsupported", supported: false };
}
if (
!lowIdentity ||
!fullIdentity ||
lowIdentity.brand !== fullIdentity.brand ||
lowIdentity.major !== fullIdentity.major
) {
return { reason: "identity_unavailable", supported: false };
}
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
return { reason: "version_unsupported", supported: false };
}
return { identity: fullIdentity, supported: true };
}
function signature(secret: Buffer, encodedPayload: string) {
return createHmac("sha256", secret).update(encodedPayload).digest("base64url");
}
export function createBrowserSupportCookie(
secret: Buffer,
release: BrowserSupportRelease,
identity: BrowserIdentity,
issuedAtSeconds = Math.floor(Date.now() / 1000),
) {
const encodedPayload = Buffer.from(
JSON.stringify({
app_version: release.appVersion,
brand: identity.brand,
issued_at: issuedAtSeconds,
major: identity.major,
}),
).toString("base64url");
return `${encodedPayload}.${signature(secret, encodedPayload)}`;
}
function cookieValue(cookieHeader: string | undefined) {
for (const pair of cookieHeader?.split(";") ?? []) {
const [name, ...rest] = pair.trim().split("=");
if (name === browserSupportCookieName) return rest.join("=");
}
return undefined;
}
export function verifyBrowserSupportCookie(input: {
cookieHeader: string | undefined;
nowSeconds?: number;
release: BrowserSupportRelease | undefined;
secChUa: string | undefined;
secret: Buffer;
}) {
if (!input.release) return { reason: "version_unsupported" as const, supported: false as const };
const value = cookieValue(input.cookieHeader);
if (!value) return { reason: "identity_unavailable" as const, supported: false as const };
const [encodedPayload, encodedSignature, extra] = value.split(".");
if (!encodedPayload || !encodedSignature || extra) {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const expected = Buffer.from(signature(input.secret, encodedPayload));
const actual = Buffer.from(encodedSignature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return { reason: "identity_unavailable" as const, supported: false as const };
}
let payload: { app_version?: unknown; brand?: unknown; issued_at?: unknown; major?: unknown };
try {
payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
} catch {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
if (
payload.app_version !== input.release.appVersion ||
!supportedBrands.has(payload.brand as SupportedBrand) ||
!Number.isSafeInteger(payload.major) ||
!Number.isSafeInteger(payload.issued_at) ||
(payload.issued_at as number) > now ||
now - (payload.issued_at as number) > browserSupportCookieMaxAgeSeconds
) {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const headerBrands = parsedHeaderList(input.secChUa);
const currentIdentity = headerBrands ? supportedIdentity(headerBrands) : undefined;
if (!currentIdentity) return { reason: "identity_unavailable" as const, supported: false as const };
if (currentIdentity.brand !== payload.brand) {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
return { reason: "version_unsupported" as const, supported: false as const };
}
return { identity: currentIdentity, supported: true as const };
}
+6 -2
View File
@@ -1,6 +1,10 @@
import { createApp } from "./app.js";
import { resolve } from "node:path";
const app = await createApp();
import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {});
await app.listen({
host: "127.0.0.1",
+27
View File
@@ -0,0 +1,27 @@
export const DADA_LOOPBACK_HOST = "127.0.0.1";
export const DADA_LOOPBACK_PORT = 43121;
export interface NetworkBoundaryOptions {
allowTestPort?: boolean;
}
interface NetworkRequest {
host: string | undefined;
method: string;
origin: string | undefined;
}
export function isAllowedNetworkRequest(
request: NetworkRequest,
options: NetworkBoundaryOptions = {},
) {
if (request.method === "OPTIONS" || !request.host) return false;
const allowedHost = options.allowTestPort
? /^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(request.host)
: request.host === `${DADA_LOOPBACK_HOST}:${DADA_LOOPBACK_PORT}`;
if (!allowedHost) return false;
if (request.origin !== undefined && request.origin !== `http://${request.host}`) return false;
if (!["GET", "HEAD"].includes(request.method) && request.origin === undefined) return false;
return true;
}
+41
View File
@@ -2,6 +2,47 @@
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
export async function checkBrowserSupport(body: {
"brands": Array<{
"brand": string;
"version": string;
}>;
"full_version_list": Array<{
"brand": string;
"version": string;
}>;
"platform": string;
}, options: ClientOptions = {}): Promise<{
"app_version": string;
"browser": {
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
};
"status": "supported";
"supported_browsers": Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
}> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"app_version": string;
"browser": {
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
};
"status": "supported";
"supported_browsers": Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
}>;
}
export async function getBootstrap(options: ClientOptions = {}): Promise<{
"app_version": string;
"dependencies": Array<{
+37
View File
@@ -18,6 +18,33 @@ export type BootstrapResponse = {
}>;
};
export type BrowserSupportRequest = {
"brands": Array<{
"brand": string;
"version": string;
}>;
"full_version_list": Array<{
"brand": string;
"version": string;
}>;
"platform": string;
};
export type BrowserSupportSuccess = {
"app_version": string;
"browser": {
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
};
"status": "supported";
"supported_browsers": Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
};
export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
export type CorrelationId = string;
export type ErrorDetails = {
@@ -28,7 +55,12 @@ export type ErrorDetails = {
"message_key": string;
}>;
"latest_version"?: number | string;
"reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
"remaining_bytes"?: number;
"supported_browsers"?: Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
};
export type ErrorEnvelope = {
@@ -43,7 +75,12 @@ export type ErrorEnvelope = {
"message_key": string;
}>;
"latest_version"?: number | string;
"reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
"remaining_bytes"?: number;
"supported_browsers"?: Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
};
"error_category"?: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
"message_key": string;
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="zh-CN" data-support-status="checking">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>浏览器支持检查 · Dada</title>
<link rel="stylesheet" href="/support-gate.css" />
</head>
<body>
<div class="top-rule" aria-hidden="true"></div>
<main>
<section class="gate" aria-labelledby="gate-title">
<div class="wordmark">DADA</div>
<div id="status-code" class="code">BROWSER_UNSUPPORTED</div>
<h1 id="gate-title" tabindex="0">当前浏览器无法使用 Dada</h1>
<p id="gate-description" class="description">
本次 P0-A 发布仅支持当前这台 Windows 电脑上、已经完成验收的 Chrome 或 Edge
版本。当前环境无法可靠通过支持检查,因此不会加载任何产品功能。
</p>
<div class="detection" role="status" aria-live="assertive" aria-atomic="true">
<dl>
<div>
<dt>检测原因</dt>
<dd id="reason-value" class="danger">identity_unavailable</dd>
</div>
<div>
<dt>当前浏览器</dt>
<dd id="browser-value">正在检测</dd>
</div>
<div>
<dt>受支持环境</dt>
<dd id="supported-value">等待发布记录</dd>
</div>
</dl>
</div>
<details>
<summary>查看如何在 Chrome / Edge 中打开本机地址</summary>
<p>
请在当前 Windows 电脑上使用本次发布记录支持的 Chrome 或 Edge,打开
<code>http://127.0.0.1:43121/</code>
</p>
</details>
<div class="actions">
<button id="retry-button" type="button">重新检测</button>
<button id="copy-button" class="secondary" type="button">复制本机地址</button>
</div>
<p id="no-bypass" class="no-bypass">没有“仍然继续”入口,也不会提供绕过令牌或参数。</p>
</section>
</main>
<footer>P0-A · LOCALHOST ONLY · WINDOWS</footer>
<script type="module" src="/support-gate.js"></script>
</body>
</html>
+212
View File
@@ -0,0 +1,212 @@
:root {
color: #111111;
background: #ffffff;
font-family: "Segoe UI", Arial, sans-serif;
font-synthesis: none;
letter-spacing: 0;
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
display: grid;
grid-template-rows: 10px minmax(0, 1fr) 48px;
background: #ffffff;
}
.top-rule {
background: #eaff00;
}
main {
display: grid;
place-items: center;
padding: 32px 24px;
}
.gate {
width: min(700px, 100%);
}
.wordmark {
margin-bottom: 16px;
font-size: 34px;
font-weight: 900;
line-height: 1;
}
.code {
display: inline-block;
padding: 6px 9px;
color: #eaff00;
background: #111111;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
h1 {
margin: 18px 0 12px;
font-size: 38px;
line-height: 1.15;
font-weight: 900;
}
.description {
margin: 0;
color: #555555;
font-size: 15px;
line-height: 1.7;
}
.detection {
min-height: 120px;
margin-top: 20px;
padding: 14px 16px;
border: 1px solid #111111;
background: #f5f5f5;
}
dl {
margin: 0;
}
dl > div {
min-height: 30px;
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr);
gap: 16px;
align-items: center;
}
dt {
color: #666666;
font-size: 12px;
}
dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
text-align: right;
font-family: Consolas, monospace;
font-size: 12px;
font-weight: 700;
}
.danger,
.no-bypass {
color: #b42318;
}
details {
margin-top: 14px;
border: 1px solid #c7c7c7;
padding: 14px 16px;
}
summary {
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
details p {
margin: 12px 0 0;
color: #444444;
font-size: 13px;
line-height: 1.6;
}
code {
font-family: Consolas, monospace;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
min-width: 170px;
min-height: 44px;
border: 1px solid #111111;
border-radius: 0;
padding: 0 16px;
color: #111111;
background: #eaff00;
font: inherit;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
button.secondary {
background: #ffffff;
}
button:focus-visible,
summary:focus-visible {
outline: 3px solid #0067c0;
outline-offset: 3px;
}
.no-bypass {
margin: 14px 0 0;
font-size: 12px;
font-weight: 800;
}
footer {
display: grid;
place-items: center;
color: #eaff00;
background: #111111;
font-family: Consolas, monospace;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 560px) {
body {
grid-template-rows: 8px auto 48px;
}
main {
padding: 24px 16px;
}
h1 {
font-size: 30px;
}
dl > div {
grid-template-columns: 1fr;
gap: 2px;
padding: 5px 0;
}
dd {
text-align: left;
}
.actions,
button {
width: 100%;
}
}
+94
View File
@@ -0,0 +1,94 @@
const localAddress = "http://127.0.0.1:43121/";
const root = document.documentElement;
const gateTitle = document.querySelector("#gate-title");
const gateDescription = document.querySelector("#gate-description");
const statusCode = document.querySelector("#status-code");
const noBypass = document.querySelector("#no-bypass");
const reasonValue = document.querySelector("#reason-value");
const browserValue = document.querySelector("#browser-value");
const supportedValue = document.querySelector("#supported-value");
const retryButton = document.querySelector("#retry-button");
const copyButton = document.querySelector("#copy-button");
function browserLabel(fullVersionList) {
const known = fullVersionList?.find(({ brand }) =>
brand === "Google Chrome" || brand === "Microsoft Edge");
return known ? `${known.brand} ${known.version.split(".")[0]}` : "无法可靠识别";
}
function supportedLabel(supportedBrowsers) {
if (!Array.isArray(supportedBrowsers) || supportedBrowsers.length === 0) return "等待发布记录";
return supportedBrowsers.map(({ brand, major }) => `${brand} ${major}`).join(" / ");
}
function showBlocked(reason, supportedBrowsers, fullVersionList) {
root.dataset.supportStatus = "blocked";
gateTitle.textContent = "当前浏览器无法使用 Dada";
gateDescription.textContent = "本次 P0-A 发布仅支持当前这台 Windows 电脑上、已经完成验收的 Chrome 或 Edge 版本。当前环境无法可靠通过支持检查,因此不会加载任何产品功能。";
statusCode.hidden = false;
noBypass.hidden = false;
reasonValue.classList.add("danger");
reasonValue.textContent = reason;
browserValue.textContent = browserLabel(fullVersionList);
supportedValue.textContent = supportedLabel(supportedBrowsers);
}
async function checkSupport() {
root.dataset.supportStatus = "checking";
reasonValue.textContent = "identity_unavailable";
browserValue.textContent = "正在检测";
const userAgentData = navigator.userAgentData;
if (!userAgentData || typeof userAgentData.getHighEntropyValues !== "function") {
showBlocked("identity_unavailable", [], []);
return;
}
try {
const entropy = await userAgentData.getHighEntropyValues(["fullVersionList", "platform"]);
const body = {
brands: userAgentData.brands ?? [],
full_version_list: entropy.fullVersionList ?? [],
platform: entropy.platform ?? "",
};
const response = await fetch("/api/v1/support/check", {
body: JSON.stringify(body),
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
method: "POST",
});
const result = await response.json();
if (response.ok && result.status === "supported") {
root.dataset.supportStatus = "supported";
gateTitle.textContent = "浏览器支持检查已通过";
gateDescription.textContent = "当前浏览器已通过 Dada 支持检查。";
statusCode.hidden = true;
noBypass.hidden = true;
reasonValue.classList.remove("danger");
reasonValue.textContent = "supported";
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
supportedValue.textContent = supportedLabel(result.supported_browsers);
window.dispatchEvent(new CustomEvent("dada:support-ready"));
return;
}
showBlocked(
result.error?.details?.reason ?? "identity_unavailable",
result.error?.details?.supported_browsers ?? [],
body.full_version_list,
);
} catch {
showBlocked("identity_unavailable", [], []);
}
}
retryButton.addEventListener("click", () => void checkSupport());
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(localAddress);
copyButton.textContent = "已复制本机地址";
} catch {
copyButton.textContent = localAddress;
}
});
void checkSupport();