Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
This commit is contained in:
+474
-88
@@ -1,35 +1,138 @@
|
||||
// History storage + routes. Sessions are written as one JSON file per
|
||||
// summary in /data/history/<id>.json. Folder structure / ordering lives
|
||||
// in a sidecar `_meta.json`.
|
||||
// History storage + routes. Per-user-scoped under /data/history/<scope>/.
|
||||
//
|
||||
// Module-private state: just the historyDir path, set by initHistory().
|
||||
// The DELETE route needs to add the deleted videoId to the skip list
|
||||
// (so subscriptions don't re-queue it) — that's a cross-module concern,
|
||||
// so it's injected as a callback by setupHistoryRoutes.
|
||||
// "Scope" is:
|
||||
// - single mode: "owner" (always)
|
||||
// - multi mode signed-in user: "<user_id>"
|
||||
// - multi mode anonymous trial: "anon/<trial_cookie_id>"
|
||||
//
|
||||
// Each scope has its own folder with one *.json file per summary and a
|
||||
// `_meta.json` for the folder/ordering UI. Scope isolation is enforced
|
||||
// here at the path level — handlers in this file refuse to read across
|
||||
// scopes, period. The auth middleware populates req.userId; we derive
|
||||
// the scope via scopeForRequest(req) and never trust raw URL input.
|
||||
//
|
||||
// On a brand-new install nothing exists. Single-mode installs created
|
||||
// before 0.2.77 wrote files flat to /data/history/*.json; the migration
|
||||
// hook (see migrateLegacyLibrary below) moves those into the "owner"
|
||||
// scope on first multi-mode boot.
|
||||
//
|
||||
// Module-private state: historyDir (the root path). All per-scope
|
||||
// paths are derived per-call so adding a new user doesn't need a
|
||||
// re-init.
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
let historyDir = null;
|
||||
let metaPath = null;
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────────────────
|
||||
// Call once at boot. Creates the directory and stores the path. Idempotent.
|
||||
export async function initHistory({ dataDir }) {
|
||||
// Call once at boot. Creates the root directory and stores the path.
|
||||
// In single mode also ensures /data/history/owner exists so the
|
||||
// owner-scope writes don't race on first-summary mkdir.
|
||||
export async function initHistory({ dataDir, mode = "single" }) {
|
||||
historyDir = path.join(dataDir, "history");
|
||||
metaPath = path.join(historyDir, "_meta.json");
|
||||
await fs.mkdir(historyDir, { recursive: true }).catch(() => {});
|
||||
if (mode === "single") {
|
||||
await fs.mkdir(ownerScopeDir(), { recursive: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scope helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// Files that live at the root of /data/history/ (not inside a per-user
|
||||
// scope) — subscription state, skip lists, etc. Filtered out when
|
||||
// listing sessions so they don't appear as phantom library items.
|
||||
// Files that live inside a scope dir but are NOT session records:
|
||||
// the folder/order meta + the per-scope subscription state (moved here by
|
||||
// the 0.2.147 migration). They must be filtered out of every place that
|
||||
// lists `.json` files as sessions, or they show up as phantom "Invalid
|
||||
// Date · undefined topics" library entries.
|
||||
export const ROOT_SIDECARS = new Set([
|
||||
"_meta.json",
|
||||
"subscriptions.json",
|
||||
"skip-list.json",
|
||||
"seen-list.json",
|
||||
"auto-queue.json",
|
||||
]);
|
||||
|
||||
// Sanitize a user-supplied scope component so it can't escape the
|
||||
// history root via path traversal. Allows the alphabet that user_ids
|
||||
// and trial cookie_ids use (base64url + hex chars + the literal "anon"
|
||||
// and "owner" prefixes). Anything else → throw.
|
||||
function safeComponent(s) {
|
||||
if (typeof s !== "string" || !s) throw new Error("invalid_scope_component");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(s)) throw new Error("invalid_scope_component");
|
||||
return s;
|
||||
}
|
||||
|
||||
// scopeForRequest(req) — single string identifying the writer/reader
|
||||
// of a library. Used as a subpath under /data/history/. Throws if the
|
||||
// request has no usable identity (caller should 401 in that case).
|
||||
//
|
||||
// Returned strings:
|
||||
// "owner" — single mode, OR the multi-mode admin (so
|
||||
// a multi→single mode flip preserves the
|
||||
// operator's library at the same path)
|
||||
// "<user_id>" — multi mode non-admin signed-in user
|
||||
// "anon/<cookie_id>" — multi mode anonymous-trial cookie
|
||||
//
|
||||
// Why admin → "owner": before v0.2.91 we renamed /data/history/owner/
|
||||
// → /data/history/<admin_user_id>/ on first multi-mode signup, which
|
||||
// made switching back to single mode hide the operator's library
|
||||
// (single mode reads "owner"). Keeping admin's scope at "owner"
|
||||
// regardless of mode makes mode-switching lossless.
|
||||
export function scopeForRequest(req) {
|
||||
if (req.recapMode !== "multi") return "owner";
|
||||
if (req.user && req.user.is_admin) return "owner";
|
||||
if (req.user && req.user.id) return safeComponent(req.user.id);
|
||||
if (typeof req.userId === "string" && req.userId.startsWith("anon:")) {
|
||||
return `anon/${safeComponent(req.userId.slice(5))}`;
|
||||
}
|
||||
if (req.userId === "owner") return "owner"; // pre-multi-mode legacy shim
|
||||
throw new Error("no_scope");
|
||||
}
|
||||
|
||||
function scopeDir(scope) {
|
||||
// `scope` may contain a slash for the "anon/<id>" case — split into
|
||||
// segments so path.join doesn't treat it as one component (and so
|
||||
// safeComponent enforcement covers each piece).
|
||||
const parts = scope.split("/").map(safeComponent);
|
||||
return path.join(historyDir, ...parts);
|
||||
}
|
||||
|
||||
function ownerScopeDir() {
|
||||
return path.join(historyDir, "owner");
|
||||
}
|
||||
|
||||
function metaPathFor(scope) {
|
||||
return path.join(scopeDir(scope), "_meta.json");
|
||||
}
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────────────────
|
||||
// saveToHistory persists a completed summary. Returns the generated id.
|
||||
// Used by /api/process. The id encodes the timestamp + a content hint
|
||||
// (videoId for YouTube, base64-truncated guid/url for podcasts) so files
|
||||
// sort chronologically by name.
|
||||
export async function saveToHistory(videoId, url, title, chunks, entries, logs, uploadDate, type) {
|
||||
const idSuffix = type === "podcast"
|
||||
? Buffer.from(videoId).toString("base64url").slice(0, 16)
|
||||
: videoId;
|
||||
// Caller (the /api/process handler) is responsible for passing the
|
||||
// right scope — derived via scopeForRequest(req) up the call stack.
|
||||
//
|
||||
// The id encodes the timestamp + a content hint (videoId for YouTube,
|
||||
// base64-truncated guid/url for podcasts) so files sort chronologically
|
||||
// by name.
|
||||
export async function saveToHistory(
|
||||
scope,
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
chunks,
|
||||
entries,
|
||||
logs,
|
||||
uploadDate,
|
||||
type,
|
||||
speakers = null,
|
||||
speakerNames = null,
|
||||
) {
|
||||
const idSuffix =
|
||||
type === "podcast"
|
||||
? Buffer.from(videoId).toString("base64url").slice(0, 16)
|
||||
: videoId;
|
||||
const id = `${Date.now()}-${idSuffix}`;
|
||||
const record = {
|
||||
id,
|
||||
@@ -44,101 +147,352 @@ export async function saveToHistory(videoId, url, title, chunks, entries, logs,
|
||||
chunks,
|
||||
entries,
|
||||
logs,
|
||||
// Phase 1E — speaker legend summary keyed by global speaker ID
|
||||
// (Speaker_A, Speaker_B, ...). Each chunk's entries also carry
|
||||
// `.speaker` and `.speaker_confidence` fields inline. Null when
|
||||
// diarization wasn't available (older relay, off, or no
|
||||
// fingerprints collected). Persisting at the record level lets
|
||||
// the library card show "2 speakers" without scanning entries.
|
||||
speakers: speakers || null,
|
||||
// Phase 2 — inferred speaker names from the relay's post-cluster
|
||||
// polish pass. Map { Speaker_A: "Matt Hill", ... } with null
|
||||
// values for unidentified speakers. Reopening a saved session
|
||||
// restores names alongside the cluster IDs.
|
||||
speakerNames: speakerNames || null,
|
||||
};
|
||||
await fs.writeFile(path.join(historyDir, `${id}.json`), JSON.stringify(record));
|
||||
const dir = scopeDir(scope);
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(record));
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Meta ────────────────────────────────────────────────────────────────────
|
||||
// `_meta.json` shape: { folders: [{ id, name, order, collapsed,
|
||||
// items: [sessionId, ...] }], uncategorized: [sessionId, ...] }
|
||||
export async function loadMeta() {
|
||||
// Each scope has its own `_meta.json` for folder/ordering UI state.
|
||||
// New scope = empty meta on read (no file yet).
|
||||
export async function loadMeta(scope) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(metaPath, "utf-8"));
|
||||
return JSON.parse(await fs.readFile(metaPathFor(scope), "utf-8"));
|
||||
} catch {
|
||||
return { folders: [], uncategorized: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMeta(meta) {
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
||||
export async function saveMeta(scope, meta) {
|
||||
const dir = scopeDir(scope);
|
||||
await fs.mkdir(dir, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(metaPathFor(scope), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
// getHistoryDir() — root /data/history/. Some callers (subscriptions,
|
||||
// skip-list, etc.) write sidecar files here that aren't scoped.
|
||||
export function getHistoryDir() {
|
||||
return historyDir;
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// Pass `addToSkipList` so the DELETE route can suppress re-queueing of
|
||||
// videos the user has explicitly removed. Decoupled from subscriptions
|
||||
// to keep this module standalone.
|
||||
export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
// Get all history: sessions + folder structure
|
||||
app.get("/api/history", async (req, res) => {
|
||||
// getScopeHistoryDir(scope) — the per-scope directory. Used by handlers
|
||||
// that need raw filesystem access (e.g. delete).
|
||||
export function getScopeHistoryDir(scope) {
|
||||
return scopeDir(scope);
|
||||
}
|
||||
|
||||
// ── Audio-first ("walking mode") TTS cache helpers ──────────────────────────
|
||||
// Per-topic synthesized summary clips live alongside the session JSON in
|
||||
// a sibling folder: /data/history/<scope>/<id>-audio/topic-<i>.mp3. Same
|
||||
// scope-isolation guarantees as the session record (safeFilename guards
|
||||
// the id; scopeDir guards the scope).
|
||||
|
||||
// Directory holding a session's cached summary-audio clips.
|
||||
export function sessionAudioDir(scope, id) {
|
||||
return path.join(scopeDir(scope), `${safeFilename(id)}-audio`);
|
||||
}
|
||||
|
||||
// Load a full session record by id within a scope. Returns null if it
|
||||
// doesn't exist (or can't be parsed) — callers 404 on null.
|
||||
export async function loadSession(scope, id) {
|
||||
try {
|
||||
const raw = await fs.readFile(
|
||||
path.join(scopeDir(scope), `${safeFilename(id)}.json`),
|
||||
"utf-8",
|
||||
);
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
|
||||
// `summaryAudio` availability). No-op-safe: returns null if the record
|
||||
// is missing rather than throwing.
|
||||
export async function patchSession(scope, id, patch) {
|
||||
const file = path.join(scopeDir(scope), `${safeFilename(id)}.json`);
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(await fs.readFile(file, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const merged = { ...rec, ...patch };
|
||||
await fs.writeFile(file, JSON.stringify(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Legacy library migration (single → multi) ───────────────────────────────
|
||||
// Pre-0.2.77 single-mode installs wrote summaries flat to
|
||||
// /data/history/*.json with a single _meta.json. On first boot in
|
||||
// multi mode we move all of that into /data/history/owner/ so the
|
||||
// operator's library is accessible under the "owner" scope. After the
|
||||
// first real user signs up (is_admin=1), auth-routes.js renames that
|
||||
// folder to the user's actual id so they own their original library.
|
||||
//
|
||||
// Idempotent — writes a sentinel after the first migration. Safe to
|
||||
// call on every boot; no-op if there's nothing flat to move.
|
||||
export async function migrateLegacyLibrary() {
|
||||
const sentinel = path.join(historyDir, ".migrated_to_multi");
|
||||
try {
|
||||
await fs.access(sentinel);
|
||||
return { migrated: 0, skipped: "already_migrated" };
|
||||
} catch {}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(historyDir);
|
||||
} catch {
|
||||
return { migrated: 0, skipped: "no_history_dir" };
|
||||
}
|
||||
|
||||
const flatFiles = entries.filter(
|
||||
(f) => f.endsWith(".json") && !ROOT_SIDECARS.has(f),
|
||||
);
|
||||
if (flatFiles.length === 0 && !entries.includes("_meta.json")) {
|
||||
// Truly empty. Write the sentinel so future boots don't keep
|
||||
// checking, but flag this as a non-migration.
|
||||
await fs.writeFile(sentinel, new Date().toISOString());
|
||||
return { migrated: 0, skipped: "empty_legacy_library" };
|
||||
}
|
||||
|
||||
const target = ownerScopeDir();
|
||||
await fs.mkdir(target, { recursive: true }).catch(() => {});
|
||||
|
||||
let moved = 0;
|
||||
for (const f of flatFiles) {
|
||||
try {
|
||||
const files = await fs.readdir(historyDir);
|
||||
await fs.rename(
|
||||
path.join(historyDir, f),
|
||||
path.join(target, f),
|
||||
);
|
||||
moved += 1;
|
||||
} catch (err) {
|
||||
console.warn(`[history] failed to migrate ${f}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Move _meta.json too if it exists at the root. Folder/ordering state
|
||||
// belongs to the same library.
|
||||
try {
|
||||
await fs.rename(
|
||||
path.join(historyDir, "_meta.json"),
|
||||
path.join(target, "_meta.json"),
|
||||
);
|
||||
} catch {} // no _meta.json is fine
|
||||
|
||||
await fs.writeFile(sentinel, new Date().toISOString());
|
||||
console.log(
|
||||
`[history] migrated ${moved} legacy session(s) to /data/history/owner/`,
|
||||
);
|
||||
return { migrated: moved };
|
||||
}
|
||||
|
||||
// reclaimAdminLibraryToOwner({ db }) — one-time fixup for installs
|
||||
// upgraded from <0.2.91 where the first admin's library got renamed
|
||||
// from /data/history/owner/ → /data/history/<admin_user_id>/ on their
|
||||
// signup. With the new admin-scope-is-always-owner rule (above), we
|
||||
// need that library back under "owner" so multi-mode admin reads it
|
||||
// AND a future single-mode flip can still find it. Idempotent — runs
|
||||
// the rename only if BOTH (a) an admin user exists in SQLite AND
|
||||
// (b) /data/history/<admin_user_id>/ exists AND (c) /data/history/owner/
|
||||
// does NOT already exist. Otherwise no-op.
|
||||
//
|
||||
// Pass in the better-sqlite3 db handle from db.js — we don't import
|
||||
// here to avoid a dep cycle (db.js is multi-mode only, history.js is
|
||||
// loaded in single mode too).
|
||||
export async function reclaimAdminLibraryToOwner({ db }) {
|
||||
if (!db) return { reclaimed: false, reason: "no_db" };
|
||||
let admin;
|
||||
try {
|
||||
admin = db
|
||||
.prepare(
|
||||
"SELECT id, email FROM users WHERE is_admin = 1 ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.get();
|
||||
} catch {
|
||||
return { reclaimed: false, reason: "no_users_table" };
|
||||
}
|
||||
if (!admin) return { reclaimed: false, reason: "no_admin" };
|
||||
|
||||
const ownerDir = path.join(historyDir, "owner");
|
||||
const adminDir = path.join(historyDir, safeComponent(admin.id));
|
||||
try {
|
||||
await fs.access(ownerDir);
|
||||
// /data/history/owner/ already exists → either a fresh install or
|
||||
// the fixup already ran. Either way, do nothing.
|
||||
return { reclaimed: false, reason: "owner_already_exists" };
|
||||
} catch {}
|
||||
try {
|
||||
await fs.access(adminDir);
|
||||
} catch {
|
||||
return { reclaimed: false, reason: "admin_dir_missing" };
|
||||
}
|
||||
await fs.rename(adminDir, ownerDir);
|
||||
console.log(
|
||||
`[history] reclaimed admin library: /data/history/${admin.id}/ → /data/history/owner/`,
|
||||
);
|
||||
return { reclaimed: true, admin_id: admin.id, email: admin.email };
|
||||
}
|
||||
|
||||
// renameScopeDir(fromScope, toScope) — atomic rename of a per-scope
|
||||
// folder. Used when:
|
||||
// - the first multi-mode signup claims the "owner" legacy library
|
||||
// (auth-routes.js calls this with fromScope="owner", toScope=user.id)
|
||||
// - an anonymous trial converts to a real user (auth-routes.js,
|
||||
// fromScope="anon/<cookie_id>", toScope=user.id)
|
||||
//
|
||||
// If `fromScope` doesn't exist, no-op (returns false). If `toScope`
|
||||
// already exists, we don't clobber — the caller has to merge manually
|
||||
// (which currently only matters in edge cases, since fresh user ids
|
||||
// are uuids that won't collide). Returns true on actual rename.
|
||||
export async function renameScopeDir(fromScope, toScope) {
|
||||
const from = scopeDir(fromScope);
|
||||
const to = scopeDir(toScope);
|
||||
try {
|
||||
await fs.access(from);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.access(to);
|
||||
console.warn(
|
||||
`[history] renameScopeDir: ${toScope} already exists; refusing to clobber. Leaving ${fromScope} in place for manual reconciliation.`,
|
||||
);
|
||||
return false;
|
||||
} catch {}
|
||||
// Ensure parent of `to` exists (for the "anon/<id>" case the parent
|
||||
// is /data/history/anon/, which won't be there in fresh installs).
|
||||
await fs.mkdir(path.dirname(to), { recursive: true }).catch(() => {});
|
||||
await fs.rename(from, to);
|
||||
console.log(`[history] renamed scope ${fromScope} → ${toScope}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Routes ──────────────────────────────────────────────────────────────────
|
||||
// All routes are scoped to req — they read scopeForRequest(req) and
|
||||
// refuse to operate outside that scope. No request body or URL param
|
||||
// can reference another user's library.
|
||||
//
|
||||
// `addToSkipList(scope, videoId)` is injected so the DELETE route can
|
||||
// suppress re-queueing of a subscription video the user explicitly removed.
|
||||
// It's scope-keyed (./subscriptions.js): the skip applies to the same
|
||||
// scope's subscription store.
|
||||
export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
function requireScope(req, res) {
|
||||
try {
|
||||
return scopeForRequest(req);
|
||||
} catch {
|
||||
res.status(401).json({ error: "auth_required" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all history: sessions + folder structure for THIS user.
|
||||
app.get("/api/history", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const dir = scopeDir(scope);
|
||||
let files = [];
|
||||
try {
|
||||
files = await fs.readdir(dir);
|
||||
} catch {
|
||||
files = []; // no library yet — render an empty state
|
||||
}
|
||||
const sessionsMap = {};
|
||||
// Skip the meta + state files; everything else is a session.
|
||||
for (const file of files.filter(f =>
|
||||
f.endsWith(".json") &&
|
||||
!f.startsWith("_") &&
|
||||
f !== "subscriptions.json" &&
|
||||
f !== "skip-list.json" &&
|
||||
f !== "seen-list.json" &&
|
||||
f !== "auto-queue.json"
|
||||
for (const file of files.filter(
|
||||
(f) =>
|
||||
f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f),
|
||||
)) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, file), "utf-8");
|
||||
const raw = await fs.readFile(path.join(dir, file), "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
sessionsMap[data.id] = {
|
||||
id: data.id, videoId: data.videoId, url: data.url,
|
||||
title: data.title, topicCount: data.topicCount,
|
||||
id: data.id,
|
||||
videoId: data.videoId,
|
||||
url: data.url,
|
||||
title: data.title,
|
||||
topicCount: data.topicCount,
|
||||
type: data.type || "youtube",
|
||||
segmentCount: data.segmentCount, createdAt: data.createdAt,
|
||||
segmentCount: data.segmentCount,
|
||||
createdAt: data.createdAt,
|
||||
uploadDate: data.uploadDate || "",
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const meta = await loadMeta();
|
||||
const meta = await loadMeta(scope);
|
||||
|
||||
// Clean up: remove references to deleted sessions
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => sessionsMap[id]);
|
||||
folder.items = folder.items.filter((id) => sessionsMap[id]);
|
||||
}
|
||||
meta.uncategorized = meta.uncategorized.filter(id => sessionsMap[id]);
|
||||
meta.uncategorized = meta.uncategorized.filter((id) => sessionsMap[id]);
|
||||
|
||||
// Add any sessions not in meta (newly created)
|
||||
const allReferenced = new Set([
|
||||
...meta.uncategorized,
|
||||
...meta.folders.flatMap(f => f.items),
|
||||
...meta.folders.flatMap((f) => f.items),
|
||||
]);
|
||||
const allIds = Object.keys(sessionsMap);
|
||||
const orphans = allIds.filter(id => !allReferenced.has(id))
|
||||
.sort((a, b) => new Date(sessionsMap[b].createdAt) - new Date(sessionsMap[a].createdAt));
|
||||
const orphans = allIds
|
||||
.filter((id) => !allReferenced.has(id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(sessionsMap[b].createdAt) -
|
||||
new Date(sessionsMap[a].createdAt),
|
||||
);
|
||||
meta.uncategorized = [...orphans, ...meta.uncategorized];
|
||||
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ sessions: sessionsMap, meta });
|
||||
} catch (err) {
|
||||
res.json({ sessions: {}, meta: { folders: [], uncategorized: [] } });
|
||||
res.json({
|
||||
sessions: {},
|
||||
meta: { folders: [], uncategorized: [] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single session (full data)
|
||||
// Get a single session (full data) — scoped to current user.
|
||||
app.get("/api/history/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(historyDir, `${req.params.id}.json`), "utf-8");
|
||||
const raw = await fs.readFile(
|
||||
path.join(scopeDir(scope), `${safeFilename(req.params.id)}.json`),
|
||||
"utf-8",
|
||||
);
|
||||
res.json(JSON.parse(raw));
|
||||
} catch {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a session title
|
||||
// Rename a session title — scoped.
|
||||
app.put("/api/history/:id/title", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const filePath = path.join(historyDir, `${req.params.id}.json`);
|
||||
const filePath = path.join(
|
||||
scopeDir(scope),
|
||||
`${safeFilename(req.params.id)}.json`,
|
||||
);
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
data.title = req.body.title || data.title;
|
||||
@@ -149,11 +503,16 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a session — also adds the videoId to the skip list so any
|
||||
// subscriptions don't re-queue it.
|
||||
// Delete a session — scoped. Also adds the videoId to the (global)
|
||||
// skip list so subscriptions don't re-queue it.
|
||||
app.delete("/api/history/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const filePath = path.join(historyDir, `${req.params.id}.json`);
|
||||
const filePath = path.join(
|
||||
scopeDir(scope),
|
||||
`${safeFilename(req.params.id)}.json`,
|
||||
);
|
||||
let videoId = null;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
@@ -163,26 +522,30 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
await fs.unlink(filePath);
|
||||
|
||||
if (videoId && typeof addToSkipList === "function") {
|
||||
await addToSkipList(videoId);
|
||||
await addToSkipList(scope, videoId);
|
||||
}
|
||||
|
||||
const meta = await loadMeta();
|
||||
meta.uncategorized = meta.uncategorized.filter(id => id !== req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
meta.uncategorized = meta.uncategorized.filter(
|
||||
(id) => id !== req.params.id,
|
||||
);
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => id !== req.params.id);
|
||||
folder.items = folder.items.filter((id) => id !== req.params.id);
|
||||
}
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update meta (folders, ordering) — the frontend sends the full structure
|
||||
// Update meta (folders, ordering) — scoped.
|
||||
app.put("/api/history/meta", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = req.body;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -190,11 +553,18 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.post("/api/history/folders", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = { id: `folder-${Date.now()}`, name: req.body.name || "New Folder", collapsed: false, items: [] };
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = {
|
||||
id: `folder-${Date.now()}`,
|
||||
name: req.body.name || "New Folder",
|
||||
collapsed: false,
|
||||
items: [],
|
||||
};
|
||||
meta.folders.push(folder);
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json(folder);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -202,12 +572,14 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.put("/api/history/folders/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = meta.folders.find(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = meta.folders.find((f) => f.id === req.params.id);
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
folder.name = req.body.name || folder.name;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json(folder);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -215,48 +587,52 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
});
|
||||
|
||||
app.put("/api/history/folders/:id/collapsed", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const folder = meta.folders.find(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const folder = meta.folders.find((f) => f.id === req.params.id);
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
folder.collapsed = !!req.body.collapsed;
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true, collapsed: folder.collapsed });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a folder — items move back to uncategorized
|
||||
// Delete a folder — items move back to uncategorized.
|
||||
app.delete("/api/history/folders/:id", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const meta = await loadMeta();
|
||||
const idx = meta.folders.findIndex(f => f.id === req.params.id);
|
||||
const meta = await loadMeta(scope);
|
||||
const idx = meta.folders.findIndex((f) => f.id === req.params.id);
|
||||
if (idx === -1) return res.status(404).json({ error: "Folder not found" });
|
||||
const [folder] = meta.folders.splice(idx, 1);
|
||||
meta.uncategorized = [...folder.items, ...meta.uncategorized];
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Move a session to a folder (or uncategorized if folderId is null)
|
||||
// Move a session to a folder (or uncategorized if folderId is null).
|
||||
app.put("/api/history/move", async (req, res) => {
|
||||
const scope = requireScope(req, res);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const { sessionId, folderId, index } = req.body;
|
||||
const meta = await loadMeta();
|
||||
const meta = await loadMeta(scope);
|
||||
|
||||
// Remove from current location
|
||||
meta.uncategorized = meta.uncategorized.filter(id => id !== sessionId);
|
||||
meta.uncategorized = meta.uncategorized.filter((id) => id !== sessionId);
|
||||
for (const folder of meta.folders) {
|
||||
folder.items = folder.items.filter(id => id !== sessionId);
|
||||
folder.items = folder.items.filter((id) => id !== sessionId);
|
||||
}
|
||||
|
||||
// Add to new location
|
||||
if (folderId) {
|
||||
const folder = meta.folders.find(f => f.id === folderId);
|
||||
const folder = meta.folders.find((f) => f.id === folderId);
|
||||
if (folder) {
|
||||
const i = typeof index === "number" ? index : folder.items.length;
|
||||
folder.items.splice(i, 0, sessionId);
|
||||
@@ -266,10 +642,20 @@ export function setupHistoryRoutes(app, { addToSkipList } = {}) {
|
||||
meta.uncategorized.splice(i, 0, sessionId);
|
||||
}
|
||||
|
||||
await saveMeta(meta);
|
||||
await saveMeta(scope, meta);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Allow the same character set as scope components for session ids.
|
||||
// Belt-and-suspenders against ../../ in :id; ids generated by
|
||||
// saveToHistory always match.
|
||||
function safeFilename(s) {
|
||||
if (typeof s !== "string" || !/^[A-Za-z0-9_-]+$/.test(s)) {
|
||||
throw new Error("invalid_session_id");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user