import { spawn } from "node:child_process"; import { createServer } from "node:http"; const port = Number(process.env.PORT || 7860); const appOrigin = new URL(process.env.APP_ORIGIN || "http://127.0.0.1:4173"); let backupRunning = false; const server = createServer(async (req, res) => { try { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); if (url.pathname === "/backup-injected.js") { sendText(res, 200, injectedScript(), "text/javascript; charset=utf-8"); return; } if (url.pathname === "/api/backup/run") { await handleBackup(req, res); return; } await proxyRequest(req, res, url); } catch (error) { console.error("[backup-proxy]", error); sendJson(res, 500, { ok: false, error: "proxy_error" }); } }); server.listen(port, "0.0.0.0", () => { console.log(`Backup proxy running at http://0.0.0.0:${port}`); console.log(`Proxying app origin: ${appOrigin.origin}`); }); async function handleBackup(req, res) { if (req.method !== "POST") { sendJson(res, 405, { ok: false, error: "method_not_allowed" }); return; } if (!isSameOriginRequest(req)) { sendJson(res, 403, { ok: false, error: "origin_forbidden" }); return; } if (!await isAuthenticated(req)) { sendJson(res, 401, { ok: false, error: "unauthorized" }); return; } if (backupRunning) { sendJson(res, 409, { ok: false, error: "backup_running" }); return; } backupRunning = true; try { const result = await runBackupScript(); sendJson(res, 200, result); } catch (error) { console.error("[backup]", error); sendJson(res, 500, { ok: false, error: "backup_failed" }); } finally { backupRunning = false; } } async function isAuthenticated(req) { const response = await fetch(new URL("/api/auth/status", appOrigin), { headers: { cookie: req.headers.cookie || "" } }); if (!response.ok) return false; const status = await response.json().catch(() => ({})); return status.authenticated === true; } function isSameOriginRequest(req) { const origin = req.headers.origin; if (!origin) return true; const host = req.headers.host; if (!host) return false; try { return new URL(origin).host === host; } catch { return false; } } function runBackupScript() { return new Promise((resolve, reject) => { const child = spawn(process.execPath, ["scripts/backup-sqlite.mjs", "--backup"], { env: process.env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", reject); child.on("close", (code) => { if (code !== 0) { reject(new Error(`backup_script_failed_${code}: ${stderr.slice(0, 500)}`)); return; } resolve(parseLastJson(stdout)); }); }); } async function proxyRequest(req, res, url) { const target = new URL(`${url.pathname}${url.search}`, appOrigin); const headers = new Headers(req.headers); headers.delete("host"); headers.delete("content-length"); const init = { method: req.method, headers, redirect: "manual" }; if (!["GET", "HEAD"].includes(req.method || "GET")) { init.body = await readBody(req); } const response = await fetch(target, init); const responseHeaders = new Headers(response.headers); let body = Buffer.from(await response.arrayBuffer()); if (isHtmlResponse(responseHeaders)) { const html = body.toString("utf8"); body = Buffer.from(injectBackupScript(html)); responseHeaders.set("content-length", String(body.byteLength)); } res.writeHead(response.status, Object.fromEntries(responseHeaders.entries())); res.end(body); } function injectBackupScript(html) { if (html.includes("/backup-injected.js")) return html; const tag = ''; return html.includes("") ? html.replace("", `${tag}`) : `${html}${tag}`; } function injectedScript() { return ` (() => { const uploadIcon = ''; function makeButton(className, label) { const button = document.createElement('button'); button.type = 'button'; button.className = className; button.setAttribute('aria-label', '备份数据库'); button.title = '备份数据库'; button.innerHTML = label ? uploadIcon + '' + label + '' : uploadIcon; button.addEventListener('click', runBackup); return button; } async function runBackup(event) { const button = event.currentTarget; button.disabled = true; const originalOpacity = button.style.opacity; button.style.opacity = '0.45'; try { const response = await fetch('/api/backup/run', { method: 'POST' }); if (response.status === 401) { alert('请先登录后再备份'); return; } if (response.status === 409) { alert('备份正在进行中'); return; } if (!response.ok) throw new Error('backup_failed'); alert('备份完成'); } catch { alert('备份失败,请检查 rclone 配置'); } finally { button.disabled = false; button.style.opacity = originalOpacity; } } function mount() { if (!document.querySelector('[data-backup-button="desktop"]')) { const toolbar = document.querySelector('.toolbar'); if (toolbar) { const button = makeButton('icon-button', ''); button.dataset.backupButton = 'desktop'; toolbar.append(button); } } if (!document.querySelector('[data-backup-button="mobile"]')) { const menu = document.querySelector('#mobileActionMenu'); if (menu) { const button = makeButton('', '备份'); button.dataset.backupButton = 'mobile'; menu.append(button); } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount, { once: true }); } else { mount(); } })(); `; } function isHtmlResponse(headers) { return (headers.get("content-type") || "").includes("text/html"); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } function parseLastJson(output) { const lines = output.trim().split(/\r?\n/).filter(Boolean); const last = lines.at(-1); if (!last) return { ok: true }; try { return JSON.parse(last); } catch { return { ok: true }; } } function sendJson(res, status, payload) { sendText(res, status, JSON.stringify(payload), "application/json; charset=utf-8"); } function sendText(res, status, body, contentType) { res.writeHead(status, { "content-type": contentType, "cache-control": "no-store", "content-length": Buffer.byteLength(body) }); res.end(body); }