Files
rhinelab-ui/scripts/archive-service.mjs

448 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本地归档服务:只监听 127.0.0.1,用每次启动生成的令牌限制网页访问。
//
// 职责:
// 1. 监视归档根目录,变动后自动重新生成快照(对应"有新文件自动更新")
// 2. 提供检索索引(gzip)
// 3. 提供读操作:打开文件、在资源管理器中定位
// 4. 提供安全写操作:新建分类目录、移动、复制、解压;一律先 plan 预览再 apply
// —— 不提供删除与重命名
//
// 所有写操作都校验解析后的绝对路径必须落在归档根目录内,并追加到操作日志。
import fs from "node:fs";
import fsp from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import zlib from "node:zlib";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { loadConfig, regenerate } from "./archive-source.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, "..");
const SEVEN_ZIP = "C:\\Program Files\\7-Zip\\7z.exe";
const config = await loadConfig();
const PORT = config.port ?? 43117;
const TOKEN = crypto.randomBytes(24).toString("hex");
const TOKEN_FILE = path.join(projectRoot, ".archive-token");
await fsp.writeFile(TOKEN_FILE, TOKEN, "utf8");
const state = {
watching: false,
lastScan: null,
lastError: null,
pendingRescan: null,
};
// ---------- 归档根目录围栏 ----------
// 客户端一律传相对路径(/ 分隔)。解析后必须仍在根目录内,且真实路径也不得越界。
function resolveInside(relPath) {
if (typeof relPath !== "string" || relPath.includes("\0"))
throw httpError(400, "路径不合法");
const cleaned = relPath.replace(/^[/\\]+/, "").replace(/\//g, path.sep);
if (!cleaned) throw httpError(400, "路径为空");
const abs = path.resolve(config.root, cleaned);
const rel = path.relative(config.root, abs);
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
throw httpError(403, "只允许访问归档目录内的路径");
return abs;
}
function realInside(abs) {
let real;
try {
real = fs.realpathSync(abs);
} catch {
return abs; // 还不存在(例如待新建目录);上一级已校验
}
const rel = path.relative(fs.realpathSync(config.root), real);
if (rel.startsWith("..") || path.isAbsolute(rel))
throw httpError(403, "解析后的真实路径越出归档目录");
return real;
}
function httpError(status, message) {
const error = new Error(message);
error.status = status;
return error;
}
// ---------- 统计与日志 ----------
function statsOf(abs) {
const out = { exists: false, isDir: false, bytes: 0, files: 0 };
let stat;
try {
stat = fs.lstatSync(abs);
} catch {
return out;
}
out.exists = true;
out.isDir = stat.isDirectory();
if (!out.isDir) {
out.bytes = stat.size;
out.files = 1;
return out;
}
const walk = (dir) => {
let items;
try {
items = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const item of items) {
const child = path.join(dir, item.name);
let s;
try {
s = fs.lstatSync(child);
} catch {
continue;
}
if (s.isDirectory()) walk(child);
else if (s.isFile()) {
out.files += 1;
out.bytes += s.size;
}
}
};
walk(abs);
return out;
}
async function writeLog(entry) {
const logRel = config.logFile ?? "00_索引\\操作日志.jsonl";
const logAbs = path.join(config.root, logRel);
await fsp.mkdir(path.dirname(logAbs), { recursive: true });
await fsp.appendFile(
logAbs,
`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
"utf8",
);
}
const sameVolume = (a, b) =>
path.parse(a).root.toLowerCase() === path.parse(b).root.toLowerCase();
// ---------- 操作计划(读操作也能生成计划) ----------
function buildPlan({ op, from, to, folder }) {
switch (op) {
case "mkdir": {
const abs = resolveInside(folder);
const exists = fs.existsSync(abs);
return {
op,
from: null,
to: path.relative(config.root, abs).replace(/\\/g, "/"),
targetExists: exists,
blocked: exists ? "目标目录已存在" : null,
note: exists ? null : "将新建一个分类目录",
bytes: 0,
files: 0,
};
}
case "move":
case "copy": {
const srcAbs = realInside(resolveInside(from));
const dstAbs = resolveInside(to);
const src = statsOf(srcAbs);
if (!src.exists) return { op, from, to, blocked: "源不存在", bytes: 0, files: 0 };
const targetExists = fs.existsSync(dstAbs);
const crossVolume = !sameVolume(srcAbs, dstAbs);
const blocked =
op === "move" && crossVolume
? "跨盘移动不被允许(请改用复制)"
: targetExists
? "目标已存在,请换一个名字"
: null;
return {
op,
from: path.relative(config.root, srcAbs).replace(/\\/g, "/"),
to: path.relative(config.root, dstAbs).replace(/\\/g, "/"),
bytes: src.bytes,
files: src.files,
isDir: src.isDir,
targetExists,
crossVolume,
blocked,
note: src.isDir
? `目录,含 ${src.files} 个文件`
: `文件,${src.bytes} 字节`,
};
}
case "extract": {
const srcAbs = realInside(resolveInside(from));
if (!fs.existsSync(srcAbs)) return { op, from, to, blocked: "压缩包不存在", bytes: 0, files: 0 };
const dstAbs = resolveInside(to);
const dst = statsOf(dstAbs);
const targetExists = dst.exists && dst.files > 0;
return {
op,
from: path.relative(config.root, srcAbs).replace(/\\/g, "/"),
to: path.relative(config.root, dstAbs).replace(/\\/g, "/"),
bytes: fs.statSync(srcAbs).size,
files: 0,
targetExists,
blocked: targetExists ? "目标目录已存在且非空" : null,
note: `解压到 ${path.relative(config.root, dstAbs).replace(/\\/g, "/") || "."}`,
};
}
default:
throw httpError(400, `不支持的操作:${op}`);
}
}
async function applyPlan({ op, from, to, folder }) {
const plan = buildPlan({ op, from, to, folder });
if (plan.blocked) throw httpError(409, plan.blocked);
if (op === "mkdir") {
const abs = resolveInside(folder);
await fsp.mkdir(abs, { recursive: true });
} else if (op === "move") {
const abs = resolveInside(to);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.rename(resolveInside(from), abs);
} else if (op === "copy") {
const abs = resolveInside(to);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.cp(resolveInside(from), abs, { recursive: true, errorOnExist: true });
} else if (op === "extract") {
if (!fs.existsSync(SEVEN_ZIP)) throw httpError(500, `找不到 7-Zip:${SEVEN_ZIP}`);
const abs = resolveInside(to);
await fsp.mkdir(abs, { recursive: true });
const code = await new Promise((resolve) => {
const child = spawn(
SEVEN_ZIP,
["x", resolveInside(from), `-o${abs}`, "-y", "-bso0", "-bsp0"],
{ stdio: "ignore", windowsHide: true },
);
child.on("close", resolve);
child.on("error", () => resolve(-1));
});
if (code !== 0) throw httpError(500, `7-Zip 解压失败(退出码 ${code})`);
}
await writeLog({ op, from: plan.from, to: plan.to, result: "ok" });
scheduleRescan();
return plan;
}
// ---------- 读操作:交给系统 ----------
function reveal(abs) {
spawn("explorer.exe", [`/select,${abs}`], { stdio: "ignore", windowsHide: false }).unref();
}
function openWith(abs) {
spawn("cmd.exe", ["/c", "start", "", abs], { stdio: "ignore", windowsHide: true }).unref();
}
// ---------- 快照刷新与自动更新 ----------
let timer = null;
function scheduleRescan(delay = 2500) {
clearTimeout(timer);
timer = setTimeout(async () => {
try {
const result = await regenerate({ quiet: true });
state.lastScan = result.generated;
state.lastError = null;
broadcast({ type: "rescan", generated: result.generated, totals: result.totals });
} catch (error) {
state.lastError = error.message;
}
}, delay);
}
const clients = new Set();
function broadcast(message) {
const line = `data: ${JSON.stringify(message)}\n\n`;
for (const res of clients) {
try {
res.write(line);
} catch {
clients.delete(res);
}
}
}
function startWatcher() {
try {
fs.watch(config.root, { recursive: true }, (_event, filename) => {
if (filename && String(filename).includes("操作日志.jsonl")) return;
scheduleRescan();
});
state.watching = true;
} catch (error) {
state.watching = false;
state.lastError = `无法监视归档目录:${error.message}`;
}
}
// ---------- HTTP ----------
const indexAbs = path.join(projectRoot, config.indexFile ?? "public/archive-index.json");
let indexCache = { mtime: 0, gzip: null, raw: null };
function readIndex() {
const mtime = fs.existsSync(indexAbs) ? fs.statSync(indexAbs).mtimeMs : 0;
if (indexCache.mtime !== mtime) {
const raw = mtime ? fs.readFileSync(indexAbs) : Buffer.from("{}");
indexCache = { mtime, raw, gzip: zlib.gzipSync(raw) };
}
return indexCache;
}
function searchIndex(query, limit) {
const { raw } = readIndex();
const payload = JSON.parse(raw.toString("utf8"));
const needle = query.trim().toLowerCase();
if (!needle) return { total: payload.entries.length, hits: [] };
const hits = [];
for (const entry of payload.entries) {
if (entry[0].toLowerCase().includes(needle)) {
hits.push(entry);
if (hits.length >= limit) break;
}
}
return { total: hits.length, hits };
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1_000_000) throw httpError(413, "请求体过大");
chunks.push(chunk);
}
if (!chunks.length) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw httpError(400, "请求体不是合法 JSON");
}
}
const send = (res, status, body, headers = {}) => {
const isBuffer = Buffer.isBuffer(body);
const payload = isBuffer ? body : Buffer.from(JSON.stringify(body));
res.writeHead(status, {
"content-type": isBuffer ? "application/octet-stream" : "application/json; charset=utf-8",
"cache-control": "no-store",
...headers,
});
res.end(payload);
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
const route = url.pathname;
// 事件流不需要令牌以外的额外处理,但同样要求令牌
if (req.headers["x-archive-token"] !== TOKEN) {
return send(res, 401, { error: "访问令牌无效" });
}
if (route === "/events") {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
});
res.write(": connected\n\n");
clients.add(res);
req.on("close", () => clients.delete(res));
return;
}
try {
if (route === "/status" && req.method === "GET") {
const { raw } = readIndex();
const payload = JSON.parse(raw.toString("utf8"));
return send(res, 200, {
root: config.root,
watching: state.watching,
lastScan: state.lastScan ?? payload.generated ?? null,
lastError: state.lastError,
totals: payload.totals ?? null,
port: PORT,
sevenZip: fs.existsSync(SEVEN_ZIP),
});
}
if (route === "/index" && req.method === "GET") {
const { raw, gzip } = readIndex();
if (String(req.headers["accept-encoding"] ?? "").includes("gzip"))
return send(res, 200, gzip, {
"content-type": "application/json; charset=utf-8",
"content-encoding": "gzip",
});
return send(res, 200, raw, { "content-type": "application/json; charset=utf-8" });
}
if (route === "/search" && req.method === "GET") {
const q = url.searchParams.get("q") ?? "";
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 2000);
return send(res, 200, searchIndex(q, limit));
}
if (route === "/plan" && req.method === "POST") {
const body = await readBody(req);
return send(res, 200, buildPlan(body));
}
if (route === "/apply" && req.method === "POST") {
const body = await readBody(req);
if (body.confirm !== true) throw httpError(400, "写操作需要 confirm: true");
const plan = await applyPlan(body);
return send(res, 200, { ok: true, plan });
}
if (route === "/reveal" && req.method === "POST") {
const body = await readBody(req);
const abs = realInside(resolveInside(body.path));
if (!fs.existsSync(abs)) throw httpError(404, "路径不存在");
reveal(abs);
return send(res, 200, { ok: true, action: "reveal", path: body.path });
}
if (route === "/open" && req.method === "POST") {
const body = await readBody(req);
const abs = realInside(resolveInside(body.path));
if (!fs.existsSync(abs)) throw httpError(404, "路径不存在");
openWith(abs);
return send(res, 200, { ok: true, action: "open", path: body.path });
}
if (route === "/refresh" && req.method === "POST") {
const result = await regenerate({ quiet: true });
state.lastScan = result.generated;
state.lastError = null;
broadcast({ type: "rescan", generated: result.generated, totals: result.totals });
return send(res, 200, { ok: true, generated: result.generated, totals: result.totals });
}
return send(res, 404, { error: `未知接口:${route}` });
} catch (error) {
return send(res, error.status ?? 500, { error: error.message });
}
});
server.listen(PORT, "127.0.0.1", async () => {
console.log(`归档服务已启动:http://127.0.0.1:${PORT}`);
console.log(`归档根目录:${config.root}`);
console.log(`访问令牌:已写入 ${path.relative(projectRoot, TOKEN_FILE)}`);
try {
const result = await regenerate({ quiet: true });
state.lastScan = result.generated;
console.log(
`快照就绪:${result.records.length} 条精选档案,` +
`${result.totals.files} 个文件 / ${result.totals.dirs} 个目录`,
);
} catch (error) {
state.lastError = error.message;
console.error(`首次生成快照失败:${error.message}`);
}
startWatcher();
console.log(state.watching ? "已开始监视归档变动(自动更新快照)" : "监视未启用");
});