import express from 'express'; import mongoose from 'mongoose'; import multer from 'multer'; import os from 'os'; import { promises as fs } from 'fs'; import Item from '../models/Item'; import User from '../models/User'; import LoginLog from '../models/LoginLog'; import Settings from '../models/Settings'; import Collection from '../models/Collection'; import CustomPlugin from '../models/CustomPlugin'; import PriceHistory from '../models/PriceHistory'; import InstanceSettings from '../models/InstanceSettings'; import Furniture from '../models/Furniture'; import List from '../models/List'; import { invalidateInstanceSettingsCache } from '../utils/instanceSettings'; import { invalidateAiConfigCache } from '../core/ai/instance'; import { requireAuth, requireAdmin, requireCollectionRole } from '../middleware/authMiddleware'; import { registry } from '../core/registry'; import { buildSortTitle, stringifyCsv, getPublicProtocol } from '../core/helpers'; import { importableFields, fieldValue, ImportTargetField } from '../core/csvMapping'; // Stamped into every dump so a restore log says which build produced the file. // Read from package.json rather than copied, which is how it came to say 3.1.0 on 3.1.1. const pkg = require('../package.json'); import { migrateDatabase, normalizeThemePresets } from '../utils/migrate'; import { migrateExtraFieldIdentities } from '../utils/migrateExtraFieldIdentities'; import { seedFurnitureFromLocations } from '../core/shelfStore'; import { findDuplicateCellKey } from '../utils/shelfHelpers'; import { collectionInfoFromBackup, collectionInfoOf } from '../core/collectionInfo'; import { applyCustomPluginsFromDB } from '../core/customPluginSync'; import { collectExtraDateFields, reviveExtraDates } from '../core/pluginExtraFields'; import { deleteUnusedManagedItemImages, managedItemImageFile, managedItemImagesForQuery } from '../core/itemImageStorage'; import { MAX_BACKUP_UPLOAD_BYTES, readBackupArchive, sendBackupArchive } from '../core/backupArchive'; import { BASE_URL } from '../config/constants'; const router = express.Router(); const backupArchiveUpload = multer({ dest: os.tmpdir(), limits: { files: 1, fileSize: MAX_BACKUP_UPLOAD_BYTES } }); const requireInstanceImportAccess = async (req: any, res: any, next: any) => { try { const userCount = await User.countDocuments(); if (userCount === 0 || res.locals.user?.isAdmin) return next(); console.warn(`[SECURITY] import unauthorized : ${req.ip}`); return res.status(403).json({ error: 'Import unauthorized.' }); } catch (err) { next(err); } }; const receiveBackupArchive = (req: any, res: any, next: any) => { backupArchiveUpload.single('backup')(req, res, (err: any) => { if (!err) return next(); const tooLarge = err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE'; return res.status(tooLarge ? 413 : 400).json({ error: tooLarge ? 'Backup archive too large' : 'Invalid backup archive' }); }); }; const loadBackupArchive = async (req: any, res: any, next: any) => { if (!req.file?.path) return res.status(400).json({ error: 'Backup archive missing' }); try { const imported = await readBackupArchive(req.file.path); req.body = imported.data; req.importedManagedImagePaths = imported.importedImages; next(); } catch (err) { console.warn('[BACKUP] Invalid archive:', err); res.status(400).json({ error: 'Backup archive corrupted or invalid' }); } finally { try { await fs.unlink(req.file.path); } catch (err: any) { if (err?.code !== 'ENOENT') console.warn('[BACKUP] Temporary archive cleanup failed:', err); } } }; async function cleanupImportedImages(req: any): Promise { const images = Array.isArray(req.importedManagedImagePaths) ? req.importedManagedImagePaths : []; if (images.length === 0) return; try { await deleteUnusedManagedItemImages(images); } catch (err) { console.warn('[BACKUP] Imported image cleanup failed:', err); } } // ============ WHOLE-INSTANCE BACKUP (instance admin) ============ async function buildInstanceBackup(): Promise { return { users: await User.find({}).lean(), albums: await Item.find({}).lean(), logs: await LoginLog.find({}).lean(), settings: await Settings.find({}).lean(), collections: await Collection.find({}).lean(), furniture: await Furniture.find({}).lean(), lists: await List.find({}).lean(), customPlugins: await CustomPlugin.find({}).lean(), priceHistory: await PriceHistory.find({}).lean(), instanceSettings: await InstanceSettings.findOne({ key: 'instance' }).lean(), metadata: { version: pkg.version, date: new Date() } }; } router.get('/export', requireAuth, requireAdmin, async (req, res) => { try { const data = await buildInstanceBackup(); const fileName = `dvinyl_instance_${new Date().toISOString().split('T')[0]}.json`; console.log(`[BACKUP] Instance export: ${data.users.length} user(s), ${data.albums.length} item(s), ${data.collections.length} collection(s)`); res.setHeader('Content-disposition', 'attachment; filename=' + fileName); res.setHeader('Content-type', 'application/json'); res.send(JSON.stringify(data, null, 2)); } catch (err) { console.error("[BACKUP] Instance export failed:", err); res.status(500).send("Export failed"); } }); router.get('/export-archive', requireAuth, requireAdmin, async (req, res) => { try { const data = await buildInstanceBackup(); const fileName = `dvinyl_instance_${new Date().toISOString().split('T')[0]}.zip`; console.log(`[BACKUP] Instance archive export: ${data.users.length} user(s), ${data.albums.length} item(s), ${data.collections.length} collection(s)`); await sendBackupArchive(res, data, fileName); } catch (err) { console.error('[BACKUP] Instance archive export failed:', err); if (!res.headersSent) res.status(500).send('Export failed'); else res.destroy(err as Error); } }); /** * POST /import - whole-instance restore (wipe & replace). * Supports three dump generations: * - v3.1 (has `collections` + `settings` as array): restored verbatim. * - v2/v3.0 (single global `settings`, no collections): collection-related * fields are stripped and the boot migration is re-run to rebuild a default * collection and re-stamp everything. */ const importInstanceBackup = async (req: any, res: any) => { try { // Setup let data = req.body; if (data.backupData) { try { data = typeof data.backupData === 'string' ? JSON.parse(data.backupData) : data.backupData; } catch (e) { await cleanupImportedImages(req); return res.status(400).json({ error: "Invalid JSON format" }); } } if (!data || (!Array.isArray(data.users) && !Array.isArray(data.albums))) { await cleanupImportedImages(req); return res.status(400).json({ error: "Backup file missing required fields" }); } // A collection's own backup (what a collection's admin page exports) carries no // accounts. Restored here, it would wipe every user and every other collection and // leave its items in none, with nobody left able to sign in. Refused before // anything is touched, with a message saying where it goes instead. const isCollectionDump = typeof data.collectionName === 'string' || data.metadata?.type === 'collection'; if (isCollectionDump || !Array.isArray(data.users) || data.users.length === 0) { await cleanupImportedImages(req); return res.status(400).json({ error: "Collection backup (no user accounts) posted to the instance import", userMessage: req.t('admin.backup.error_collection_dump') }); } const hasCollections = Array.isArray(data.collections) && data.collections.length > 0; // Lists name items by id, and a whole-instance restore keeps every id as it was, so // they go back untouched. Settled here, before the wipe. const listsToRestore = hasCollections ? restorableLists(data.lists, list => list) : []; const replacedImagePaths = await managedItemImagesForQuery({}); // The wipe below takes the info pages with it, so their uploads are candidates // for release too, exactly like the items' own images. for (const current of await Collection.find({}).select('info.images').lean()) { replacedImagePaths.push(...collectionInfoOf(current).images); } console.log(`[BACKUP] Instance import started (dump version ${data.metadata?.version || 'unknown'}, ${hasCollections ? 'with' : 'without'} collections): ${data.users?.length || 0} user(s), ${data.albums?.length || 0} item(s). Wiping current data...`); await Promise.all([ LoginLog.deleteMany({}), Item.deleteMany({}), User.deleteMany({}), Settings.deleteMany({}), Collection.deleteMany({}), Furniture.deleteMany({}), List.deleteMany({}), CustomPlugin.deleteMany({}), PriceHistory.deleteMany({}), InstanceSettings.deleteMany({}) ]); // The singleton is cached in memory; the wipe above must not leave a stale copy // authorizing (or blocking) collection creation until the next restart. invalidateInstanceSettingsCache(); invalidateAiConfigCache(); if (hasCollections) { await Collection.insertMany(data.collections); } // Furniture belongs to a collection, so it only means anything alongside them. if (hasCollections && Array.isArray(data.furniture) && data.furniture.length > 0) { const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v)) ? new mongoose.Types.ObjectId(v) : v; // Cast back to their BSON types like the items below: the native insert skips the // schema, and `created_at` orders the pieces of one collection after `order`. await Furniture.collection.insertMany(data.furniture.map((piece: any) => ({ ...piece, _id: toId(piece._id), collection: toId(piece.collection), createdBy: piece.createdBy ? toId(piece.createdBy) : undefined, ...(piece.created_at ? { created_at: new Date(piece.created_at) } : {}), ...(piece.updated_at ? { updated_at: new Date(piece.updated_at) } : {}) }))); } // Whether the migration below still has free-text locations to turn into shelves. // A dump that carries a furniture list, even an empty one, comes from an instance // where that already happened: its collections hold the shelves they want, and an // empty list is furniture taken down on purpose, which stays that way. Whatever // flag each collection was dumped with is not trusted for this, since one created // after its instance's last boot could carry it unset. A dump from before the // shelves carries no list at all: its collections are handed to the migration, // which rebuilds the shelves from those very locations. if (hasCollections) { await Collection.updateMany({}, { $set: { shelvesSeeded: Array.isArray(data.furniture) } }); } // Inserted through the model so the ids a JSON dump carries as strings are cast // back to ObjectIds. if (listsToRestore.length > 0) { await List.insertMany(listsToRestore); } if (data.users && data.users.length > 0) { const cleanUsers = hasCollections ? data.users : data.users.map((u: any) => { const { lastActiveCollectionId, homeCollectionId, ...rest } = u; return rest; }); await User.insertMany(cleanUsers); } if (data.albums && data.albums.length > 0) { // Legacy backups may hold items without a `kind`; assign the plugin that claims legacy items const legacyKind = registry.getAll().find(p => p.matchesLegacyItems)?.kind || 'Music'; const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v)) ? new mongoose.Types.ObjectId(v) : v; const extraDateFields = collectExtraDateFields( Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : []) ); const cleanAlbums = data.albums.map((album: any) => { const fixed: any = album.kind ? { ...album } : { ...album, kind: legacyKind }; // Without the collections themselves, stale collection ids would orphan items if (!hasCollections) delete fixed.collection; // Cast the ref/date fields back to their BSON types (they are strings in JSON). if (fixed._id) fixed._id = toId(fixed._id); if (fixed.owner) fixed.owner = toId(fixed.owner); if (fixed.modified_by) fixed.modified_by = toId(fixed.modified_by); // Ids are preserved on a whole-instance restore, so a containment link only // needs its BSON type back. if (fixed.parent) fixed.parent = toId(fixed.parent); if (fixed.collection) fixed.collection = toId(fixed.collection); if (fixed.added_at) fixed.added_at = new Date(fixed.added_at); if (fixed.updated_at) fixed.updated_at = new Date(fixed.updated_at); if (fixed.modified_at) fixed.modified_at = new Date(fixed.modified_at); if (fixed.synced_at) fixed.synced_at = new Date(fixed.synced_at); // Same treatment for the date-typed user-defined fields, which sit in a // Mixed path and would otherwise come back as strings if (fixed.extra) fixed.extra = { ...fixed.extra }; reviveExtraDates(fixed, extraDateFields); // The native insert below skips the schema middleware that normally derives // this, and a dump older than the field carries none at all. fixed.sort_title = buildSortTitle(fixed.title); return fixed; }); // Insert with the native driver, bypassing Mongoose validation. A backup is // authoritative: re-validating restored items against the live (possibly stricter) // discriminator schema (e.g. a custom type whose `creator` became required) would // reject legitimately-saved items and, since the wipe already ran, gut the instance. await Item.collection.insertMany(cleanAlbums); } if (data.logs && data.logs.length > 0) { await LoginLog.insertMany(data.logs); } // Value snapshots are collection-scoped, so a dump restored without its collections // has nothing for them to describe: they are dropped rather than left pointing at // ids this instance does not have. A dump older than the feature simply has none. if (hasCollections && Array.isArray(data.priceHistory) && data.priceHistory.length > 0) { const toId = (v: any) => (typeof v === 'string' && mongoose.Types.ObjectId.isValid(v)) ? new mongoose.Types.ObjectId(v) : v; const cleanHistory = data.priceHistory.map((snapshot: any) => { const fixed: any = { ...snapshot }; if (fixed._id) fixed._id = toId(fixed._id); if (fixed.collection) fixed.collection = toId(fixed.collection); if (fixed.capturedAt) fixed.capturedAt = new Date(fixed.capturedAt); return fixed; }); await PriceHistory.collection.insertMany(cleanHistory); } // v3.1 exports settings as an array (one per collection); older dumps as one object const settingsDocs = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : []); for (const s of settingsDocs) { const clean = { ...s }; if (!hasCollections) delete clean.collection; // A legacy Settings doc can hold theme..preset as an object (e.g. the games // plugin's { default: 'default' }). Settings.create() validates and would throw a // CastError here, after the wipe already ran, before migrateDatabase() gets to // normalize it, leaving the instance half-restored. Normalize up front, like the // boot migration does via the native driver. normalizeThemePresets(clean.theme); await Settings.create(clean); } // No-code plugin definitions. Absent from dumps predating v3.1. if (Array.isArray(data.customPlugins) && data.customPlugins.length > 0) { await CustomPlugin.insertMany(data.customPlugins); } // Instance-wide policy singleton. Absent from older dumps, in which case the // schema defaults (self-service off) apply on the next read. if (data.instanceSettings) { const { _id, created_at, updated_at, __v, ...values } = data.instanceSettings; await InstanceSettings.updateOne( { key: 'instance' }, { $set: { ...values, key: 'instance' } }, { upsert: true } ); invalidateInstanceSettingsCache(); invalidateAiConfigCache(); } // Rebuild the multi-collection invariants (default collection, item/user/settings // stamps, memberships). Idempotent; also heals legacy dumps. migrateDatabase() // only logs on failure (it must not crash server startup when called at boot), // so check its core invariant here rather than trusting a bare "it didn't throw". await migrateDatabase(); // Reconcile no-code plugins with the freshly imported DB: re-materialize the // plugins// folders and hot-register them, pruning any from the old instance. await applyCustomPluginsFromDB(); await upgradeRestoredExtraFields(); // A JSON restore can keep paths already present on this installation, while a ZIP // restore has already rewritten its files to fresh paths. In both cases, remove only // files that the replacement no longer references. try { await deleteUnusedManagedItemImages(replacedImagePaths); } catch (cleanupError) { console.warn('[ITEM IMAGE] Post-import cleanup failed:', cleanupError); } const restoredCollectionCount = await Collection.countDocuments(); console.log(`[BACKUP] Instance import finished: ${restoredCollectionCount} collection(s) rebuilt, ${await Item.countDocuments()} item(s) restored`); res.cookie('jwt', '', { maxAge: 1 }); if (restoredCollectionCount === 0) { return res.status(200).json({ success: true, warning: "Import completed but no collection could be rebuilt (the dump may be missing an admin user). Items may be inaccessible until an admin account exists.", message: "Import successful" }); } res.status(200).json({ success: true, message: "Import successful" }); } catch (err: any) { console.error("[ERR] Import :", err); await cleanupImportedImages(req); res.status(500).json({ error: err.message }); } }; router.post('/import', requireInstanceImportAccess, importInstanceBackup); router.post( '/import-archive', requireInstanceImportAccess, receiveBackupArchive, loadBackupArchive, importInstanceBackup ); // ============ PER-COLLECTION BACKUP (collection admin) ============ async function buildCollectionBackup(activeCollectionId: any, collection: any): Promise { const settings = await Settings.findOne({ collection: activeCollectionId }).lean() as any; if (settings) { delete settings._id; delete settings.collection; delete settings.__v; } const albums = (await Item.find({ collection: activeCollectionId }).lean()).map((a: any) => { // Owner and collection are re-stamped at import time (a dump may be restored // into another collection, or another instance), so they go. The id stays: a // season points at the show that holds it, and the import can only rebuild // that link if it can tell which item was which. It is never restored as is, // the import draws a new one and rewrites the links against it. const { __v, owner, collection, ...rest } = a; return rest; }); // Stripped of what the import re-stamps. Everything a shelf is made of is its name // and its place in the grid, and the items find it by name, so nothing else is needed // for the restore to put the collection back exactly as it stood. const furniture = (await Furniture.find({ collection: activeCollectionId }).lean()).map((piece: any) => { const { _id, __v, collection: owner, createdBy, created_at, updated_at, ...rest } = piece; return rest; }); // Kept with the item ids they point at: the import draws new ids for the items and // rewrites these against them, like it does for a season and its show. const lists = (await List.find({ collection: activeCollectionId }).lean()).map((list: any) => { const { _id, __v, collection: owner, createdBy, ...rest } = list; return rest; }); return { collectionName: collection?.name || 'Collection', albums, furniture, lists, // The collection's own page travels with it: it is written about these items, // and its pictures are picked up by the archive alongside theirs. info: collectionInfoOf(collection), settings: settings || null, metadata: { version: pkg.version, type: 'collection', date: new Date() } }; } router.get('/collection/export', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => { try { const activeCollectionId = res.locals.activeCollectionId; const collection = res.locals.activeCollection; const data = await buildCollectionBackup(activeCollectionId, collection); const slug = collection?.slug || 'collection'; const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.json`; res.setHeader('Content-disposition', 'attachment; filename=' + fileName); res.setHeader('Content-type', 'application/json'); res.send(JSON.stringify(data, null, 2)); } catch (err) { console.error("[ERR] Collection export:", err); res.status(500).send("Export failed"); } }); router.get('/collection/export-archive', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => { try { const activeCollectionId = res.locals.activeCollectionId; const collection = res.locals.activeCollection; const data = await buildCollectionBackup(activeCollectionId, collection); const slug = collection?.slug || 'collection'; const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.zip`; await sendBackupArchive(res, data, fileName); } catch (err) { console.error('[BACKUP] Collection archive export failed:', err); if (!res.headersSent) res.status(500).send('Export failed'); else res.destroy(err as Error); } }); /** * GET /collection/export-csv - the same collection, as a flat spreadsheet instead of a * restorable dump. One row per item; columns are the union of the importable fields * (base + plugin + user-defined) of every kind actually present, so a single-type * collection reads as a clean sheet and a mixed one simply carries more (sparse) * columns. Fields shared by several plugins share a column only when they are the same * field, see the identity check below. Read-only: unlike the JSON export this never * round-trips through /import. */ router.get('/collection/export-csv', requireAuth, requireCollectionRole('admin'), async (req: any, res: any) => { try { const activeCollectionId = res.locals.activeCollectionId; const collection = res.locals.activeCollection; const settings = res.locals.settings; const albums = await Item.find({ collection: activeCollectionId }).lean(); // Registry order rather than the order Mongo happened to return the items in, // so the same collection always exports its columns in the same order. const present = new Set(albums.map((a: any) => a.kind)); const kinds = registry.getAll().map(p => p.kind).filter(kind => present.has(kind)); // A column is one field of one plugin. Two plugins merge into a single column // only when their field is the same thing under the same name: the base fields // (Title, Year...) do, media_type does not, since it holds vinyl/cd for music // and movie/tv for DVDs. Merging those would print one plugin's labels and the // other's raw values in a column headed with a name that fits half the rows. const identity = (f: ImportTargetField) => JSON.stringify([ f.name, f.type, f.label, f.extraField === true, (f.options || []).map(o => [o.value, o.label]) ]); interface ExportColumn { field: ImportTargetField; label: string; /** Left empty for any other kind, even one owning a field of the same name. */ kinds: Set; /** Names the module in the header when two columns end up reading alike. */ owner: string; } const columns: ExportColumn[] = []; const byIdentity = new Map(); for (const kind of kinds) { const plugin = registry.getByKind(kind); if (!plugin) continue; // Cannot happen, kinds come from the registry: narrows the type. const pluginLabel = req.t(plugin.label, { defaultValue: plugin.id }); for (const field of importableFields(plugin, settings, req.t)) { const key = identity(field); const known = byIdentity.get(key); if (known) { known.kinds.add(kind); continue; } const column: ExportColumn = { field, label: field.label, kinds: new Set([kind]), owner: pluginLabel }; byIdentity.set(key, column); columns.push(column); } } const typeLabel = req.t('admin.backup.csv.type_column'); const wishlistLabel = req.t('admin.backup.csv.wishlist_column'); // Four modules label a field "Format", each with its own options, and the DVD // plugin calls its own field "Type" like the module column added here: without // this the sheet repeats a header and nobody can tell the columns apart. The two // columns this route adds itself are counted in, and win the bare name. const labelUses = new Map([[typeLabel, 1], [wishlistLabel, 1]]); for (const column of columns) labelUses.set(column.label, (labelUses.get(column.label) || 0) + 1); for (const column of columns) { if ((labelUses.get(column.label) || 0) > 1) column.label = `${column.label} (${column.owner})`; } const header = [typeLabel, ...columns.map(c => c.label), wishlistLabel]; // An image uploaded from an item form is stored as a portable `/uploads/items/...` // path, which is what the item document and the ZIP archive want: neither is tied // to the address this instance answers on. A spreadsheet is read outside the app, // where that path resolves nowhere, so the column headed "Cover (URL)" is given the // absolute URL this deployment actually serves the file at. Linked images already // carry their own absolute URL and are handed through untouched. const publicOrigin = `${getPublicProtocol(req)}://${req.get('host')}${BASE_URL.replace(/\/+$/, '')}`; const asPublicUrl = (value: string) => (managedItemImageFile(value) ? `${publicOrigin}${value}` : value); const rows: string[][] = [header]; for (const album of albums) { const kind = String((album as any).kind || ''); const plugin = registry.getByKind(kind); const typeName = plugin ? req.t(plugin.label, { defaultValue: plugin.id }) : kind; const cells = columns.map(c => (c.kinds.has(kind) ? asPublicUrl(fieldValue(album, c.field)) : '')); rows.push([typeName, ...cells, (album as any).in_wishlist ? 'true' : 'false']); } // Leading BOM so Excel (which guesses ANSI otherwise) opens accented labels // and titles as UTF-8 instead of mojibake. const csv = '' + stringifyCsv(rows); const slug = collection?.slug || 'collection'; const fileName = `dvinyl_collection-${slug}_${new Date().toISOString().split('T')[0]}.csv`; res.setHeader('Content-disposition', 'attachment; filename=' + fileName); res.setHeader('Content-type', 'text/csv; charset=utf-8'); res.send(csv); } catch (err) { console.error("[ERR] Collection CSV export:", err); res.status(500).send("Export failed"); } }); /** * The custom-field identity upgrade, run on data a restore has just finished writing. * Logged rather than thrown: the restore itself went through, and reporting it as failed * would also release the images the archive brought in for it. The migration is * idempotent, so the next boot takes it up again. */ async function upgradeRestoredExtraFields(options: { collectionId?: any } = {}): Promise { try { await migrateExtraFieldIdentities(options); } catch (err) { console.error('[BACKUP] Custom field identity migration failed after the restore:', err); } } /** * The furniture a collection restore puts back as it is, or null when the shelves have * to be rebuilt from the restored items' locations instead. * * Rebuilt when the dump has no furniture of this collection to offer: one from before * the shelves, or a whole-instance dump, whose pieces belong to several collections and * can name the same shelf twice once poured into one. Rebuilt too when the pieces would * not pass the model or claim one shelf twice (a file edited by hand): the insert would * only refuse them after the collection's items were already replaced. * * An empty list comes back empty: that collection had its furniture taken down on purpose. */ function restorableFurniture(data: any, collectionId: any, userId: any): any[] | null { if (!Array.isArray(data.furniture) || Array.isArray(data.collections)) return null; const pieces = data.furniture.map((piece: any, index: number) => { // The export strips these already. A file that still carries them must not bring // back an id this instance may hold for another piece. const { _id, __v, collection: _owner, createdBy: _by, created_at, updated_at, ...rest } = piece || {}; return { ...rest, collection: collectionId, order: typeof rest.order === 'number' ? rest.order : 100 + index, createdBy: userId }; }); const cells = pieces.flatMap((piece: any) => (Array.isArray(piece.cells) ? piece.cells : [])); const duplicate = findDuplicateCellKey(cells); const invalid = pieces.find((piece: any) => new Furniture(piece).validateSync()); if (duplicate || invalid) { console.warn(`[BACKUP] The dump's furniture cannot be restored as it is (${duplicate ? `shelf "${duplicate}" appears twice` : 'invalid piece'}); rebuilding the shelves from the item locations.`); return null; } return pieces; } // An id as a dump writes one: 24 hex digits, or already an ObjectId. isValid() alone // would also take any 12-character string. const isDumpId = (value: unknown) => value instanceof mongoose.Types.ObjectId || /^[a-f0-9]{24}$/i.test(String(value ?? '')); /** * The lists of a dump that can go back, each in the shape `place` gives it. Settled * before anything is wiped, for the same reason as the furniture: a list the model * refuses (a kind this version does not know, a name left blank in a file edited by * hand) would only fail its insert once the replacement had already started, and a * whole-instance restore inserts the lists before the accounts. Such a list is left out * rather than costing the rest of the restore, and so is a line naming no valid item. */ function restorableLists(lists: unknown, place: (list: any) => any): any[] { if (!Array.isArray(lists)) return []; const kept: any[] = []; let dropped = 0; for (const raw of lists) { if (!raw || typeof raw !== 'object') { dropped++; continue; } const entries = (Array.isArray(raw.entries) ? raw.entries : []).filter((entry: any) => entry && isDumpId(entry.item) && (entry.track === undefined || entry.track === null || isDumpId(entry.track)) ); const list = place({ ...raw, entries }); if (new List(list).validateSync()) { dropped++; continue; } kept.push(list); } if (dropped > 0) { console.warn(`[BACKUP] ${dropped} list(s) of the dump could not be restored as they are and were left out.`); } return kept; } /** * POST /collection/import - replaces the ACTIVE collection's items (and settings, * when present in the file) with the backup's content. Accepts both per-collection * dumps (albums pre-stripped) and whole-instance dumps (albums re-stamped here). */ const importCollectionBackup = async (req: any, res: any) => { try { const activeCollectionId = res.locals.activeCollectionId; let data = req.body; if (data.backupData) { try { data = typeof data.backupData === 'string' ? JSON.parse(data.backupData) : data.backupData; } catch (e) { await cleanupImportedImages(req); return res.status(400).json({ error: "Invalid JSON format" }); } } if (!data || !Array.isArray(data.albums)) { await cleanupImportedImages(req); return res.status(400).json({ error: "Backup file missing required fields" }); } // Settled before anything is wiped, so furniture that cannot go back as it is // turns into a rebuild rather than an error halfway through the replacement. const furnitureToRestore = restorableFurniture(data, activeCollectionId, req.user._id); // Old item id -> new one. Ids are reassigned (the same dump may be restored twice // into different collections), which would leave every "contained in" and every // list line pointing at an item that no longer exists. So the new ids are drawn up // front and the links rewritten against them, keeping a show and its seasons, and // the lists, together through the restore. const idMap = new Map(); for (const album of data.albums) { if (album?._id) idMap.set(String(album._id), new mongoose.Types.ObjectId()); } const legacyKind = registry.getAll().find(p => p.matchesLegacyItems)?.kind || 'Music'; const extraDateFields = collectExtraDateFields( Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : []) ); // A file edited by hand can name one id twice. The second copy gets an id of its // own, since sharing one would fail the insert after the wipe. const usedIds = new Set(); const cleanAlbums = data.albums.map((album: any) => { const { _id, __v, ...rest } = album || {}; const fixed: any = { ...rest, kind: rest.kind || legacyKind, owner: req.user._id, collection: activeCollectionId }; const newId = _id ? idMap.get(String(_id)) : undefined; if (newId && !usedIds.has(String(newId))) { fixed._id = newId; usedIds.add(String(newId)); } // A holder left outside the dump would strand the item in no listing at all, // so it becomes standalone rather than invisible. fixed.parent = rest.parent ? idMap.get(String(rest.parent)) : undefined; if (!fixed.parent) delete fixed.parent; if (fixed.extra) fixed.extra = { ...fixed.extra }; reviveExtraDates(fixed, extraDateFields); return fixed; }); // Checked against their models before the wipe: the insert below validates too, // and an item it refused there would leave the collection emptied. Refused here, // the collection stays exactly as it was. for (const album of cleanAlbums) { const Model: any = (Item as any).discriminators?.[album.kind] || Item; const invalid = new Model(album).validateSync(); if (invalid) { await cleanupImportedImages(req); const field = Object.keys(invalid.errors || {})[0] || '?'; return res.status(400).json({ error: invalid.message, userMessage: req.t('admin.collection_backup.error_invalid_item', { title: String(album.title || '?').slice(0, 100), field }) }); } } // A line whose item did not come back with the dump has nothing to point at and is // left out; tracks keep their own ids through the restore, so only the item half of // a playlist line is rewritten. const listsToRestore = restorableLists(data.lists, (list: any) => { const { _id, __v, collection: _owner, createdBy: _by, ...rest } = list; return { ...rest, collection: activeCollectionId, createdBy: req.user._id, entries: rest.entries .filter((entry: any) => idMap.has(String(entry.item))) .map((entry: any) => ({ ...entry, item: idMap.get(String(entry.item)) })) }; }); // Replacement semantics: the collection's current items are wiped first. const replacedImagePaths = await managedItemImagesForQuery({ collection: activeCollectionId }); await Item.deleteMany({ collection: activeCollectionId }); if (cleanAlbums.length > 0) { await Item.insertMany(cleanAlbums); } // Replacement semantics for the lists too. await List.deleteMany({ collection: activeCollectionId }); if (listsToRestore.length > 0) { await List.insertMany(listsToRestore); } // Replacement semantics again: the collection's own furniture goes with its items. await Furniture.deleteMany({ collection: activeCollectionId }); if (furnitureToRestore) { if (furnitureToRestore.length > 0) await Furniture.insertMany(furnitureToRestore); } else { // The restored items still say where each one is kept. Rebuilt into furniture // the same way the boot migration does it, so the restore does not land a // collection whose every item is unsorted. const seeded = await seedFurnitureFromLocations( activeCollectionId, res.locals.activeCollection?.name || 'Collection' ); if (seeded.shelves > 0) { console.log(`[BACKUP] ${seeded.shelves} shelf/shelves rebuilt from the restored locations.`); } } // The collection's shelves are now whatever this restore made of them, so the boot // migration has nothing left to convert here, and must not rebuild pieces its // owners take down from now on. await Collection.updateOne({ _id: activeCollectionId }, { $set: { shelvesSeeded: true } }); // The info page is replaced like everything else, and the pictures the previous // one held are released once the new page is in place (nothing else points at // them, and the release checks every other reference before it unlinks a file). const previousInfo = collectionInfoOf(await Collection.findById(activeCollectionId).lean()); if (data.info && typeof data.info === 'object') { const info = collectionInfoFromBackup(data.info); await Collection.updateOne({ _id: activeCollectionId }, { $set: { info } }); replacedImagePaths.push(...previousInfo.images.filter(image => !info.images.includes(image))); } // Restore the collection's settings container when the dump carries one const settingsDoc = Array.isArray(data.settings) ? data.settings[0] : data.settings; if (settingsDoc) { const clean = { ...settingsDoc }; delete clean._id; delete clean.__v; clean.collection = activeCollectionId; await Settings.deleteMany({ collection: activeCollectionId }); await Settings.create(clean); } // Collection restores do not run the whole boot migration. Apply the same // custom-field identity upgrade explicitly before the restored data is used. await upgradeRestoredExtraFields({ collectionId: activeCollectionId }); try { await deleteUnusedManagedItemImages(replacedImagePaths); } catch (cleanupError) { console.warn('[ITEM IMAGE] Collection import cleanup failed:', cleanupError); } res.status(200).json({ success: true, message: "Import successful", count: data.albums.length }); } catch (err: any) { console.error("[ERR] Collection import:", err); await cleanupImportedImages(req); res.status(500).json({ error: err.message }); } }; router.post('/collection/import', requireAuth, requireCollectionRole('admin'), importCollectionBackup); router.post( '/collection/import-archive', requireAuth, requireCollectionRole('admin'), receiveBackupArchive, loadBackupArchive, importCollectionBackup ); export = router;