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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user