775 lines
31 KiB
TypeScript
775 lines
31 KiB
TypeScript
import { readFileSync } from "node:fs";
|
||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||
import { join } from "node:path";
|
||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../shared/src/rotating-jsonl";
|
||
import { notificationStyles } from "./notification-styles";
|
||
|
||
interface RuntimeConfig {
|
||
port: number;
|
||
coreInternalUrl: string;
|
||
frontendPublicUrl: string;
|
||
providerIngressPublicUrl: string;
|
||
authUsername: string;
|
||
authPassword: string;
|
||
sessionSecret: string;
|
||
sessionTtlSeconds: number;
|
||
logFile: string;
|
||
deploy: {
|
||
serviceId: string;
|
||
repo: string;
|
||
commit: string;
|
||
requestedCommit: string;
|
||
};
|
||
}
|
||
|
||
interface SessionPayload {
|
||
username: string;
|
||
expiresAt: number;
|
||
nonce: string;
|
||
}
|
||
|
||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||
|
||
interface RequestPerformanceSample {
|
||
at: string;
|
||
component: string;
|
||
method: string;
|
||
path: string;
|
||
status: number;
|
||
durationMs: number;
|
||
ok: boolean;
|
||
}
|
||
|
||
interface OperationPerformanceSample {
|
||
at: string;
|
||
service: string;
|
||
operation: string;
|
||
durationMs: number;
|
||
ok: boolean;
|
||
detail: string;
|
||
}
|
||
|
||
const sessionCookieName = "unidesk_session";
|
||
const config = readConfig();
|
||
const logger = createLogger("frontend", config.logFile);
|
||
const publicDir = join(import.meta.dir, "..", "public");
|
||
const vendorDir = join(import.meta.dir, "..", "node_modules");
|
||
const docsDir = join(import.meta.dir, "../../../..", "docs");
|
||
const appBundle = await buildFrontendApp("app.tsx");
|
||
const clientConfig = JSON.stringify({
|
||
frontendPublicUrl: config.frontendPublicUrl,
|
||
providerIngressPublicUrl: config.providerIngressPublicUrl,
|
||
authUsername: config.authUsername,
|
||
sessionTtlSeconds: config.sessionTtlSeconds,
|
||
apiBaseUrl: "/api",
|
||
});
|
||
const indexHtmlTemplate = readFileSync(join(publicDir, "index.html"), "utf8");
|
||
const indexHtmlRootMarker = '<div id="root" data-config="__UNIDESK_CONFIG__"></div>';
|
||
const baiduNetdiskDocsFallbackLinks = [
|
||
{ href: "/docs/issue/baidu-netdisk-env-setup.md", section: "Baidu Netdisk hero action / 配置与文档 SETUP", label: "Baidu Netdisk 环境变量配置" },
|
||
{ href: "/docs/issue/baidu-netdisk-user-service.md", section: "配置与文档 DESIGN", label: "Baidu Netdisk 服务方案与 API" },
|
||
{ href: "/docs/reference/microservices.md", section: "配置与文档 REF", label: "UniDesk 用户服务安全边界" },
|
||
{ href: "/docs/reference/deployment.md", section: "配置与文档 DEPLOY", label: "UniDesk 部署与重建流程" },
|
||
{ href: "/docs/reference/cli.md", section: "配置与文档 CLI", label: "UniDesk CLI 验证命令" },
|
||
];
|
||
function renderIndexHtml(extraRootAttributes = ""): string {
|
||
const docsFallback = `<nav hidden data-page="baidu-netdisk" data-section="配置与文档">${baiduNetdiskDocsFallbackLinks.map((link) => {
|
||
const href = new URL(link.href, config.frontendPublicUrl).toString();
|
||
return `<a href="${escapeHtmlAttribute(href)}" data-page-section="${escapeHtmlAttribute(link.section)}">${escapeHtmlText(link.label)}</a>`;
|
||
}).join("")}</nav>`;
|
||
const notificationStyleTag = `<style>${notificationStyles}</style>`;
|
||
const htmlWithStyles = indexHtmlTemplate.includes("</head>")
|
||
? indexHtmlTemplate.replace("</head>", `${notificationStyleTag}</head>`)
|
||
: indexHtmlTemplate;
|
||
return htmlWithStyles.replace(
|
||
indexHtmlRootMarker,
|
||
`<div id="root" data-config="${escapeHtmlAttribute(clientConfig)}"${extraRootAttributes}>${docsFallback}</div>`,
|
||
);
|
||
}
|
||
|
||
async function spaShellHtml(req: Request, pathname: string): Promise<string> {
|
||
void req;
|
||
void pathname;
|
||
// Do not inline Codex task overview JSON into the root element: task prompts
|
||
// can be very large and contain quote-heavy text, which makes malformed
|
||
// markup failures show raw JSON at the page tail. The React page fetches the
|
||
// same overview through the authenticated API after mounting.
|
||
return renderIndexHtml();
|
||
}
|
||
|
||
const requestPerformanceSamples: RequestPerformanceSample[] = [];
|
||
const operationPerformanceSamples: OperationPerformanceSample[] = [];
|
||
const maxPerformanceSamples = 3000;
|
||
|
||
async function buildFrontendApp(entrypoint: string): Promise<string> {
|
||
const result = await Bun.build({
|
||
entrypoints: [join(import.meta.dir, entrypoint)],
|
||
target: "browser",
|
||
format: "iife",
|
||
define: {
|
||
"process.env.NODE_ENV": "\"production\"",
|
||
"import.meta.env": "{\"MODE\":\"production\"}",
|
||
"import.meta.env.MODE": "\"production\"",
|
||
},
|
||
minify: true,
|
||
sourcemap: "none",
|
||
});
|
||
if (!result.success || result.outputs.length === 0) {
|
||
const messages = result.logs.map((item) => item.message).join("; ");
|
||
throw new Error(`frontend ${entrypoint} build failed: ${messages || "no output"}`);
|
||
}
|
||
return result.outputs[0].text();
|
||
}
|
||
|
||
function requiredEnv(name: string): string {
|
||
const value = process.env[name];
|
||
if (value === undefined || value.length === 0) {
|
||
throw new Error(`Missing required environment variable: ${name}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readNumberEnv(name: string): number {
|
||
const raw = requiredEnv(name);
|
||
const parsed = Number(raw);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error(`Environment variable ${name} must be a positive number, got ${raw}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function readConfig(): RuntimeConfig {
|
||
return {
|
||
port: readNumberEnv("PORT"),
|
||
coreInternalUrl: requiredEnv("CORE_INTERNAL_URL"),
|
||
frontendPublicUrl: requiredEnv("FRONTEND_PUBLIC_URL"),
|
||
providerIngressPublicUrl: requiredEnv("PROVIDER_INGRESS_PUBLIC_URL"),
|
||
authUsername: requiredEnv("AUTH_USERNAME"),
|
||
authPassword: requiredEnv("AUTH_PASSWORD"),
|
||
sessionSecret: requiredEnv("SESSION_SECRET"),
|
||
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
|
||
logFile: requiredEnv("LOG_FILE"),
|
||
deploy: {
|
||
serviceId: process.env.UNIDESK_DEPLOY_SERVICE_ID || "frontend",
|
||
repo: process.env.UNIDESK_DEPLOY_REPO || "",
|
||
commit: process.env.UNIDESK_DEPLOY_COMMIT || "",
|
||
requestedCommit: process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || "",
|
||
},
|
||
};
|
||
}
|
||
|
||
function createLogger(service: string, logFile: string) {
|
||
const writer = createHourlyJsonlWriter({
|
||
baseLogFile: logFile,
|
||
service,
|
||
maxBytes: logRetentionBytesForService(service),
|
||
});
|
||
writer.prune();
|
||
return (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue): void => {
|
||
const entry = data === undefined
|
||
? { ts: new Date().toISOString(), service, level, message }
|
||
: { ts: new Date().toISOString(), service, level, message, data };
|
||
const line = `${JSON.stringify(entry)}\n`;
|
||
try {
|
||
writer.appendLine(line, new Date(entry.ts));
|
||
} catch (error) {
|
||
console.error(JSON.stringify({ ts: new Date().toISOString(), service, level: "error", message: "log_write_failed", data: String(error) }));
|
||
}
|
||
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
||
consoleMethod(line.trimEnd());
|
||
};
|
||
}
|
||
|
||
function contentType(pathname: string): string {
|
||
if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
|
||
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
||
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||
if (pathname.endsWith(".md")) return "text/markdown; charset=utf-8";
|
||
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
||
if (pathname.endsWith(".ico")) return "image/x-icon";
|
||
return "text/plain; charset=utf-8";
|
||
}
|
||
|
||
function textResponse(req: Request, body: string, type: string): Response {
|
||
const headers = new Headers({ "content-type": type, "vary": "accept-encoding" });
|
||
if (/\bgzip\b/u.test(req.headers.get("accept-encoding") ?? "")) {
|
||
headers.set("content-encoding", "gzip");
|
||
return new Response(Bun.gzipSync(body), { headers });
|
||
}
|
||
return new Response(body, { headers });
|
||
}
|
||
|
||
function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit): Response {
|
||
const headers = new Headers({ "content-type": "application/json; charset=utf-8" });
|
||
if (extraHeaders !== undefined) {
|
||
new Headers(extraHeaders).forEach((value, key) => headers.set(key, value));
|
||
}
|
||
return new Response(JSON.stringify(body, null, 2), {
|
||
status,
|
||
headers,
|
||
});
|
||
}
|
||
|
||
function safePreview(value: string, max = 400): string {
|
||
const normalized = value.replace(/\s+/gu, " ").trim();
|
||
return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
|
||
}
|
||
|
||
function trimPerformanceBuffers(): void {
|
||
while (requestPerformanceSamples.length > maxPerformanceSamples) requestPerformanceSamples.shift();
|
||
while (operationPerformanceSamples.length > maxPerformanceSamples) operationPerformanceSamples.shift();
|
||
}
|
||
|
||
function classifyRequestComponent(pathname: string): string {
|
||
if (pathname === "/api/frontend-performance") return "webui_performance";
|
||
if (pathname.startsWith("/api/") || pathname === "/logs") return "webui_api_proxy";
|
||
if (pathname === "/login" || pathname === "/logout" || pathname === "/api/session") return "webui_auth";
|
||
if (pathname === "/app.js" || pathname.startsWith("/vendor/") || /\/[^/]+\.[a-z0-9]+$/iu.test(pathname)) return "webui_static";
|
||
return "webui_page";
|
||
}
|
||
|
||
function recordRequestPerformance(req: Request, pathname: string, response: Response | undefined, durationMs: number): void {
|
||
const status = response?.status ?? 500;
|
||
requestPerformanceSamples.push({
|
||
at: new Date().toISOString(),
|
||
component: classifyRequestComponent(pathname),
|
||
method: req.method,
|
||
path: pathname,
|
||
status,
|
||
durationMs,
|
||
ok: status < 400,
|
||
});
|
||
trimPerformanceBuffers();
|
||
}
|
||
|
||
function recordOperationPerformance(service: string, operation: string, durationMs: number, ok: boolean, detail = "-"): void {
|
||
operationPerformanceSamples.push({
|
||
at: new Date().toISOString(),
|
||
service,
|
||
operation,
|
||
durationMs,
|
||
ok,
|
||
detail: detail.length > 260 ? `${detail.slice(0, 257)}...` : detail,
|
||
});
|
||
trimPerformanceBuffers();
|
||
}
|
||
|
||
function percentile(values: number[], ratio: number): number {
|
||
if (values.length === 0) return 0;
|
||
const sorted = [...values].sort((left, right) => left - right);
|
||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
|
||
return sorted[index] ?? 0;
|
||
}
|
||
|
||
function average(values: number[]): number {
|
||
if (values.length === 0) return 0;
|
||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||
}
|
||
|
||
function roundMs(value: number): number {
|
||
return Math.round(value * 10) / 10;
|
||
}
|
||
|
||
function summarizeRequestPerformance(): JsonValue[] {
|
||
const groups = new Map<string, RequestPerformanceSample[]>();
|
||
for (const sample of requestPerformanceSamples) {
|
||
const rows = groups.get(sample.component) ?? [];
|
||
rows.push(sample);
|
||
groups.set(sample.component, rows);
|
||
}
|
||
return Array.from(groups.entries()).map(([component, rows]) => {
|
||
const durations = rows.map((row) => row.durationMs);
|
||
const failed = rows.filter((row) => !row.ok).length;
|
||
return {
|
||
component,
|
||
requestCount: rows.length,
|
||
failureCount: failed,
|
||
failureRate: rows.length === 0 ? 0 : failed / rows.length,
|
||
averageLatencyMs: roundMs(average(durations)),
|
||
p95LatencyMs: roundMs(percentile(durations, 0.95)),
|
||
maxLatencyMs: roundMs(Math.max(0, ...durations)),
|
||
};
|
||
}).sort((left, right) => Number((right as Record<string, JsonValue>).requestCount ?? 0) - Number((left as Record<string, JsonValue>).requestCount ?? 0)) as JsonValue[];
|
||
}
|
||
|
||
function summarizeOperationPerformance(): JsonValue[] {
|
||
const groups = new Map<string, OperationPerformanceSample[]>();
|
||
for (const sample of operationPerformanceSamples) {
|
||
const key = `${sample.service}:${sample.operation}`;
|
||
const rows = groups.get(key) ?? [];
|
||
rows.push(sample);
|
||
groups.set(key, rows);
|
||
}
|
||
return Array.from(groups.entries()).map(([key, rows]) => {
|
||
const [service, ...operationParts] = key.split(":");
|
||
const durations = rows.map((row) => row.durationMs);
|
||
const failed = rows.filter((row) => !row.ok).length;
|
||
return {
|
||
service,
|
||
operation: operationParts.join(":"),
|
||
count: rows.length,
|
||
failureCount: failed,
|
||
averageLatencyMs: roundMs(average(durations)),
|
||
p95LatencyMs: roundMs(percentile(durations, 0.95)),
|
||
maxLatencyMs: roundMs(Math.max(0, ...durations)),
|
||
};
|
||
}).sort((left, right) => Number((right as Record<string, JsonValue>).count ?? 0) - Number((left as Record<string, JsonValue>).count ?? 0)) as JsonValue[];
|
||
}
|
||
|
||
function frontendPerformanceResponse(req: Request): Response {
|
||
if (sessionFromRequest(req) === null) {
|
||
return jsonResponse({ ok: false, error: "authentication required" }, 401);
|
||
}
|
||
const memory = process.memoryUsage();
|
||
const recentOperationCutoff = Date.now() - 10 * 60 * 1000;
|
||
return jsonResponse({
|
||
ok: true,
|
||
service: "frontend",
|
||
generatedAt: new Date().toISOString(),
|
||
appBundleBytes: Buffer.byteLength(appBundle, "utf8"),
|
||
requests: {
|
||
sampleCount: requestPerformanceSamples.length,
|
||
componentSummary: summarizeRequestPerformance(),
|
||
recentFailures: requestPerformanceSamples.filter((sample) => !sample.ok).slice(-20).reverse(),
|
||
},
|
||
operations: {
|
||
sampleCount: operationPerformanceSamples.length,
|
||
summary: summarizeOperationPerformance(),
|
||
recentSlowOperations: operationPerformanceSamples
|
||
.filter((sample) => {
|
||
const at = Date.parse(sample.at);
|
||
return Number.isFinite(at) && at >= recentOperationCutoff;
|
||
})
|
||
.sort((left, right) => Date.parse(right.at) - Date.parse(left.at))
|
||
.slice(0, 20),
|
||
},
|
||
process: {
|
||
rssBytes: memory.rss,
|
||
heapUsedBytes: memory.heapUsed,
|
||
heapTotalBytes: memory.heapTotal,
|
||
externalBytes: memory.external,
|
||
arrayBuffersBytes: memory.arrayBuffers,
|
||
},
|
||
});
|
||
}
|
||
|
||
function escapeHtmlAttribute(value: string): string {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function escapeHtmlText(value: string): string {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function docsSlug(value: string): string {
|
||
const slug = String(value || "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[`*_~[\]().::,,/\\?#%]+/gu, "")
|
||
.replace(/\s+/gu, "-")
|
||
.replace(/-+/gu, "-")
|
||
.replace(/^-+|-+$/gu, "");
|
||
return slug || "section";
|
||
}
|
||
|
||
function isSafeDocLink(url: string): boolean {
|
||
return /^https?:\/\//u.test(url) || url.startsWith("/docs/");
|
||
}
|
||
|
||
function linkifiedMarkdownInline(value: string): string {
|
||
const pattern = /(`[^`]+`|\[[^\]]+\]\((?:https?:\/\/|\/docs\/)[^) \t]+\)|https?:\/\/[^\s<>)]+|\/docs\/[^\s<>)]+)/gu;
|
||
let cursor = 0;
|
||
let html = "";
|
||
for (const match of value.matchAll(pattern)) {
|
||
const token = match[0];
|
||
const index = match.index ?? 0;
|
||
html += escapeHtmlText(value.slice(cursor, index));
|
||
cursor = index + token.length;
|
||
if (token.startsWith("`") && token.endsWith("`")) {
|
||
html += `<code>${escapeHtmlText(token.slice(1, -1))}</code>`;
|
||
continue;
|
||
}
|
||
const markdownLink = token.match(/^\[([^\]]+)\]\((.+)\)$/u);
|
||
if (markdownLink !== null) {
|
||
const [, label, href] = markdownLink;
|
||
html += isSafeDocLink(href)
|
||
? `<a href="${escapeHtmlAttribute(href)}" target="_blank" rel="noreferrer">${escapeHtmlText(label)}</a>`
|
||
: escapeHtmlText(token);
|
||
continue;
|
||
}
|
||
html += `<a href="${escapeHtmlAttribute(token)}" target="_blank" rel="noreferrer">${escapeHtmlText(token)}</a>`;
|
||
}
|
||
html += escapeHtmlText(value.slice(cursor));
|
||
return html;
|
||
}
|
||
|
||
function renderMarkdownBody(markdown: string): string {
|
||
const lines = markdown.split(/\r?\n/u);
|
||
let html = "";
|
||
let paragraph: string[] = [];
|
||
let listKind: "ul" | "ol" | null = null;
|
||
let inFence = false;
|
||
|
||
function flushParagraph(): void {
|
||
if (paragraph.length === 0) return;
|
||
html += `<p>${linkifiedMarkdownInline(paragraph.join(" "))}</p>`;
|
||
paragraph = [];
|
||
}
|
||
|
||
function closeList(): void {
|
||
if (listKind === null) return;
|
||
html += `</${listKind}>`;
|
||
listKind = null;
|
||
}
|
||
|
||
function openList(kind: "ul" | "ol"): void {
|
||
flushParagraph();
|
||
if (listKind === kind) return;
|
||
closeList();
|
||
listKind = kind;
|
||
html += `<${kind}>`;
|
||
}
|
||
|
||
for (const line of lines) {
|
||
if (/^```/u.test(line.trim())) {
|
||
flushParagraph();
|
||
closeList();
|
||
html += inFence ? "</code></pre>" : "<pre><code>";
|
||
inFence = !inFence;
|
||
continue;
|
||
}
|
||
if (inFence) {
|
||
html += `${escapeHtmlText(line)}\n`;
|
||
continue;
|
||
}
|
||
const heading = line.match(/^(#{1,6})\s+(.+)$/u);
|
||
if (heading !== null) {
|
||
flushParagraph();
|
||
closeList();
|
||
const level = Math.min(6, heading[1].length);
|
||
const title = heading[2].trim();
|
||
html += `<h${level} id="${escapeHtmlAttribute(docsSlug(title))}">${linkifiedMarkdownInline(title)}</h${level}>`;
|
||
continue;
|
||
}
|
||
const unordered = line.match(/^\s*-\s+(.+)$/u);
|
||
if (unordered !== null) {
|
||
openList("ul");
|
||
html += `<li>${linkifiedMarkdownInline(unordered[1])}</li>`;
|
||
continue;
|
||
}
|
||
const ordered = line.match(/^\s*\d+\.\s+(.+)$/u);
|
||
if (ordered !== null) {
|
||
openList("ol");
|
||
html += `<li>${linkifiedMarkdownInline(ordered[1])}</li>`;
|
||
continue;
|
||
}
|
||
if (line.trim().length === 0) {
|
||
flushParagraph();
|
||
closeList();
|
||
continue;
|
||
}
|
||
paragraph.push(line.trim());
|
||
}
|
||
flushParagraph();
|
||
closeList();
|
||
if (inFence) html += "</code></pre>";
|
||
return html;
|
||
}
|
||
|
||
function renderDocsHtml(relativePath: string, markdown: string): string {
|
||
const firstHeading = markdown.match(/^#\s+(.+)$/mu)?.[1]?.trim();
|
||
const title = firstHeading || relativePath.split("/").pop() || "UniDesk Docs";
|
||
const escapedTitle = escapeHtmlText(title);
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>${escapedTitle} - UniDesk Docs</title>
|
||
<style>
|
||
:root { color-scheme: dark; --bg:#0d141a; --panel:#131d26; --line:#2b3a45; --text:#d7e3e7; --muted:#81939f; --accent:#d7a13a; --accent2:#4eb7a8; }
|
||
* { box-sizing: border-box; }
|
||
body { margin:0; color:var(--text); background:radial-gradient(circle at top left, rgba(78,183,168,.12), transparent 30%), var(--bg); font:15px/1.66 "Aptos Narrow", "Noto Sans", sans-serif; }
|
||
main { width:min(980px, calc(100vw - 28px)); margin:24px auto 56px; padding:22px; border:1px solid var(--line); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)), var(--panel); box-shadow:0 18px 46px rgba(0,0,0,.32); }
|
||
nav { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
|
||
a { color:var(--accent2); text-decoration:none; border-bottom:1px solid rgba(78,183,168,.35); }
|
||
a:hover { color:var(--accent); border-bottom-color:var(--accent); }
|
||
nav a { padding:4px 8px; border:1px solid var(--line); background:rgba(12,18,24,.62); color:var(--text); }
|
||
h1, h2, h3, h4, h5, h6 { line-height:1.25; letter-spacing:.03em; }
|
||
h1 { margin:0 0 14px; font-size:28px; }
|
||
h2 { margin-top:28px; padding-top:16px; border-top:1px solid var(--line); color:#f1d18b; }
|
||
h3 { color:#a6ded5; }
|
||
code { padding:1px 4px; border:1px solid rgba(255,255,255,.08); background:rgba(0,0,0,.28); border-radius:3px; font-family:"Cascadia Mono","IBM Plex Mono",monospace; font-size:.9em; }
|
||
pre { overflow:auto; padding:14px; border:1px solid var(--line); background:#071016; }
|
||
pre code { padding:0; border:0; background:transparent; }
|
||
li { margin:5px 0; }
|
||
.path { margin:0 0 16px; color:var(--muted); font-size:12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<nav>
|
||
<a href="/app/baidu-netdisk/">返回 Baidu Netdisk</a>
|
||
<a href="/docs/issue/baidu-netdisk-env-setup.md">环境变量配置</a>
|
||
<a href="/docs/issue/baidu-netdisk-user-service.md">服务设计文档</a>
|
||
<a href="/docs/reference/microservices.md">用户服务参考</a>
|
||
</nav>
|
||
<p class="path">${escapeHtmlText(relativePath)}</p>
|
||
${renderMarkdownBody(markdown)}
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function safeDocsRelativePath(pathname: string): string | null {
|
||
const raw = pathname === "/docs" ? "" : pathname.slice("/docs/".length);
|
||
let decoded = "";
|
||
try {
|
||
decoded = decodeURIComponent(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!decoded || decoded.includes("\0") || decoded.includes("\\") || decoded.startsWith("/") || decoded.split("/").includes("..")) return null;
|
||
if (!decoded.endsWith(".md")) return null;
|
||
return decoded;
|
||
}
|
||
|
||
async function docsResponse(req: Request, url: URL): Promise<Response> {
|
||
if (req.method !== "GET" && req.method !== "HEAD") return jsonResponse({ ok: false, error: "method not allowed" }, 405);
|
||
if (sessionFromRequest(req) === null) return textResponse(req, "authentication required", "text/plain; charset=utf-8");
|
||
const relativePath = safeDocsRelativePath(url.pathname);
|
||
if (relativePath === null) return jsonResponse({ ok: false, error: "invalid docs path", path: url.pathname }, 400);
|
||
const file = Bun.file(join(docsDir, relativePath));
|
||
if (!(await file.exists())) return jsonResponse({ ok: false, error: "doc not found", path: relativePath }, 404);
|
||
if (req.method === "HEAD") return new Response(null, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||
return textResponse(req, renderDocsHtml(relativePath, await file.text()), "text/html; charset=utf-8");
|
||
}
|
||
|
||
function signPayload(payload: string): string {
|
||
return createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
|
||
}
|
||
|
||
function safeEqual(a: string, b: string): boolean {
|
||
const left = Buffer.from(a);
|
||
const right = Buffer.from(b);
|
||
return left.length === right.length && timingSafeEqual(left, right);
|
||
}
|
||
|
||
function createSession(username: string): { token: string; expiresAt: number } {
|
||
const expiresAt = Date.now() + config.sessionTtlSeconds * 1000;
|
||
const payload: SessionPayload = { username, expiresAt, nonce: randomBytes(12).toString("base64url") };
|
||
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||
return { token: `${encoded}.${signPayload(encoded)}`, expiresAt };
|
||
}
|
||
|
||
function verifySession(token: string | undefined): SessionPayload | null {
|
||
if (!token) return null;
|
||
const [encoded, signature, extra] = token.split(".");
|
||
if (!encoded || !signature || extra !== undefined) return null;
|
||
if (!safeEqual(signPayload(encoded), signature)) return null;
|
||
try {
|
||
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Partial<SessionPayload>;
|
||
if (payload.username !== config.authUsername || typeof payload.expiresAt !== "number" || payload.expiresAt <= Date.now()) return null;
|
||
if (typeof payload.nonce !== "string") return null;
|
||
return payload as SessionPayload;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function parseCookies(header: string | null): Record<string, string> {
|
||
const cookies: Record<string, string> = {};
|
||
if (!header) return cookies;
|
||
for (const item of header.split(";")) {
|
||
const index = item.indexOf("=");
|
||
if (index === -1) continue;
|
||
const key = item.slice(0, index).trim();
|
||
const value = item.slice(index + 1).trim();
|
||
try {
|
||
cookies[key] = decodeURIComponent(value);
|
||
} catch {
|
||
cookies[key] = value;
|
||
}
|
||
}
|
||
return cookies;
|
||
}
|
||
|
||
function sessionFromRequest(req: Request): SessionPayload | null {
|
||
return verifySession(parseCookies(req.headers.get("cookie"))[sessionCookieName]);
|
||
}
|
||
|
||
function setSessionCookie(token: string): string {
|
||
return `${sessionCookieName}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${config.sessionTtlSeconds}`;
|
||
}
|
||
|
||
function clearSessionCookie(): string {
|
||
return `${sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
||
}
|
||
|
||
async function readLoginBody(req: Request): Promise<{ username: string; password: string }> {
|
||
const contentTypeHeader = req.headers.get("content-type") ?? "";
|
||
if (contentTypeHeader.includes("application/x-www-form-urlencoded") || contentTypeHeader.includes("multipart/form-data")) {
|
||
const form = await req.formData();
|
||
return { username: String(form.get("username") ?? ""), password: String(form.get("password") ?? "") };
|
||
}
|
||
const body = (await req.json()) as { username?: unknown; password?: unknown };
|
||
return {
|
||
username: typeof body.username === "string" ? body.username : "",
|
||
password: typeof body.password === "string" ? body.password : "",
|
||
};
|
||
}
|
||
|
||
async function login(req: Request): Promise<Response> {
|
||
try {
|
||
const body = await readLoginBody(req);
|
||
if (body.username !== config.authUsername || body.password !== config.authPassword) {
|
||
logger("warn", "login_failed", { username: body.username });
|
||
return jsonResponse({ ok: false, error: "invalid credentials" }, 401);
|
||
}
|
||
const session = createSession(body.username);
|
||
logger("info", "login_succeeded", { username: body.username, expiresAt: new Date(session.expiresAt).toISOString() });
|
||
return jsonResponse(
|
||
{ ok: true, user: { username: body.username }, expiresAt: new Date(session.expiresAt).toISOString() },
|
||
200,
|
||
{ "set-cookie": setSessionCookie(session.token) },
|
||
);
|
||
} catch (error) {
|
||
logger("error", "login_failed", { error: error instanceof Error ? error.message : String(error) });
|
||
return jsonResponse({ ok: false, error: "invalid login request" }, 400);
|
||
}
|
||
}
|
||
|
||
function logout(): Response {
|
||
return jsonResponse({ ok: true }, 200, { "set-cookie": clearSessionCookie() });
|
||
}
|
||
|
||
function sessionResponse(req: Request): Response {
|
||
const session = sessionFromRequest(req);
|
||
if (session === null) return jsonResponse({ ok: true, authenticated: false });
|
||
return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() });
|
||
}
|
||
|
||
async function proxyApi(req: Request, url: URL): Promise<Response> {
|
||
if (sessionFromRequest(req) === null) {
|
||
return jsonResponse({ ok: false, error: "authentication required" }, 401);
|
||
}
|
||
const upstreamUrl = new URL(`${url.pathname}${url.search}`, config.coreInternalUrl);
|
||
const headers = new Headers(req.headers);
|
||
headers.delete("host");
|
||
headers.delete("connection");
|
||
headers.delete("content-length");
|
||
headers.delete("cookie");
|
||
const init: RequestInit = { method: req.method, headers, redirect: "manual", signal: req.signal };
|
||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||
init.body = await req.arrayBuffer();
|
||
}
|
||
let upstream: Response;
|
||
const started = performance.now();
|
||
try {
|
||
upstream = await fetch(upstreamUrl, init);
|
||
recordOperationPerformance("frontend", "core_proxy", performance.now() - started, upstream.ok, `${req.method} ${url.pathname}`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
recordOperationPerformance("frontend", "core_proxy", performance.now() - started, false, message);
|
||
logger("warn", "proxy_upstream_failed", { path: url.pathname, upstreamUrl: upstreamUrl.toString(), error: message });
|
||
return jsonResponse({ ok: false, error: { message: "upstream proxy failed", detail: message } }, 502);
|
||
}
|
||
const responseHeaders = new Headers();
|
||
const upstreamContentType = upstream.headers.get("content-type");
|
||
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
|
||
if ((upstreamContentType ?? "").toLowerCase().includes("text/event-stream")) {
|
||
responseHeaders.set("cache-control", upstream.headers.get("cache-control") || "no-store, no-transform");
|
||
responseHeaders.set("connection", "keep-alive");
|
||
const buffering = upstream.headers.get("x-accel-buffering");
|
||
if (buffering !== null) responseHeaders.set("x-accel-buffering", buffering);
|
||
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||
}
|
||
return new Response(await upstream.arrayBuffer(), { status: upstream.status, headers: responseHeaders });
|
||
}
|
||
|
||
function vendorPath(pathname: string): string | null {
|
||
if (pathname === "/vendor/react.production.min.js") return join(vendorDir, "react", "umd", "react.production.min.js");
|
||
if (pathname === "/vendor/react-dom.production.min.js") return join(vendorDir, "react-dom", "umd", "react-dom.production.min.js");
|
||
if (pathname === "/vendor/xyflow.css") return join(vendorDir, "@xyflow", "react", "dist", "style.css");
|
||
return null;
|
||
}
|
||
|
||
async function staticResponse(req: Request, pathname: string): Promise<Response> {
|
||
if (pathname === "/app.js") {
|
||
return textResponse(req, appBundle, "text/javascript; charset=utf-8");
|
||
}
|
||
const vendor = vendorPath(pathname);
|
||
const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, ""));
|
||
const file = Bun.file(filePath);
|
||
if (!(await file.exists())) {
|
||
return jsonResponse({ ok: false, error: "not found", path: pathname }, 404);
|
||
}
|
||
return new Response(file, { headers: { "content-type": contentType(pathname) } });
|
||
}
|
||
|
||
function isStaticAssetPath(pathname: string): boolean {
|
||
return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname);
|
||
}
|
||
|
||
async function handleRequest(req: Request): Promise<Response> {
|
||
const url = new URL(req.url);
|
||
logger("debug", "request", { path: url.pathname });
|
||
try {
|
||
if (url.pathname === "/health") {
|
||
return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl, deploy: config.deploy });
|
||
}
|
||
if (url.pathname === "/login" && req.method === "POST") return login(req);
|
||
if (url.pathname === "/logout" && req.method === "POST") return logout();
|
||
if (url.pathname === "/api/session") return sessionResponse(req);
|
||
if (url.pathname === "/api/frontend-performance") return frontendPerformanceResponse(req);
|
||
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
|
||
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);
|
||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||
return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8");
|
||
}
|
||
const safePath = url.pathname.replace(/^\/+/, "");
|
||
if (safePath.includes("..") || safePath.includes("\0")) {
|
||
return jsonResponse({ ok: false, error: "invalid path" }, 400);
|
||
}
|
||
if (!isStaticAssetPath(url.pathname)) {
|
||
return textResponse(req, await spaShellHtml(req, url.pathname), "text/html; charset=utf-8");
|
||
}
|
||
return staticResponse(req, url.pathname);
|
||
} catch (error) {
|
||
logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) });
|
||
return jsonResponse({ ok: false, error: "request failed" }, 500);
|
||
}
|
||
}
|
||
|
||
const server = Bun.serve({
|
||
port: config.port,
|
||
hostname: "0.0.0.0",
|
||
idleTimeout: 120,
|
||
async fetch(req) {
|
||
const started = performance.now();
|
||
const url = new URL(req.url);
|
||
let response: Response | undefined;
|
||
try {
|
||
response = await handleRequest(req);
|
||
return response;
|
||
} finally {
|
||
recordRequestPerformance(req, url.pathname, response, performance.now() - started);
|
||
}
|
||
},
|
||
});
|
||
|
||
logger("info", "server_listening", {
|
||
url: `http://0.0.0.0:${server.port}`,
|
||
coreInternalUrl: config.coreInternalUrl,
|
||
frontendPublicUrl: config.frontendPublicUrl,
|
||
providerIngressPublicUrl: config.providerIngressPublicUrl,
|
||
logFile: config.logFile,
|
||
});
|