* feat: Add modal to display watched movies with details Co-authored-by: mathieumoran1 <[email protected]> * Refactor movie modal: remove search, improve category display Co-authored-by: mathieumoran1 <[email protected]> * Refactor movie category display to use details element Co-authored-by: mathieumoran1 <[email protected]> * Refactor movie playback and add clickability to stats Co-authored-by: mathieumoran1 <[email protected]> * Refactor: Improve movie linking and player logic Co-authored-by: mathieumoran1 <[email protected]> * Prevent caching of sensitive and dynamic API data Co-authored-by: mathieumoran1 <[email protected]> * feat: Prevent caching of sensitive admin data Co-authored-by: mathieumoran1 <[email protected]> * feat: Add no-cache headers to API requests Co-authored-by: mathieumoran1 <[email protected]> * Remove unused movie sorting functionality The movie sorting dropdown and its associated logic were removed as they are no longer needed. Co-authored-by: mathieumoran1 <[email protected]> * Refactor admin settings to use tabs instead of accordion Co-authored-by: mathieumoran1 <[email protected]> * Refactor: Adjust button layout for active year selection Co-authored-by: mathieumoran1 <[email protected]> * Refactor active year selection UI Co-authored-by: mathieumoran1 <[email protected]> * Refactor: Improve admin settings layout for better responsiveness Co-authored-by: mathieumoran1 <[email protected]> * Remove redundant completion explanation from user-stat.html Co-authored-by: mathieumoran1 <[email protected]> * Refactor user stats page to display year in ranking tab badge Co-authored-by: mathieumoran1 <[email protected]> * Refactor: Rename 'Finisseur(s)' column to 'Nombre' Co-authored-by: mathieumoran1 <[email protected]> * Refactor: Remove redundant completer count column Co-authored-by: mathieumoran1 <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]>
421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
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");
|
|
const { Transform } = require("stream");
|
|
|
|
// 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
|
|
// SECURITY: protect against gzip "zip bombs" (small upload -> massive output).
|
|
// This caps the *decompressed* stream size during restore.
|
|
const MAX_RESTORE_UNZIPPED_BYTES = 1024 * 1024 * 1024; // 1GB
|
|
|
|
function createByteLimitStream(maxBytes) {
|
|
let seen = 0;
|
|
return new Transform({
|
|
transform(chunk, _enc, cb) {
|
|
seen += chunk.length;
|
|
if (seen > maxBytes) {
|
|
const err = new Error("Restore failed: decompressed data is too large");
|
|
// mimic our handler's error shape
|
|
err.statusCode = 413;
|
|
cb(err);
|
|
return;
|
|
}
|
|
cb(null, chunk);
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
// Admin + auth-scoped data: never allow caching.
|
|
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
|
res.setHeader("Vary", "Authorization");
|
|
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);
|
|
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
|
res.setHeader("Vary", "Authorization");
|
|
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;
|
|
// Best-effort: refuse obviously wrong file names (UI expects .ndjson.gz).
|
|
const original = String(file.originalname || "").trim().toLowerCase();
|
|
if (original && !original.endsWith(".gz")) {
|
|
const err = new Error("Invalid backup file (expected a .gz file)");
|
|
err.statusCode = 400;
|
|
throw err;
|
|
}
|
|
|
|
const db = mongoose.connection.db;
|
|
const gunzip = zlib.createGunzip();
|
|
const byteLimit = createByteLimitStream(MAX_RESTORE_UNZIPPED_BYTES);
|
|
const input = fs.createReadStream(uploadedPath).pipe(gunzip).pipe(byteLimit);
|
|
|
|
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
// Ensure stream errors don't become unhandled.
|
|
let streamError = null;
|
|
input.on("error", (e) => {
|
|
streamError = e;
|
|
try {
|
|
rl.close();
|
|
} catch (_) {}
|
|
});
|
|
|
|
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();
|
|
if (streamError) {
|
|
throw streamError;
|
|
}
|
|
|
|
// 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;
|
|
|