const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); let multer = null; try { multer = require("multer"); } catch { multer = null; } const { INCOMING_DIR, deleteResource, formatBytes, getResource, getResourceByExposedToken, getStorageStats, importUploadedFiles, listResources, preflightIncomingRequest, resourceFilePath, serializeResource, setResourceAccess, supportedExtensions, supportedFormats, updateResourceName, updateStorageSettings, uploadLimits, verifySignedResource } = require("./content-library"); function registerPublicContentRoutes(app) { app.get("/media/:token/:filename", (req, res) => { const resource = getResourceByExposedToken(req.params.token); if (!resource) return res.status(404).send("Not found."); return serveResource(req, res, resource, { cache_control: "public, max-age=0, must-revalidate", cors: true, disposition: req.query.download === "1" ? "attachment" : "inline" }); }); app.get("/internal/media/:id/:filename", (req, res) => { const resource = verifySignedResource(req.params.id, req.query.expires, req.query.sig); if (!resource) return res.status(404).send("Not found."); return serveResource(req, res, resource, { cache_control: "private, no-store", cors: true, disposition: req.query.download === "1" ? "attachment" : "inline" }); }); } function registerContentAdminRoutes(app, options = {}) { const requireAdmin = options.requireAdmin; if (typeof requireAdmin !== "function") { throw new Error("Content-library admin routes require an admin authorization middleware."); } const uploadMany = buildUploadMiddleware(); app.get("/admin/resources", requireAdmin, (req, res) => { const baseUrl = requestBaseUrl(req); const resources = listResources().map((resource) => serializeResource(resource, { base_url: baseUrl })); const storage = getStorageStats(); res.render("admin-resources", { title: "Resources", resources, storage, settings: storageSettingsForView(storage), formatGroups: groupFormats(supportedFormats()), formatBytes }); }); app.get("/api/admin/resources", requireAdmin, (req, res) => { const baseUrl = requestBaseUrl(req); res.set("Cache-Control", "no-store"); res.json({ ok: true, resources: listResources(req.query).map((resource) => serializeResource(resource, { base_url: baseUrl })), storage: getStorageStats() }); }); app.get("/admin/resources/:id/raw/:filename", requireAdmin, (req, res) => { const resource = getResource(req.params.id); if (!resource) return res.status(404).send("Not found."); return serveResource(req, res, resource, { cache_control: "private, no-store", cors: false, disposition: req.query.download === "1" ? "attachment" : "inline" }); }); app.post( "/admin/resources/upload", requireAdmin, guardUploadCapacity, uploadMany, async (req, res) => { try { if (req.contentUploadError) throw req.contentUploadError; const imported = await importUploadedFiles(req.files, { access_level: req.body.access_level, uploaded_by: req.session.user?.id || null }); const baseUrl = requestBaseUrl(req); return mutationSuccess(req, res, { message: `${imported.length} resource${imported.length === 1 ? "" : "s"} uploaded.`, resources: imported.map((resource) => serializeResource(resource, { base_url: baseUrl })), storage: getStorageStats() }, "/admin/resources"); } catch (error) { cleanupRequestFiles(req.files); return mutationError(req, res, error, "/admin/resources"); } } ); app.post("/admin/resources/settings", requireAdmin, (req, res) => { try { const storage = updateStorageSettings(req.body); return mutationSuccess(req, res, { message: "Resource storage settings saved.", storage, settings: storageSettingsForView(storage) }, "/admin/resources"); } catch (error) { return mutationError(req, res, error, "/admin/resources"); } }); app.post("/admin/resources/:id/rename", requireAdmin, (req, res) => { try { const resource = updateResourceName(req.params.id, req.body.display_name); return mutationSuccess(req, res, { message: "Resource name updated.", resource: serializeResource(resource, { base_url: requestBaseUrl(req) }) }, "/admin/resources"); } catch (error) { return mutationError(req, res, error, "/admin/resources"); } }); app.post("/admin/resources/:id/access", requireAdmin, (req, res) => { try { const resource = setResourceAccess(req.params.id, "exposed"); return mutationSuccess(req, res, { message: "Resource exposed with a new read-only URL.", resource: serializeResource(resource, { base_url: requestBaseUrl(req) }) }, "/admin/resources"); } catch (error) { return mutationError(req, res, error, "/admin/resources"); } }); app.post("/admin/resources/:id/revoke", requireAdmin, (req, res) => { try { const resource = setResourceAccess(req.params.id, "locked"); return mutationSuccess(req, res, { message: "Resource locked. Its previous exposed URL no longer works.", resource: serializeResource(resource, { base_url: requestBaseUrl(req) }) }, "/admin/resources"); } catch (error) { return mutationError(req, res, error, "/admin/resources"); } }); app.post("/admin/resources/:id/delete", requireAdmin, (req, res) => { try { const deleted = deleteResource(req.params.id); return mutationSuccess(req, res, { message: `${deleted.display_name} deleted.`, deleted_id: deleted.id, storage: getStorageStats() }, "/admin/resources"); } catch (error) { return mutationError(req, res, error, "/admin/resources"); } }); } function buildUploadMiddleware() { if (!multer) { return (req, _res, next) => { req.contentUploadError = withStatus(new Error("File uploads require the multer dependency. Run npm install, then restart Lumi."), 503); next(); }; } const storage = multer.diskStorage({ destination: (_req, _file, callback) => callback(null, INCOMING_DIR), filename: (_req, _file, callback) => callback(null, `${crypto.randomUUID()}.upload`) }); return (req, res, next) => { const configured = uploadLimits(); const uploader = multer({ storage, limits: { fileSize: configured.max_file_bytes, files: configured.max_files, fields: 10, parts: configured.max_files + 12 }, fileFilter: (_req, file, callback) => { const extension = path.extname(file.originalname || "").toLowerCase(); if (supportedExtensions().includes(extension)) return callback(null, true); return callback(new Error(`${extension || "That file type"} is not supported by Lumi's media library.`)); } }); uploader.array("resources", configured.max_files)(req, res, (error) => { if (!error) return next(); cleanupRequestFiles(req.files); const message = error.code === "LIMIT_FILE_SIZE" ? `A file exceeded the configured per-file limit of ${formatBytes(configured.max_file_bytes)}.` : error.code === "LIMIT_FILE_COUNT" ? `Upload no more than ${configured.max_files} files at once.` : error.message; req.contentUploadError = withStatus(new Error(message), error.code?.startsWith("LIMIT_") ? 413 : 400); next(); }); }; } function guardUploadCapacity(req, res, next) { const result = preflightIncomingRequest(req.get("content-length")); if (result.ok) return next(); return mutationError(req, res, withStatus(new Error(result.reason), result.status || 507), "/admin/resources"); } function serveResource(req, res, resource, options = {}) { let filePath; let stat; try { filePath = resourceFilePath(resource); stat = fs.statSync(filePath); } catch { return res.status(404).send("Not found."); } const etag = `"sha256-${resource.checksum_sha256}"`; res.set({ "Accept-Ranges": "bytes", "Cache-Control": options.cache_control || "private, no-store", "Content-Type": resource.mime, "Content-Disposition": contentDisposition(options.disposition || "inline", resource.original_name), "ETag": etag, "X-Content-Type-Options": "nosniff", "Cross-Origin-Resource-Policy": options.cors ? "cross-origin" : "same-origin" }); if (options.cors) res.set("Access-Control-Allow-Origin", "*"); if (String(resource.mime || "").startsWith("image/svg+xml")) { res.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; sandbox"); } if (!req.headers.range && req.headers["if-none-match"] === etag) { return res.status(304).end(); } const range = parseByteRange(req.headers.range, stat.size); if (range?.invalid) { res.set("Content-Range", `bytes */${stat.size}`); return res.status(416).end(); } if (range) { const length = range.end - range.start + 1; res.status(206); res.set({ "Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`, "Content-Length": String(length) }); if (req.method === "HEAD") return res.end(); return pipeFile(res, filePath, { start: range.start, end: range.end }); } res.set("Content-Length", String(stat.size)); if (req.method === "HEAD") return res.end(); return pipeFile(res, filePath); } function parseByteRange(header, size) { if (!header) return null; const match = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim()); if (!match) return { invalid: true }; let start; let end; if (!match[1]) { const suffixLength = Number(match[2]); if (!Number.isFinite(suffixLength) || suffixLength <= 0) return { invalid: true }; start = Math.max(0, size - suffixLength); end = size - 1; } else { start = Number(match[1]); end = match[2] ? Number(match[2]) : size - 1; } if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) { return { invalid: true }; } return { start, end: Math.min(end, size - 1) }; } function pipeFile(res, filePath, options = undefined) { const stream = fs.createReadStream(filePath, options); stream.on("error", (error) => { if (!res.headersSent) res.status(500).send("Unable to read resource."); else res.destroy(error); }); stream.pipe(res); return stream; } function contentDisposition(type, filename) { const safeAscii = String(filename || "resource") .replace(/[^\x20-\x7e]/g, "_") .replace(/["\\]/g, "_"); return `${type}; filename="${safeAscii}"; filename*=UTF-8''${encodeURIComponent(filename || "resource")}`; } function requestBaseUrl(req) { return `${req.protocol}://${req.get("host")}`; } function groupFormats(formats) { const groups = {}; formats.forEach((format) => { if (!groups[format.category]) groups[format.category] = []; groups[format.category].push(format.extension); }); return Object.entries(groups) .map(([category, extensions]) => ({ category, extensions: extensions.sort() })) .sort((left, right) => left.category.localeCompare(right.category)); } function storageSettingsForView(storage) { return { limit_gib: storage.limit_bytes > 0 ? round(storage.limit_bytes / (1024 ** 3), 2) : 0, reserve_gib: round(storage.reserve_bytes / (1024 ** 3), 2), max_file_mib: Math.round(storage.max_file_bytes / (1024 ** 2)), max_files: storage.max_files }; } function mutationSuccess(req, res, payload, fallbackPath) { if (wantsJson(req)) return res.json({ ok: true, ...payload }); req.session.flash = { type: "success", message: payload.message || "Saved." }; return res.redirect(fallbackPath); } function mutationError(req, res, error, fallbackPath) { const status = Number(error?.status) || 400; const message = error?.message || "Unable to update the resource library."; if (wantsJson(req)) return res.status(status).json({ ok: false, error: message }); req.session.flash = { type: "error", message }; return res.redirect(fallbackPath); } function wantsJson(req) { return req.xhr || String(req.get("accept") || "").toLowerCase().includes("application/json"); } function cleanupRequestFiles(files) { for (const file of Array.isArray(files) ? files : []) { if (!file?.path) continue; try { fs.rmSync(file.path, { force: true }); } catch {} } } function withStatus(error, status) { error.status = status; return error; } function round(value, places) { const factor = 10 ** places; return Math.round(Number(value) * factor) / factor; } module.exports = { parseByteRange, registerContentAdminRoutes, registerPublicContentRoutes, serveResource };