add video backend server, fix styling and more #54
@@ -21,12 +21,14 @@ const userRoutes = require("./routes/users");
|
|||||||
const movieRoutes = require("./routes/movies");
|
const movieRoutes = require("./routes/movies");
|
||||||
const versionRoutes = require("./routes/version");
|
const versionRoutes = require("./routes/version");
|
||||||
const settingsRoutes = require("./routes/settings");
|
const settingsRoutes = require("./routes/settings");
|
||||||
|
const adminDbRoutes = require("./routes/adminDb");
|
||||||
|
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
app.use("/api/users", userRoutes);
|
app.use("/api/users", userRoutes);
|
||||||
app.use("/api/movies", movieRoutes);
|
app.use("/api/movies", movieRoutes);
|
||||||
app.use("/api/version", versionRoutes);
|
app.use("/api/version", versionRoutes);
|
||||||
app.use("/api/settings", settingsRoutes);
|
app.use("/api/settings", settingsRoutes);
|
||||||
|
app.use("/api/admin", adminDbRoutes);
|
||||||
|
|
||||||
// Start Server
|
// Start Server
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|||||||
Generated
+131
-1
@@ -17,7 +17,8 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
"mongoose": "^8.9.5"
|
"mongoose": "^8.9.5",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.9"
|
"nodemon": "^3.1.9"
|
||||||
@@ -74,6 +75,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -186,6 +193,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -268,6 +292,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -997,6 +1036,27 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/moment": {
|
||||||
"version": "2.30.1",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
@@ -1152,6 +1212,24 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1360,6 +1438,20 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -1580,6 +1672,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1650,6 +1759,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -1666,6 +1781,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -1705,6 +1826,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,8 @@
|
|||||||
"express": "^4.22.1",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
"mongoose": "^8.9.5"
|
"mongoose": "^8.9.5",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.9"
|
"nodemon": "^3.1.9"
|
||||||
|
|||||||
@@ -80,6 +80,11 @@
|
|||||||
Films
|
Films
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
|
||||||
|
Backup / Restore
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content pt-3">
|
<div class="tab-content pt-3">
|
||||||
@@ -317,6 +322,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="admin-backup" role="tabpanel" aria-labelledby="admin-backup-tab">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-xxl-6">
|
||||||
|
<div class="card p-3">
|
||||||
|
<h4 class="mb-1">Backup</h4>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Crée une sauvegarde de la base MongoDB (format <code>.ndjson.gz</code>) et conserve-la sur le serveur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button type="button" id="db-backup-create" class="btn btn-warning">Créer une sauvegarde</button>
|
||||||
|
<button type="button" id="db-backup-refresh" class="btn btn-outline-primary">Rafraîchir la liste</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive mt-3">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fichier</th>
|
||||||
|
<th class="text-nowrap" style="width: 1%;">Taille</th>
|
||||||
|
<th class="text-nowrap" style="width: 1%;">Modifié</th>
|
||||||
|
<th class="text-nowrap text-end" style="width: 1%;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="db-backup-list">
|
||||||
|
<tr><td colspan="4" class="text-muted">Ouvre l’onglet pour charger la liste…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xxl-6">
|
||||||
|
<div class="card p-3">
|
||||||
|
<h4 class="mb-1 text-danger">Restore</h4>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Restaure une sauvegarde (fichier <code>.ndjson.gz</code>). Option recommandée: <strong>drop</strong> pour supprimer les collections avant import.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
La restauration peut <strong>écraser</strong> les données. Fais un backup juste avant si besoin.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="db-restore-file" class="form-label">Fichier de backup</label>
|
||||||
|
<input class="form-control" type="file" id="db-restore-file" accept=".gz,.ndjson.gz,application/gzip">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="db-restore-drop" checked>
|
||||||
|
<label class="form-check-label" for="db-restore-drop">
|
||||||
|
Drop les collections avant restore (recommandé)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||||
|
<button type="button" id="db-restore-run" class="btn btn-danger">Restaurer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="db-restore-result" class="small text-muted mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,18 @@ window.onload = async function () {
|
|||||||
const adminUsersCountEl = document.getElementById('admin-users-count');
|
const adminUsersCountEl = document.getElementById('admin-users-count');
|
||||||
const adminUsersTabBtn = document.getElementById('admin-users-tab');
|
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
|
// Admin: add user form elements
|
||||||
const openAddUserModalBtn = document.getElementById('open-add-user-modal');
|
const openAddUserModalBtn = document.getElementById('open-add-user-modal');
|
||||||
const addUserModalEl = document.getElementById('addUserModal');
|
const addUserModalEl = document.getElementById('addUserModal');
|
||||||
@@ -755,6 +767,240 @@ window.onload = async function () {
|
|||||||
adminUsersTabBtn.addEventListener('shown.bs.tab', () => loadAdminUsers({ force: false }));
|
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 = '<tr><td colspan="4" class="text-muted">Aucune sauvegarde.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbBackupListBody.innerHTML = '';
|
||||||
|
list.forEach((b) => {
|
||||||
|
const name = String(b?.name || '');
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="font-monospace">${name || '—'}</td>
|
||||||
|
<td class="text-nowrap">${formatBytes(b?.sizeBytes)}</td>
|
||||||
|
<td class="text-nowrap">${formatDateTime(b?.mtime)}</td>
|
||||||
|
<td class="text-end text-nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-backup-download="${name}">Télécharger</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" data-backup-delete="${name}">Supprimer</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
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 = '<tr><td colspan="4" class="text-muted">Chargement…</td></tr>';
|
||||||
|
try {
|
||||||
|
const backups = await fetchBackups();
|
||||||
|
backupsLoadedOnce = true;
|
||||||
|
renderBackups(backups);
|
||||||
|
} catch (err) {
|
||||||
|
dbBackupListBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
form.addEventListener('submit', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user