// 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 }); } }, ); }