notes / scripts /backup-proxy.mjs
ghuser1's picture
Upload backup-proxy.mjs
40a984b verified
Raw
History Blame Contribute Delete
7.18 kB
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 = '<script src="/backup-injected.js" defer></script>';
return html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : `${html}${tag}`;
}
function injectedScript() {
return `
(() => {
const uploadIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 8 5-5 5 5"></path><path d="M5 21h14"></path></svg>';
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 + '<span>' + label + '</span>' : 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);
}