feat: wire wechat archive through langbot and n8n

This commit is contained in:
Codex
2026-06-13 04:32:52 +00:00
parent e47eca77ab
commit c818d0cd44
8 changed files with 653 additions and 78 deletions
+142
View File
@@ -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);
@@ -1032,6 +1032,38 @@ async function createTransferJob(direction: TransferDirection, body: JsonRecord)
return { ok: true, job: transferFromRow(row) };
}
function safeStagingFilename(value: string, fallback: string): string {
const base = basename(value || fallback || "upload.txt").replace(/[^A-Za-z0-9._-]+/gu, "_").replace(/^_+|_+$/gu, "");
return base || fallback || "upload.txt";
}
function contentUploadBuffer(body: JsonRecord): { buffer: Buffer; source: string } {
if (typeof body.dataBase64 === "string" && body.dataBase64.length > 0) return { buffer: Buffer.from(body.dataBase64, "base64"), source: "dataBase64" };
if (typeof body.content === "string") return { buffer: Buffer.from(body.content, "utf8"), source: "content" };
throw new HttpError(400, "upload-content requires content or dataBase64");
}
async function createContentUpload(body: JsonRecord): Promise<JsonRecord> {
const { buffer, source } = contentUploadBuffer(body);
const maxBytes = Math.max(1, Math.min(10 * 1024 * 1024, asNumber(body.maxBytes, 10 * 1024 * 1024)));
if (buffer.byteLength > maxBytes) throw new HttpError(413, "upload-content body is too large", { maxBytes, bytes: buffer.byteLength });
const remotePath = remoteFilePath(String(body.remotePath || ""), safeStagingFilename(String(body.filename || ""), "upload.txt"));
const filename = safeStagingFilename(String(body.filename || pathPosix.basename(remotePath)), pathPosix.basename(remotePath) || "upload.txt");
const relativePath = pathPosix.join("content-uploads", `${Date.now()}-${randomUUID().slice(0, 8)}-${filename}`);
const localPath = resolveStagingPath(relativePath);
mkdirSync(dirname(localPath), { recursive: true });
await writeFile(localPath, buffer);
const sha256 = createHash("sha256").update(buffer).digest("hex");
const created = await createTransferJob("upload", { localPath: relativePath, remotePath });
return {
ok: created.ok === true,
source,
remotePath,
local: { relativePath, bytes: buffer.byteLength, sha256 },
job: asRecord(created.job),
};
}
async function computeMd5Blocks(filePath: string, jobId: string): Promise<{ size: number; fullMd5: string; blocks: string[] }> {
const info = await stat(filePath);
if (!info.isFile()) throw new HttpError(400, "localPath must be a file inside staging directory", { localPath: filePath });
@@ -1363,6 +1395,7 @@ async function route(req: Request): Promise<Response> {
if (path === "/api/folders" && req.method === "POST") return jsonResponse(await createFolder(await readJsonBody(req)));
if (path === "/api/files/manage" && req.method === "POST") return jsonResponse(await manageFiles(await readJsonBody(req)));
if (path === "/api/transfers/upload-from-path" && req.method === "POST") return jsonResponse(await createTransferJob("upload", await readJsonBody(req)));
if (path === "/api/transfers/upload-content" && req.method === "POST") return jsonResponse(await createContentUpload(await readJsonBody(req)));
if (path === "/api/transfers/download-to-path" && req.method === "POST") return jsonResponse(await createTransferJob("download", await readJsonBody(req)));
if (path === "/api/self-test" && req.method === "POST") return jsonResponse(await runSelfTest(await readJsonBody(req)));
if (path === "/api/transfers" && req.method === "GET") return jsonResponse(await listTransfers(url));