feat: wire wechat archive through langbot and n8n
This commit is contained in:
@@ -15,6 +15,7 @@ interface RuntimeConfig {
|
||||
providerToken: string | null;
|
||||
sshClientToken: string | null;
|
||||
sshClientRouteAllowlist: string[];
|
||||
wechatArchiveToken: string | null;
|
||||
sessionSecret: string;
|
||||
sessionTtlSeconds: number;
|
||||
logFile: string;
|
||||
@@ -211,6 +212,8 @@ function readConfig(): RuntimeConfig {
|
||||
sshClientToken: optionalEnv("UNIDESK_SSH_CLIENT_TOKEN")
|
||||
?? optionalFileEnv("UNIDESK_SSH_CLIENT_TOKEN_FILE", "/run/secrets/unidesk_ssh_client_token"),
|
||||
sshClientRouteAllowlist: parseRouteAllowlist(optionalEnv("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*"),
|
||||
wechatArchiveToken: optionalEnv("UNIDESK_WECHAT_ARCHIVE_TOKEN")
|
||||
?? optionalFileEnv("UNIDESK_WECHAT_ARCHIVE_TOKEN_FILE", "/tmp/unidesk-wechat-archive-token"),
|
||||
sessionSecret: requiredEnv("SESSION_SECRET"),
|
||||
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
|
||||
logFile: requiredEnv("LOG_FILE"),
|
||||
@@ -319,6 +322,7 @@ function trimPerformanceBuffers(): void {
|
||||
|
||||
function classifyRequestComponent(pathname: string): string {
|
||||
if (pathname === "/api/frontend-performance") return "webui_performance";
|
||||
if (pathname === "/webhooks/wechat-archive") return "wechat_archive_webhook";
|
||||
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";
|
||||
@@ -789,6 +793,143 @@ function sessionResponse(req: Request): Response {
|
||||
return jsonResponse({ ok: true, authenticated: true, user: { username: session.username }, expiresAt: new Date(session.expiresAt).toISOString() });
|
||||
}
|
||||
|
||||
async function readJsonRecord(req: Request, maxBytes: number): Promise<Record<string, unknown>> {
|
||||
const bytes = await req.arrayBuffer();
|
||||
if (bytes.byteLength > maxBytes) throw new Error(`request body exceeds ${maxBytes} bytes`);
|
||||
const text = Buffer.from(bytes).toString("utf8");
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordFrom(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function stringFrom(value: unknown): string {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
function wechatArchiveTokenFromRequest(req: Request): string | null {
|
||||
return bearerTokenFromRequest(req) ?? req.headers.get("x-unidesk-wechat-archive-token")?.trim() ?? null;
|
||||
}
|
||||
|
||||
function isValidArchiveRemotePath(path: string): boolean {
|
||||
return path.startsWith("/UniDesk/WeChatArchive/")
|
||||
&& !path.includes("\0")
|
||||
&& !path.split("/").includes("..")
|
||||
&& path.length <= 1024;
|
||||
}
|
||||
|
||||
function archiveContentPayload(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const message = recordFrom(body.message);
|
||||
const archive = recordFrom(body.archive);
|
||||
const media = recordFrom(message.media);
|
||||
const messageType = stringFrom(message.messageType || body.messageType || "text").toLowerCase();
|
||||
const remotePath = stringFrom(archive.remotePath);
|
||||
if (!isValidArchiveRemotePath(remotePath)) throw new Error("archive.remotePath is outside the WeChat archive root");
|
||||
const filename = stringFrom(archive.filename || remotePath.split("/").filter(Boolean).pop() || "wechat-archive.txt");
|
||||
if (messageType === "image") {
|
||||
const dataBase64 = stringFrom(media.dataBase64 || archive.dataBase64 || body.dataBase64);
|
||||
if (!dataBase64) throw new Error("image archive payload is missing media.dataBase64");
|
||||
return { dataBase64, filename, remotePath, maxBytes: 10 * 1024 * 1024 };
|
||||
}
|
||||
const text = stringFrom(message.text || body.text || body.message || body.content);
|
||||
const payloadHash = new Bun.CryptoHasher("sha256").update(JSON.stringify(body)).digest("hex");
|
||||
const content = `${text}\n\n---\nsource=unidesk-wechat-archive\npayloadSha256=${payloadHash}\n`;
|
||||
return { content, filename, remotePath, maxBytes: 10 * 1024 * 1024 };
|
||||
}
|
||||
|
||||
async function coreJson(path: string, init?: RequestInit): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(new URL(path, config.coreInternalUrl), {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = text;
|
||||
try {
|
||||
parsed = text.length > 0 ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
if (!response.ok) return { ok: false, status: response.status, body: parsed };
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
? { ok: true, status: response.status, ...(parsed as Record<string, unknown>) }
|
||||
: { ok: true, status: response.status, body: parsed };
|
||||
}
|
||||
|
||||
async function waitBaiduTransfer(jobId: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: Record<string, unknown> = { ok: false, error: "not-polled" };
|
||||
while (Date.now() < deadline) {
|
||||
last = await coreJson(`/api/microservices/baidu-netdisk/proxy/api/transfers/${encodeURIComponent(jobId)}`);
|
||||
const job = recordFrom(last.job);
|
||||
const status = stringFrom(job.status || last.status);
|
||||
if (status === "succeeded") return { ok: true, job };
|
||||
if (status === "failed" || status === "canceled") return { ok: false, job };
|
||||
await Bun.sleep(1000);
|
||||
}
|
||||
return { ok: false, error: "baidu-transfer-timeout", last };
|
||||
}
|
||||
|
||||
function baiduFsIdFromJob(job: Record<string, unknown>): string {
|
||||
const result = recordFrom(job.result);
|
||||
const baidu = recordFrom(result.baidu);
|
||||
return stringFrom(baidu.fs_id || baidu.fsId || job.fsId);
|
||||
}
|
||||
|
||||
async function wechatArchiveWebhook(req: Request): Promise<Response> {
|
||||
if (req.method !== "POST") return jsonResponse({ ok: false, error: "method not allowed" }, 405);
|
||||
const expected = config.wechatArchiveToken;
|
||||
const supplied = wechatArchiveTokenFromRequest(req);
|
||||
if (expected === null || supplied === null || !timingSafeStringEqual(supplied, expected)) {
|
||||
return jsonResponse({ ok: false, error: "authentication required", valuesPrinted: false }, 401);
|
||||
}
|
||||
const started = performance.now();
|
||||
try {
|
||||
const body = await readJsonRecord(req, 2 * 1024 * 1024);
|
||||
const uploadBody = archiveContentPayload(body);
|
||||
const created = await coreJson("/api/microservices/baidu-netdisk/proxy/api/transfers/upload-content", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(uploadBody),
|
||||
});
|
||||
if (created.ok !== true) {
|
||||
recordOperationPerformance("frontend", "wechat_archive_upload_create", performance.now() - started, false, stringFrom(created.status));
|
||||
return jsonResponse({ ok: false, error: "baidu upload create failed", upstream: created, valuesPrinted: false }, 502);
|
||||
}
|
||||
const job = recordFrom(created.job);
|
||||
const jobId = stringFrom(job.id);
|
||||
if (!jobId) throw new Error("baidu upload create did not return job id");
|
||||
const waited = await waitBaiduTransfer(jobId, 90_000);
|
||||
const waitedJob = recordFrom(waited.job);
|
||||
const fsId = baiduFsIdFromJob(waitedJob);
|
||||
const remotePath = stringFrom(uploadBody.remotePath);
|
||||
const ok = waited.ok === true && fsId.length > 0;
|
||||
recordOperationPerformance("frontend", "wechat_archive_upload", performance.now() - started, ok, `${jobId}:${remotePath}`);
|
||||
logger(ok ? "info" : "warn", "wechat_archive_upload_finished", { ok, jobId, remotePath, fsIdPresent: fsId.length > 0 });
|
||||
return jsonResponse({
|
||||
ok,
|
||||
response: ok ? `已归档到百度网盘:${remotePath}` : "归档失败",
|
||||
archive: {
|
||||
remotePath,
|
||||
fsId,
|
||||
local: recordFrom(created.local),
|
||||
uploadJob: waitedJob,
|
||||
listed: null,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
}, ok ? 200 : 502);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
recordOperationPerformance("frontend", "wechat_archive_upload", performance.now() - started, false, message);
|
||||
logger("warn", "wechat_archive_upload_failed", { error: message });
|
||||
return jsonResponse({ ok: false, error: message, valuesPrinted: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyApi(req: Request, url: URL): Promise<Response> {
|
||||
if (sessionFromRequest(req) === null) {
|
||||
return jsonResponse({ ok: false, error: "authentication required" }, 401);
|
||||
@@ -952,6 +1093,7 @@ async function handleRequest(req: Request, server: Server<FrontendWsData>): Prom
|
||||
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 === "/webhooks/wechat-archive") return wechatArchiveWebhook(req);
|
||||
if (url.pathname === "/ws/ssh") return proxySshWebSocket(req, server);
|
||||
if (url.pathname.startsWith("/api/") || url.pathname === "/logs") return proxyApi(req, url);
|
||||
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) return docsResponse(req, url);
|
||||
|
||||
Reference in New Issue
Block a user