d0e98424c1
- Arbitrary file write (P0): validate import keys in /api/library/import via a now-exported safeFilename(); a ../../ key is skipped, not written out of the scope dir. - SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects. - ESM require (P1): top-level import of randomBytes in license-purchase.js (the inner require threw on the anon purchase-settle path). - Concurrency lock (P1): skip the process-global free-tier slot in multi-mode so it no longer serializes every cloud tenant onto one job. - X-Forwarded-For bypass (P1): set Express trust proxy from RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead of a client-spoofable XFF entry. Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass). Registry blockers deferred (ROADMAP); leaked-key history purge queued.
221 lines
7.6 KiB
JavaScript
221 lines
7.6 KiB
JavaScript
// Library export/import — bulk JSON dump + restore of sessions, folder
|
|
// meta, and subscriptions. Only routes; no module state. Pulls history
|
|
// data from history.js helpers; reads/writes the subscriptions file
|
|
// directly because library import predates the subscriptions module
|
|
// having a public 'merge' helper (and the merge logic is library-
|
|
// specific anyway).
|
|
//
|
|
// As of 0.2.77 (multi-tenant): all reads/writes are scoped to the
|
|
// requesting user via scopeForRequest(req). In single mode the scope
|
|
// is always "owner" — preserved single-user behavior. In multi mode
|
|
// each tenant exports/imports their own library; the operator's
|
|
// admin status doesn't grant cross-user export (a separate operator-
|
|
// only "export everyone's library" endpoint can be added later if
|
|
// the operator ever needs it).
|
|
//
|
|
// Subscriptions remain global (one /data/history/subscriptions.json
|
|
// per install) for now. Per-user subscriptions are a Phase 1D/2 task.
|
|
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import express from "express";
|
|
import {
|
|
getHistoryDir,
|
|
getScopeHistoryDir,
|
|
loadMeta,
|
|
saveMeta,
|
|
scopeForRequest,
|
|
safeFilename,
|
|
ROOT_SIDECARS,
|
|
} from "./history.js";
|
|
|
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
// Both routes are gated by the Pro 'library' entitlement (the gate runs
|
|
// upstream in license-middleware.js). They assume initHistory() has
|
|
// already been called.
|
|
export function setupLibraryRoutes(app) {
|
|
function requireScope(req, res) {
|
|
try {
|
|
return scopeForRequest(req);
|
|
} catch {
|
|
res.status(401).json({ error: "auth_required" });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Bulk export everything: meta, sessions, subscriptions.
|
|
app.get("/api/library/export", async (req, res) => {
|
|
const scope = requireScope(req, res);
|
|
if (!scope) return;
|
|
try {
|
|
const scopeDir = getScopeHistoryDir(scope);
|
|
const meta = await loadMeta(scope);
|
|
let files = [];
|
|
try {
|
|
files = await fs.readdir(scopeDir);
|
|
} catch {
|
|
files = [];
|
|
}
|
|
const sessions = {};
|
|
for (const file of files) {
|
|
// Skip non-sessions: meta + the subscription sidecar files that now
|
|
// live inside the scope dir (else they'd export as phantom sessions).
|
|
if (!file.endsWith(".json") || ROOT_SIDECARS.has(file)) continue;
|
|
try {
|
|
const raw = await fs.readFile(path.join(scopeDir, file), "utf-8");
|
|
const id = file.replace(".json", "");
|
|
sessions[id] = JSON.parse(raw);
|
|
} catch {}
|
|
}
|
|
// Subscriptions live at the install-wide history root and are
|
|
// operator-owned (single global store). Only the operator exports
|
|
// them — in single mode (the operator owns the box) or, in multi
|
|
// mode, the admin. A non-admin tenant's export must NOT leak the
|
|
// operator's subscription list. Future per-user subscriptions move
|
|
// this into the scope dir.
|
|
let subscriptions = [];
|
|
const ownsSubscriptions =
|
|
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
|
if (ownsSubscriptions) {
|
|
try {
|
|
subscriptions =
|
|
JSON.parse(
|
|
await fs.readFile(
|
|
path.join(getHistoryDir(), "subscriptions.json"),
|
|
"utf-8",
|
|
),
|
|
).subscriptions || [];
|
|
} catch {}
|
|
}
|
|
|
|
const exportData = {
|
|
version: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
meta,
|
|
sessions,
|
|
subscriptions,
|
|
};
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
'attachment; filename="recap-library.json"',
|
|
);
|
|
res.json(exportData);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Bulk import: skip sessions that already exist (don't overwrite),
|
|
// merge meta folders, add net-new subscriptions.
|
|
app.post(
|
|
"/api/library/import",
|
|
express.json({ limit: "200mb" }),
|
|
async (req, res) => {
|
|
const scope = requireScope(req, res);
|
|
if (!scope) return;
|
|
try {
|
|
const data = req.body;
|
|
if (!data || !data.sessions) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Invalid library file — missing sessions data" });
|
|
}
|
|
|
|
const scopeDir = getScopeHistoryDir(scope);
|
|
await fs.mkdir(scopeDir, { recursive: true }).catch(() => {});
|
|
|
|
let imported = 0;
|
|
let skipped = 0;
|
|
|
|
// Sessions — skip if already present.
|
|
for (const [id, session] of Object.entries(data.sessions)) {
|
|
// The import file is fully attacker-controlled; validate the key
|
|
// before using it as a filename. A "../../" id would otherwise
|
|
// escape the scope dir and write anywhere the process can reach.
|
|
let safeId;
|
|
try {
|
|
safeId = safeFilename(id);
|
|
} catch {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const filePath = path.join(scopeDir, `${safeId}.json`);
|
|
try {
|
|
await fs.access(filePath);
|
|
skipped++;
|
|
continue;
|
|
} catch {}
|
|
await fs.writeFile(filePath, JSON.stringify(session));
|
|
imported++;
|
|
}
|
|
|
|
// Meta — merge folders + add new uncategorized at the top.
|
|
if (data.meta) {
|
|
const existingMeta = await loadMeta(scope);
|
|
const allExistingIds = new Set([
|
|
...existingMeta.uncategorized,
|
|
...existingMeta.folders.flatMap((f) => f.items),
|
|
]);
|
|
|
|
if (data.meta.folders) {
|
|
for (const folder of data.meta.folders) {
|
|
const existingFolder = existingMeta.folders.find(
|
|
(f) => f.id === folder.id,
|
|
);
|
|
if (!existingFolder) {
|
|
existingMeta.folders.push(folder);
|
|
folder.items.forEach((id) => allExistingIds.add(id));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.meta.uncategorized) {
|
|
for (const id of data.meta.uncategorized) {
|
|
if (!allExistingIds.has(id)) {
|
|
existingMeta.uncategorized.unshift(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
await saveMeta(scope, existingMeta);
|
|
}
|
|
|
|
// Subscriptions — install-wide + operator-owned. Only the operator
|
|
// (single mode, or multi-mode admin) may import them; otherwise a
|
|
// tenant's import would inject into the operator's global list.
|
|
const ownsSubscriptions =
|
|
req.recapMode !== "multi" || !!(req.user && req.user.is_admin);
|
|
if (ownsSubscriptions && data.subscriptions && data.subscriptions.length > 0) {
|
|
const subsPath = path.join(getHistoryDir(), "subscriptions.json");
|
|
let existingSubs = [];
|
|
try {
|
|
existingSubs =
|
|
JSON.parse(await fs.readFile(subsPath, "utf-8"))
|
|
.subscriptions || [];
|
|
} catch {}
|
|
const existingUrls = new Set(existingSubs.map((s) => s.url));
|
|
for (const sub of data.subscriptions) {
|
|
if (!existingUrls.has(sub.url)) {
|
|
existingSubs.push(sub);
|
|
}
|
|
}
|
|
await fs.writeFile(
|
|
subsPath,
|
|
JSON.stringify({ subscriptions: existingSubs }),
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
ok: true,
|
|
imported,
|
|
skipped,
|
|
total: Object.keys(data.sessions).length,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
},
|
|
);
|
|
}
|