From 448c28caee53df02389cd4b5f9be72816ae7baa5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 20:08:58 +0000 Subject: [PATCH] feat: Add admin DB backup and restore functionality Co-authored-by: mathieumoran1 --- index.js | 2 + package-lock.json | 132 +++++++++++++- package.json | 3 +- public/control.html | 70 ++++++++ public/js/app_control.js | 246 +++++++++++++++++++++++++ routes/adminDb.js | 375 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 826 insertions(+), 2 deletions(-) create mode 100644 routes/adminDb.js diff --git a/index.js b/index.js index bdde37a..dbe6aa8 100644 --- a/index.js +++ b/index.js @@ -21,12 +21,14 @@ const userRoutes = require("./routes/users"); const movieRoutes = require("./routes/movies"); const versionRoutes = require("./routes/version"); const settingsRoutes = require("./routes/settings"); +const adminDbRoutes = require("./routes/adminDb"); app.use(express.static('public')); app.use("/api/users", userRoutes); app.use("/api/movies", movieRoutes); app.use("/api/version", versionRoutes); app.use("/api/settings", settingsRoutes); +app.use("/api/admin", adminDbRoutes); // Start Server const PORT = process.env.PORT || 5000; diff --git a/package-lock.json b/package-lock.json index a7f5ef8..63f42e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "express": "^4.22.1", "jsonwebtoken": "^9.0.2", "moment-timezone": "^0.5.47", - "mongoose": "^8.9.5" + "mongoose": "^8.9.5", + "multer": "^2.0.2" }, "devDependencies": { "nodemon": "^3.1.9" @@ -74,6 +75,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -186,6 +193,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -268,6 +292,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -997,6 +1036,27 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -1152,6 +1212,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1360,6 +1438,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1580,6 +1672,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1650,6 +1759,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1666,6 +1781,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1705,6 +1826,15 @@ "engines": { "node": ">=18" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index 7eb0230..73adf4a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "express": "^4.22.1", "jsonwebtoken": "^9.0.2", "moment-timezone": "^0.5.47", - "mongoose": "^8.9.5" + "mongoose": "^8.9.5", + "multer": "^2.0.2" }, "devDependencies": { "nodemon": "^3.1.9" diff --git a/public/control.html b/public/control.html index 72f74f9..1da98aa 100644 --- a/public/control.html +++ b/public/control.html @@ -80,6 +80,11 @@ Films +
@@ -317,6 +322,71 @@
+ +
+
+
+
+

Backup

+

+ Crée une sauvegarde de la base MongoDB (format .ndjson.gz) et conserve-la sur le serveur. +

+ +
+ + +
+ +
+ + + + + + + + + + + + +
FichierTailleModifiéActions
Ouvre l’onglet pour charger la liste…
+
+
+
+ +
+
+

Restore

+

+ Restaure une sauvegarde (fichier .ndjson.gz). Option recommandée: drop pour supprimer les collections avant import. +

