383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
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
|
|
};
|