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.
+
+
+
+
+
+
+
+
+
+
+
+ | Fichier |
+ Taille |
+ Modifié |
+ 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;
+