+ +
+ La restauration peut écraser les données. Fais un backup juste avant si besoin. +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+
+
diff --git a/public/js/app_control.js b/public/js/app_control.js index f9993d4..50075e6 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -218,6 +218,18 @@ window.onload = async function () { const adminUsersCountEl = document.getElementById('admin-users-count'); const adminUsersTabBtn = document.getElementById('admin-users-tab'); + // Admin DB backup/restore elements + const adminBackupTabBtn = document.getElementById('admin-backup-tab'); + const dbBackupCreateBtn = document.getElementById('db-backup-create'); + const dbBackupRefreshBtn = document.getElementById('db-backup-refresh'); + const dbBackupListBody = document.getElementById('db-backup-list'); + const dbRestoreFileEl = document.getElementById('db-restore-file'); + const dbRestoreDropEl = document.getElementById('db-restore-drop'); + const dbRestoreRunBtn = document.getElementById('db-restore-run'); + const dbRestoreResultEl = document.getElementById('db-restore-result'); + + let backupsLoadedOnce = false; + // Admin: add user form elements const openAddUserModalBtn = document.getElementById('open-add-user-modal'); const addUserModalEl = document.getElementById('addUserModal'); @@ -755,6 +767,240 @@ window.onload = async function () { adminUsersTabBtn.addEventListener('shown.bs.tab', () => loadAdminUsers({ force: false })); } + function formatBytes(n) { + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return '—'; + if (v < 1024) return `${v} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let x = v / 1024; + let idx = 0; + while (x >= 1024 && idx < units.length - 1) { + x /= 1024; + idx += 1; + } + return `${x.toFixed(x >= 10 ? 1 : 2)} ${units[idx]}`; + } + + function formatDateTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString('fr-FR', { hour12: false }); + } + + async function fetchBackups() { + const res = await fetch('/api/admin/db/backups', { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || data.error || `Erreur (${res.status})`); + } + return Array.isArray(data.backups) ? data.backups : []; + } + + function renderBackups(backups) { + if (!dbBackupListBody) return; + const list = Array.isArray(backups) ? backups : []; + if (!list.length) { + dbBackupListBody.innerHTML = 'Aucune sauvegarde.'; + return; + } + + dbBackupListBody.innerHTML = ''; + list.forEach((b) => { + const name = String(b?.name || ''); + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${name || '—'} + ${formatBytes(b?.sizeBytes)} + ${formatDateTime(b?.mtime)} + + + + + `; + dbBackupListBody.appendChild(tr); + }); + + dbBackupListBody.querySelectorAll('button[data-backup-download]').forEach((btn) => { + btn.addEventListener('click', async () => { + const name = btn.getAttribute('data-backup-download'); + if (!name) return; + btn.disabled = true; + const old = btn.textContent; + btn.textContent = '...'; + try { + const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message || data.error || `Erreur (${res.status})`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + btn.disabled = false; + btn.textContent = old; + } + }); + }); + + dbBackupListBody.querySelectorAll('button[data-backup-delete]').forEach((btn) => { + btn.addEventListener('click', async () => { + const name = btn.getAttribute('data-backup-delete'); + if (!name) return; + const ok = window.confirm(`Supprimer la sauvegarde "${name}" ?`); + if (!ok) return; + btn.disabled = true; + const old = btn.textContent; + btn.textContent = '...'; + try { + const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || data.error || `Erreur (${res.status})`); + } + showResponse('success', data.message || 'Sauvegarde supprimée.'); + await loadBackups({ force: true }); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + btn.disabled = false; + btn.textContent = old; + } + }); + }); + } + + async function loadBackups(options = {}) { + const force = !!options.force; + if (!dbBackupListBody) return; + if (backupsLoadedOnce && !force) return; + dbBackupListBody.innerHTML = 'Chargement…'; + try { + const backups = await fetchBackups(); + backupsLoadedOnce = true; + renderBackups(backups); + } catch (err) { + dbBackupListBody.innerHTML = `${err.message || 'Erreur réseau'}`; + } + } + + async function createBackup() { + if (!dbBackupCreateBtn) return; + dbBackupCreateBtn.disabled = true; + const old = dbBackupCreateBtn.textContent; + dbBackupCreateBtn.textContent = 'Création...'; + try { + const res = await fetch('/api/admin/db/backup', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || data.error || `Erreur (${res.status})`); + } + showResponse('success', `Sauvegarde créée: ${data?.backup?.name || 'OK'}`); + await loadBackups({ force: true }); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + dbBackupCreateBtn.disabled = false; + dbBackupCreateBtn.textContent = old; + } + } + + async function runRestore() { + if (!dbRestoreRunBtn || !dbRestoreFileEl) return; + const file = dbRestoreFileEl.files && dbRestoreFileEl.files[0]; + if (!file) { + showResponse('warning', 'Choisis un fichier de sauvegarde (.ndjson.gz).'); + return; + } + + const drop = !!dbRestoreDropEl?.checked; + const warning = drop + ? 'RESTORE avec DROP: toutes les collections restaurées seront remplacées.' + : 'RESTORE sans DROP: risque élevé de doublons/erreurs si la DB contient déjà des données.'; + + const ok = window.confirm(`${warning}\n\nContinuer ?`); + if (!ok) return; + + dbRestoreRunBtn.disabled = true; + const old = dbRestoreRunBtn.textContent; + dbRestoreRunBtn.textContent = 'Restauration...'; + if (dbRestoreResultEl) dbRestoreResultEl.textContent = ''; + + try { + const fd = new FormData(); + fd.append('backup', file); + // drop passed as query (server supports both) + const res = await fetch(`/api/admin/db/restore?drop=${drop ? 'true' : 'false'}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: fd, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.message || data.error || `Erreur (${res.status})`); + } + + showResponse('success', 'Restore terminé.'); + if (dbRestoreResultEl) { + const inserted = data?.inserted && typeof data.inserted === 'object' ? data.inserted : {}; + const keys = Object.keys(inserted); + const summary = keys.length + ? keys.map((k) => `${k}: ${inserted[k]}`).join(' | ') + : 'Aucun document inséré (ou backup vide).'; + dbRestoreResultEl.textContent = summary; + } + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + dbRestoreRunBtn.disabled = false; + dbRestoreRunBtn.textContent = old; + } + } + + if (adminBackupTabBtn) { + adminBackupTabBtn.addEventListener('shown.bs.tab', () => loadBackups({ force: false })); + } + if (dbBackupRefreshBtn) { + dbBackupRefreshBtn.addEventListener('click', async () => { + dbBackupRefreshBtn.disabled = true; + const old = dbBackupRefreshBtn.textContent; + dbBackupRefreshBtn.textContent = 'Chargement...'; + try { + await loadBackups({ force: true }); + } finally { + dbBackupRefreshBtn.disabled = false; + dbBackupRefreshBtn.textContent = old; + } + }); + } + if (dbBackupCreateBtn) { + dbBackupCreateBtn.addEventListener('click', createBackup); + } + if (dbRestoreRunBtn) { + dbRestoreRunBtn.addEventListener('click', runRestore); + } + form.addEventListener('submit', async function (e) { e.preventDefault(); diff --git a/routes/adminDb.js b/routes/adminDb.js new file mode 100644 index 0000000..0a013fa --- /dev/null +++ b/routes/adminDb.js @@ -0,0 +1,375 @@ +const express = require("express"); +const jwt = require("jsonwebtoken"); +const mongoose = require("mongoose"); +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const readline = require("readline"); +const multer = require("multer"); + +// Use MongoDB Extended JSON to preserve ObjectId/Date/etc. +// bson is a transitive dependency of mongoose (mongodb driver). +let EJSON; +try { + ({ EJSON } = require("bson")); +} catch (_) { + EJSON = null; +} + +const router = express.Router(); + +const BACKUP_DIR = path.join(process.cwd(), "backups"); +const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB + +function requireAdmin(req) { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) { + const err = new Error("Authentication token is required"); + err.statusCode = 401; + throw err; + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (!decoded?.admin) { + const err = new Error("You do not have admin privileges"); + err.statusCode = 403; + throw err; + } + return decoded; + } catch (e) { + if (e instanceof jwt.JsonWebTokenError) { + const err = new Error("Invalid or expired token"); + err.statusCode = 401; + throw err; + } + throw e; + } +} + +function safeBackupFileName(raw) { + const name = String(raw || "").trim(); + // Only allow simple filenames to avoid traversal. + if (!/^[a-zA-Z0-9._-]+$/.test(name)) return null; + if (!name.endsWith(".ndjson.gz")) return null; + return name; +} + +async function ensureBackupDir() { + await fs.promises.mkdir(BACKUP_DIR, { recursive: true }); +} + +function nowStamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +function backupFileNameFromDb(dbName) { + const safeDb = String(dbName || "db").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 60) || "db"; + return `mongo-backup-${safeDb}-${nowStamp()}.ndjson.gz`; +} + +function requireDbReady() { + if (!mongoose?.connection || mongoose.connection.readyState !== 1) { + const err = new Error("MongoDB connection is not ready"); + err.statusCode = 503; + throw err; + } + if (!EJSON) { + const err = new Error("bson EJSON is not available in this environment"); + err.statusCode = 500; + throw err; + } +} + +const upload = multer({ + storage: multer.diskStorage({ + destination: async (req, file, cb) => { + try { + await ensureBackupDir(); + cb(null, BACKUP_DIR); + } catch (e) { + cb(e); + } + }, + filename: (req, file, cb) => { + // Keep uploads isolated; we rename during restore if needed. + const name = `upload-${nowStamp()}-${Math.random().toString(16).slice(2)}.ndjson.gz`; + cb(null, name); + }, + }), + limits: { fileSize: MAX_UPLOAD_BYTES }, +}); + +async function listBackupFiles() { + await ensureBackupDir(); + const entries = await fs.promises.readdir(BACKUP_DIR, { withFileTypes: true }); + const files = entries + .filter((e) => e.isFile()) + .map((e) => e.name) + .filter((n) => safeBackupFileName(n)) + .sort() + .reverse(); + + const out = []; + for (const name of files) { + const full = path.join(BACKUP_DIR, name); + try { + const stat = await fs.promises.stat(full); + out.push({ + name, + sizeBytes: stat.size, + mtime: stat.mtime.toISOString(), + }); + } catch (_) { + // ignore races + } + } + return out; +} + +// Admin: list existing backups (metadata only) +router.get("/db/backups", async (req, res) => { + try { + requireAdmin(req); + const backups = await listBackupFiles(); + return res.status(200).json({ backups }); + } catch (err) { + return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" }); + } +}); + +// Admin: create a new backup and store it on the server +router.post("/db/backup", async (req, res) => { + try { + requireAdmin(req); + requireDbReady(); + await ensureBackupDir(); + + const db = mongoose.connection.db; + const dbName = db?.databaseName || "db"; + const fileName = backupFileNameFromDb(dbName); + const fullPath = path.join(BACKUP_DIR, fileName); + + const gzip = zlib.createGzip({ level: 9 }); + const fileOut = fs.createWriteStream(fullPath, { flags: "wx" }); + gzip.pipe(fileOut); + + function writeLine(obj) { + const line = EJSON.stringify(obj) + "\n"; + return gzip.write(line); + } + + // Header + writeLine({ + type: "meta", + version: 1, + createdAt: new Date().toISOString(), + dbName, + format: "ndjson+gzip", + }); + + const collections = await db.listCollections().toArray(); + const userCollections = collections + .map((c) => c?.name) + .filter(Boolean) + .filter((name) => !String(name).startsWith("system.")); + + for (const name of userCollections) { + writeLine({ type: "collection", name }); + const cursor = db.collection(name).find({}); + // Stream docs one-by-one (no giant in-memory array) + for await (const doc of cursor) { + writeLine({ type: "doc", collection: name, doc }); + } + } + + writeLine({ type: "end" }); + gzip.end(); + + await new Promise((resolve, reject) => { + fileOut.on("finish", resolve); + fileOut.on("error", reject); + gzip.on("error", reject); + }); + + const stat = await fs.promises.stat(fullPath); + return res.status(201).json({ + message: "Backup created", + backup: { name: fileName, sizeBytes: stat.size, createdAt: new Date().toISOString() }, + }); + } catch (err) { + return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" }); + } +}); + +// Admin: download a specific backup +router.get("/db/backups/:name", async (req, res) => { + try { + requireAdmin(req); + const safe = safeBackupFileName(req.params?.name); + if (!safe) return res.status(400).json({ message: "Invalid backup name" }); + + const full = path.join(BACKUP_DIR, safe); + await fs.promises.access(full, fs.constants.R_OK); + + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Disposition", `attachment; filename="${safe}"`); + fs.createReadStream(full).pipe(res); + } catch (err) { + const code = err.code === "ENOENT" ? 404 : err.statusCode || 500; + return res.status(code).json({ message: err.message || "Internal server error" }); + } +}); + +// Admin: delete a backup file +router.delete("/db/backups/:name", async (req, res) => { + try { + requireAdmin(req); + const safe = safeBackupFileName(req.params?.name); + if (!safe) return res.status(400).json({ message: "Invalid backup name" }); + + const full = path.join(BACKUP_DIR, safe); + await fs.promises.unlink(full); + return res.status(200).json({ message: "Backup deleted" }); + } catch (err) { + const code = err.code === "ENOENT" ? 404 : err.statusCode || 500; + return res.status(code).json({ message: err.message || "Internal server error" }); + } +}); + +// Admin: restore from an uploaded backup. +// Query: ?drop=true to drop collections before insert (recommended). +router.post("/db/restore", upload.single("backup"), async (req, res) => { + let uploadedPath = null; + try { + requireAdmin(req); + requireDbReady(); + + const drop = String(req.query?.drop || req.body?.drop || "").toLowerCase() === "true"; + const file = req.file; + if (!file?.path) { + return res.status(400).json({ message: "No backup file uploaded" }); + } + uploadedPath = file.path; + + const db = mongoose.connection.db; + const gunzip = zlib.createGunzip(); + const input = fs.createReadStream(uploadedPath).pipe(gunzip); + + const rl = readline.createInterface({ input, crlfDelay: Infinity }); + + let meta = null; + let currentCollection = null; + let batch = []; + const dropped = new Set(); + const insertedCountByCollection = new Map(); + + async function flushBatch() { + if (!currentCollection || batch.length === 0) return; + const coll = db.collection(currentCollection); + try { + const result = await coll.insertMany(batch, { ordered: false }); + const inserted = result?.insertedCount || 0; + insertedCountByCollection.set( + currentCollection, + (insertedCountByCollection.get(currentCollection) || 0) + inserted + ); + } catch (e) { + // If restoring into an existing DB without dropping, duplicates are likely. + // Surface a clear error instead of half-success. + const err = new Error( + `Restore failed while inserting into "${currentCollection}": ${e?.message || "insertMany error"}` + ); + err.statusCode = 400; + throw err; + } finally { + batch = []; + } + } + + async function ensureDropped(name) { + if (!drop) return; + if (dropped.has(name)) return; + dropped.add(name); + try { + await db.collection(name).drop(); + } catch (e) { + // If collection doesn't exist, ignore. + if (e?.codeName !== "NamespaceNotFound" && e?.code !== 26) throw e; + } + } + + for await (const line of rl) { + const trimmed = String(line || "").trim(); + if (!trimmed) continue; + + let obj; + try { + obj = EJSON.parse(trimmed); + } catch (_) { + const err = new Error("Invalid backup format (cannot parse JSON line)"); + err.statusCode = 400; + throw err; + } + + if (obj?.type === "meta") { + meta = obj; + continue; + } + + if (obj?.type === "collection") { + await flushBatch(); + currentCollection = String(obj?.name || "").trim(); + if (!currentCollection || currentCollection.startsWith("system.")) { + currentCollection = null; + continue; + } + await ensureDropped(currentCollection); + continue; + } + + if (obj?.type === "doc") { + const coll = String(obj?.collection || "").trim(); + if (!coll || coll.startsWith("system.")) continue; + if (currentCollection !== coll) { + await flushBatch(); + currentCollection = coll; + await ensureDropped(currentCollection); + } + if (obj?.doc && typeof obj.doc === "object") { + batch.push(obj.doc); + if (batch.length >= 500) { + await flushBatch(); + } + } + continue; + } + } + + await flushBatch(); + + // Cleanup upload after successful restore + try { + await fs.promises.unlink(uploadedPath); + uploadedPath = null; + } catch (_) {} + + return res.status(200).json({ + message: "Restore completed", + meta, + drop, + inserted: Object.fromEntries(insertedCountByCollection.entries()), + }); + } catch (err) { + // Best-effort cleanup + if (uploadedPath) { + try { + await fs.promises.unlink(uploadedPath); + } catch (_) {} + } + return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" }); + } +}); + +module.exports = router; +