Lumi/src/services/content-library.js
2026-07-22 10:20:32 +02:00

893 lines
34 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { TextDecoder } = require("util");
const { db } = require("./db");
const { getSetting, setSetting } = require("./settings");
const { safeDownloadFilename } = require("./upload-security");
const DATA_DIR = path.join(__dirname, "..", "..", "data", "content-library");
const FILES_DIR = path.join(DATA_DIR, "files");
const INCOMING_DIR = path.join(DATA_DIR, ".incoming");
const TRASH_DIR = path.join(DATA_DIR, ".trash");
const ACCESS_LEVELS = Object.freeze(["locked", "exposed"]);
const DEFAULT_STORAGE_RESERVE_BYTES = 512 * 1024 * 1024;
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
const HARD_MAX_FILE_BYTES = 8 * 1024 * 1024 * 1024;
const DEFAULT_UPLOAD_MAX_FILES = 20;
const HARD_UPLOAD_MAX_FILES = 50;
const SIGNED_URL_MAX_TTL_SECONDS = 24 * 60 * 60;
const TEXT_RESOURCE_MAX_BYTES = 64 * 1024 * 1024;
let initialized = false;
const FORMAT_DEFINITIONS = Object.freeze({
".png": media("image/png", "image", "image", matchPng),
".apng": media("image/apng", "image", "image", matchPng),
".jpg": media("image/jpeg", "image", "image", matchJpeg),
".jpeg": media("image/jpeg", "image", "image", matchJpeg),
".jfif": media("image/jpeg", "image", "image", matchJpeg),
".webp": media("image/webp", "image", "image", matchWebp),
".gif": media("image/gif", "image", "image", matchGif),
".avif": media("image/avif", "image", "image", (buffer) => matchIsoBrand(buffer, ["avif", "avis"])),
".heic": media("image/heic", "image", "image", (buffer) => matchIsoBrand(buffer, ["heic", "heix", "hevc", "hevx", "mif1", "msf1"])),
".heif": media("image/heif", "image", "image", (buffer) => matchIsoBrand(buffer, ["heif", "heim", "heis", "mif1", "msf1"])),
".bmp": media("image/bmp", "image", "image", (buffer) => buffer.subarray(0, 2).toString("ascii") === "BM"),
".tif": media("image/tiff", "image", "image", matchTiff),
".tiff": media("image/tiff", "image", "image", matchTiff),
".ico": media("image/x-icon", "image", "image", (buffer) => startsWith(buffer, [0x00, 0x00, 0x01, 0x00])),
".psd": media("image/vnd.adobe.photoshop", "image", "none", (buffer) => buffer.subarray(0, 4).toString("ascii") === "8BPS"),
".svg": media("image/svg+xml; charset=utf-8", "image", "image", matchSafeSvg, true),
".mp3": media("audio/mpeg", "audio", "audio", matchMp3),
".wav": media("audio/wav", "audio", "audio", (buffer) => matchRiff(buffer, "WAVE")),
".wave": media("audio/wav", "audio", "audio", (buffer) => matchRiff(buffer, "WAVE")),
".ogg": media("audio/ogg", "audio", "audio", matchOgg),
".oga": media("audio/ogg", "audio", "audio", matchOgg),
".opus": media("audio/ogg", "audio", "audio", matchOgg),
".flac": media("audio/flac", "audio", "audio", (buffer) => buffer.subarray(0, 4).toString("ascii") === "fLaC"),
".m4a": media("audio/mp4", "audio", "audio", matchIsoMedia),
".aac": media("audio/aac", "audio", "audio", matchAac),
".weba": media("audio/webm", "audio", "audio", matchEbml),
".mka": media("audio/x-matroska", "audio", "audio", matchEbml),
".wma": media("audio/x-ms-wma", "audio", "audio", matchAsf),
".caf": media("audio/x-caf", "audio", "audio", (buffer) => buffer.subarray(0, 4).toString("ascii") === "caff"),
".aiff": media("audio/aiff", "audio", "audio", (buffer) => matchForm(buffer, ["AIFF", "AIFC"])),
".aif": media("audio/aiff", "audio", "audio", (buffer) => matchForm(buffer, ["AIFF", "AIFC"])),
".mp4": media("video/mp4", "video", "video", matchIsoMedia),
".m4v": media("video/x-m4v", "video", "video", matchIsoMedia),
".mov": media("video/quicktime", "video", "video", matchIsoMedia),
".webm": media("video/webm", "video", "video", matchEbml),
".ogv": media("video/ogg", "video", "video", matchOgg),
".mkv": media("video/x-matroska", "video", "video", matchEbml),
".avi": media("video/x-msvideo", "video", "video", (buffer) => matchRiff(buffer, "AVI ")),
".mpeg": media("video/mpeg", "video", "video", matchMpegVideo),
".mpg": media("video/mpeg", "video", "video", matchMpegVideo),
".ts": media("video/mp2t", "video", "video", matchTransportStream),
".mts": media("video/mp2t", "video", "video", matchTransportStream),
".m2ts": media("video/mp2t", "video", "video", matchTransportStream),
".3gp": media("video/3gpp", "video", "video", matchIsoMedia),
".flv": media("video/x-flv", "video", "video", (buffer) => buffer.subarray(0, 3).toString("ascii") === "FLV"),
".wmv": media("video/x-ms-wmv", "video", "video", matchAsf),
".mxf": media("application/mxf", "video", "video", (buffer) => startsWith(buffer, [0x06, 0x0e, 0x2b, 0x34])),
".vtt": media("text/vtt; charset=utf-8", "caption", "text", matchWebVtt, true),
".webvtt": media("text/vtt; charset=utf-8", "caption", "text", matchWebVtt, true),
".srt": media("application/x-subrip; charset=utf-8", "caption", "text", matchSubRip, true),
".ass": media("text/x-ssa; charset=utf-8", "caption", "text", matchSubStationAlpha, true),
".ssa": media("text/x-ssa; charset=utf-8", "caption", "text", matchSubStationAlpha, true),
".json": media("application/json; charset=utf-8", "data", "text", matchJson, true),
".lottie": media("application/json; charset=utf-8", "data", "text", matchJson, true),
".pdf": media("application/pdf", "document", "pdf", (buffer) => buffer.subarray(0, 5).toString("ascii") === "%PDF-"),
".woff": media("font/woff", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "wOFF"),
".woff2": media("font/woff2", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "wOF2"),
".ttf": media("font/ttf", "font", "font", matchTrueType),
".ttc": media("font/collection", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "ttcf"),
".otf": media("font/otf", "font", "font", (buffer) => buffer.subarray(0, 4).toString("ascii") === "OTTO")
});
function media(mime, category, previewKind, matches, text = false) {
return Object.freeze({ mime, category, previewKind, matches, text });
}
function ensureContentLibrary() {
if (initialized) return;
fs.mkdirSync(FILES_DIR, { recursive: true });
fs.mkdirSync(INCOMING_DIR, { recursive: true });
fs.mkdirSync(TRASH_DIR, { recursive: true });
ensureSettingDefault("content_storage_limit_bytes", 0);
ensureSettingDefault("content_storage_reserve_bytes", DEFAULT_STORAGE_RESERVE_BYTES);
ensureSettingDefault("content_max_file_bytes", DEFAULT_MAX_FILE_BYTES);
ensureSettingDefault("content_upload_max_files", DEFAULT_UPLOAD_MAX_FILES);
cleanupStaleWorkingFiles();
initialized = true;
}
function ensureSettingDefault(key, value) {
if (getSetting(key, null) === null) setSetting(key, value);
}
function cleanupStaleWorkingFiles() {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const directory of [INCOMING_DIR, TRASH_DIR]) {
let entries = [];
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
const target = path.join(directory, entry.name);
try {
if (fs.statSync(target).mtimeMs < cutoff) fs.rmSync(target, { force: true });
} catch {}
}
}
}
function supportedFormats() {
return Object.entries(FORMAT_DEFINITIONS).map(([extension, definition]) => ({
extension,
mime: definition.mime,
category: definition.category,
preview_kind: definition.previewKind
}));
}
function supportedExtensions() {
return Object.keys(FORMAT_DEFINITIONS);
}
function uploadLimits() {
return {
max_file_bytes: clampInteger(
getSetting("content_max_file_bytes", DEFAULT_MAX_FILE_BYTES),
1 * 1024 * 1024,
HARD_MAX_FILE_BYTES,
DEFAULT_MAX_FILE_BYTES
),
max_files: clampInteger(
getSetting("content_upload_max_files", DEFAULT_UPLOAD_MAX_FILES),
1,
HARD_UPLOAD_MAX_FILES,
DEFAULT_UPLOAD_MAX_FILES
)
};
}
function getStorageStats() {
ensureContentLibrary();
const usedRow = db.prepare("SELECT COALESCE(SUM(size), 0) AS used FROM content_resources").get();
const usedBytes = nonNegativeNumber(usedRow?.used);
const limitBytes = nonNegativeNumber(getSetting("content_storage_limit_bytes", 0));
const reserveBytes = nonNegativeNumber(
getSetting("content_storage_reserve_bytes", DEFAULT_STORAGE_RESERVE_BYTES)
);
const disk = diskStats(DATA_DIR);
const diskUsableBytes = disk.available
? Math.max(0, disk.available_bytes - reserveBytes)
: Number.MAX_SAFE_INTEGER;
const quotaRemainingBytes = limitBytes > 0
? Math.max(0, limitBytes - usedBytes)
: Number.MAX_SAFE_INTEGER;
const effectiveAvailableBytes = Math.min(diskUsableBytes, quotaRemainingBytes);
return {
used_bytes: usedBytes,
file_count: Number(db.prepare("SELECT COUNT(*) AS count FROM content_resources").get()?.count || 0),
limit_bytes: limitBytes,
quota_enabled: limitBytes > 0,
quota_remaining_bytes: limitBytes > 0 ? quotaRemainingBytes : null,
reserve_bytes: reserveBytes,
disk_available: disk.available,
disk_free_bytes: disk.available ? disk.available_bytes : null,
disk_total_bytes: disk.available ? disk.total_bytes : null,
disk_usable_bytes: disk.available ? diskUsableBytes : null,
effective_available_bytes: Number.isFinite(effectiveAvailableBytes)
? effectiveAvailableBytes
: null,
usage_percent: limitBytes > 0
? percent(usedBytes, limitBytes)
: disk.available
? percent(disk.total_bytes - disk.available_bytes, disk.total_bytes)
: 0,
quota_usage_percent: limitBytes > 0 ? percent(usedBytes, limitBytes) : null,
disk_usage_percent: disk.available
? percent(disk.total_bytes - disk.available_bytes, disk.total_bytes)
: null,
...uploadLimits()
};
}
function diskStats(target) {
try {
if (typeof fs.statfsSync !== "function") return { available: false };
const stat = fs.statfsSync(target);
return {
available: true,
available_bytes: Number(stat.bavail) * Number(stat.bsize),
total_bytes: Number(stat.blocks) * Number(stat.bsize)
};
} catch {
return { available: false };
}
}
function preflightIncomingRequest(contentLength) {
const bytes = nonNegativeNumber(contentLength);
if (!bytes) return { ok: true };
const stats = getStorageStats();
if (stats.effective_available_bytes !== null && bytes > stats.effective_available_bytes) {
return {
ok: false,
status: 507,
reason: "The upload is larger than the space currently available to Lumi."
};
}
return { ok: true };
}
async function importUploadedFiles(files, options = {}) {
ensureContentLibrary();
const list = Array.isArray(files) ? files.filter(Boolean) : [];
const limits = uploadLimits();
if (!list.length) throw new Error("Choose at least one file to upload.");
if (list.length > limits.max_files) {
throw new Error(`Upload no more than ${limits.max_files} files at once.`);
}
const accessLevel = normalizeAccessLevel(options.access_level);
const prepared = [];
try {
for (const file of list) {
prepared.push(await inspectUpload(file, limits.max_file_bytes));
}
ensureImportCapacity(prepared.reduce((sum, item) => sum + item.size, 0));
const moved = [];
try {
for (const item of prepared) {
const id = crypto.randomUUID();
const storedName = `${id}${item.extension}`;
const destination = safeStoragePath(storedName);
moveFile(item.temp_path, destination);
moved.push(destination);
const token = accessLevel === "exposed" ? createStoredToken() : null;
item.row = {
id,
display_name: displayNameFromFilename(item.original_name),
original_name: item.original_name,
stored_name: storedName,
mime: item.mime,
extension: item.extension,
category: item.category,
preview_kind: item.preview_kind,
size: item.size,
checksum_sha256: item.checksum_sha256,
access_level: accessLevel,
public_token_hash: token?.hash || null,
public_token_encrypted: token?.encrypted || null,
uploaded_by: options.uploaded_by || null,
created_at: Date.now(),
updated_at: Date.now()
};
}
const insert = db.prepare(`
INSERT INTO content_resources (
id, display_name, original_name, stored_name, mime, extension,
category, preview_kind, size, checksum_sha256, access_level,
public_token_hash, public_token_encrypted, uploaded_by, created_at, updated_at
) VALUES (
@id, @display_name, @original_name, @stored_name, @mime, @extension,
@category, @preview_kind, @size, @checksum_sha256, @access_level,
@public_token_hash, @public_token_encrypted, @uploaded_by, @created_at, @updated_at
)
`);
const transaction = db.transaction((items) => {
items.forEach((item) => insert.run(item.row));
});
transaction(prepared);
return prepared.map((item) => hydrateResource(item.row));
} catch (error) {
moved.forEach((target) => {
try { fs.rmSync(target, { force: true }); } catch {}
});
throw error;
}
} finally {
list.forEach((file) => {
if (!file?.path) return;
try { fs.rmSync(file.path, { force: true }); } catch {}
});
}
}
async function inspectUpload(file, maxFileBytes) {
if (!file?.path) throw new Error("One of the uploaded files could not be read.");
const originalName = safeDownloadFilename(file.originalname, "resource");
const extension = path.extname(originalName).toLowerCase();
const definition = FORMAT_DEFINITIONS[extension];
if (!definition) {
throw new Error(`${extension || "That file type"} is not supported by Lumi's media library.`);
}
const stat = fs.statSync(file.path);
if (!stat.isFile() || stat.size < 1) throw new Error(`${originalName} is empty.`);
if (stat.size > maxFileBytes) {
throw new Error(`${originalName} is larger than the configured per-file limit (${formatBytes(maxFileBytes)}).`);
}
if (definition.text && stat.size > TEXT_RESOURCE_MAX_BYTES) {
throw new Error(`${originalName} is too large for a text-based resource (${formatBytes(TEXT_RESOURCE_MAX_BYTES)} maximum).`);
}
const sample = definition.text ? fs.readFileSync(file.path) : readPrefix(file.path, 128 * 1024);
if (!definition.matches(sample)) {
throw new Error(`${originalName} does not match its filename extension or is not a valid supported media file.`);
}
return {
temp_path: file.path,
original_name: originalName,
extension,
mime: definition.mime,
category: definition.category,
preview_kind: definition.previewKind,
size: stat.size,
checksum_sha256: await hashFile(file.path)
};
}
function ensureImportCapacity(bytes) {
const stats = getStorageStats();
if (stats.quota_enabled && bytes > stats.quota_remaining_bytes) {
throw new Error("Uploading these files would exceed Lumi's configured content-library limit.");
}
if (stats.disk_available && stats.disk_free_bytes < stats.reserve_bytes) {
throw new Error("The upload reached Lumi's reserved free-space boundary. Remove files or lower the reserve before retrying.");
}
if (stats.disk_available && bytes > stats.disk_usable_bytes) {
throw new Error("Lumi cannot safely finish storing these files without crossing the reserved free-space boundary.");
}
}
function listResources(filters = {}) {
ensureContentLibrary();
const where = [];
const params = {};
const category = String(filters.category || "").trim().toLowerCase();
const accessLevel = String(filters.access_level || "").trim().toLowerCase();
const search = String(filters.search || "").trim();
if (category) {
where.push("category = @category");
params.category = category;
}
if (ACCESS_LEVELS.includes(accessLevel)) {
where.push("access_level = @access_level");
params.access_level = accessLevel;
}
if (search) {
where.push("(display_name LIKE @search ESCAPE '\\' OR original_name LIKE @search ESCAPE '\\' OR mime LIKE @search ESCAPE '\\')");
params.search = `%${search.replace(/[\\%_]/g, "\\$&")}%`;
}
const orderBy = filters.sort === "name"
? "display_name COLLATE NOCASE ASC"
: filters.sort === "size"
? "size DESC"
: "created_at DESC";
const sql = `SELECT * FROM content_resources${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY ${orderBy}`;
return db.prepare(sql).all(params).map(hydrateResource);
}
function getResource(id) {
ensureContentLibrary();
const row = db.prepare("SELECT * FROM content_resources WHERE id = ?").get(String(id || ""));
return row ? hydrateResource(row) : null;
}
function getResourceByExposedToken(token) {
ensureContentLibrary();
const row = db.prepare(
"SELECT * FROM content_resources WHERE public_token_hash = ? AND access_level = 'exposed'"
).get(tokenHash(token));
return row ? hydrateResource(row) : null;
}
function updateResourceName(id, value) {
const current = requireResource(id);
const displayName = normalizeDisplayName(value, current.display_name);
db.prepare("UPDATE content_resources SET display_name = ?, updated_at = ? WHERE id = ?")
.run(displayName, Date.now(), current.id);
return getResource(current.id);
}
function setResourceAccess(id, value) {
const current = requireResource(id);
const accessLevel = normalizeAccessLevel(value);
if (current.access_level === accessLevel) return current;
const token = accessLevel === "exposed" ? createStoredToken() : null;
db.prepare(`
UPDATE content_resources
SET access_level = ?, public_token_hash = ?, public_token_encrypted = ?, updated_at = ?
WHERE id = ?
`).run(accessLevel, token?.hash || null, token?.encrypted || null, Date.now(), current.id);
return getResource(current.id);
}
function deleteResource(id) {
const current = requireResource(id);
const source = safeStoragePath(current.stored_name);
if (fs.existsSync(source)) {
const stat = fs.lstatSync(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The resource storage entry is not a regular file.");
// Deleting directly is more reliable than renaming to a temporary trash
// path on Windows network drives. If the database write then fails, the
// retained metadata can still be removed safely by retrying this action.
fs.rmSync(source, { force: true });
}
db.prepare("DELETE FROM content_resources WHERE id = ?").run(current.id);
return current;
}
function updateStorageSettings(values = {}) {
for (const field of ["limit_gib", "reserve_gib", "max_file_mib", "max_files"]) {
if (values[field] === undefined || values[field] === null || String(values[field]).trim() === "") {
throw new Error("Complete every storage-limit field before saving.");
}
}
const limitBytes = gibToBytes(values.limit_gib, { allowZero: true, max: 1024 * 1024 });
const reserveBytes = gibToBytes(values.reserve_gib, { allowZero: true, max: 1024 });
const maxFileBytes = mibToBytes(values.max_file_mib, {
min: 1,
max: HARD_MAX_FILE_BYTES / (1024 * 1024),
fallback: DEFAULT_MAX_FILE_BYTES
});
const maxFiles = clampInteger(values.max_files, 1, HARD_UPLOAD_MAX_FILES, DEFAULT_UPLOAD_MAX_FILES);
const usedBytes = getStorageStats().used_bytes;
if (limitBytes > 0 && limitBytes < usedBytes) {
throw new Error(`The storage limit cannot be lower than the ${formatBytes(usedBytes)} already stored.`);
}
db.transaction(() => {
setSetting("content_storage_limit_bytes", limitBytes);
setSetting("content_storage_reserve_bytes", reserveBytes);
setSetting("content_max_file_bytes", maxFileBytes);
setSetting("content_upload_max_files", maxFiles);
})();
return getStorageStats();
}
function resourceFilePath(resourceOrId) {
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
if (!resource?.stored_name) throw new Error("Resource storage metadata is missing.");
const target = safeStoragePath(resource.stored_name);
const stat = fs.lstatSync(target);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The resource file is unavailable.");
return target;
}
function openReadStream(id, options = {}) {
const resource = requireResource(id);
const filePath = resourceFilePath(resource);
return {
resource,
file_path: filePath,
stream: fs.createReadStream(filePath, options)
};
}
function publicUrl(resourceOrId, baseUrl = "") {
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
if (resource.access_level !== "exposed" || !resource.public_token) return null;
return joinBaseUrl(baseUrl, `/media/${encodeURIComponent(resource.public_token)}/${encodeURIComponent(resource.original_name)}`);
}
function adminUrl(resourceOrId, baseUrl = "") {
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
return joinBaseUrl(baseUrl, `/admin/resources/${encodeURIComponent(resource.id)}/raw/${encodeURIComponent(resource.original_name)}`);
}
function downloadUrl(resourceOrId, baseUrl = "") {
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
return joinBaseUrl(baseUrl, `/admin/resources/${encodeURIComponent(resource.id)}/download/${encodeURIComponent(resource.original_name)}`);
}
function createSignedUrl(id, options = {}) {
const resource = requireResource(id);
const ttlSeconds = clampInteger(
options.ttl_seconds,
1,
SIGNED_URL_MAX_TTL_SECONDS,
5 * 60
);
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
const signature = signedResourceSignature(resource.id, expires);
const route = `/internal/media/${encodeURIComponent(resource.id)}/${encodeURIComponent(resource.original_name)}?expires=${expires}&sig=${encodeURIComponent(signature)}`;
return joinBaseUrl(options.base_url || "", route);
}
function verifySignedResource(id, expires, signature) {
const normalizedId = String(id || "");
const expiry = Number(expires);
if (!normalizedId || !Number.isFinite(expiry) || expiry < Math.floor(Date.now() / 1000)) return null;
if (expiry > Math.floor(Date.now() / 1000) + SIGNED_URL_MAX_TTL_SECONDS + 60) return null;
const expected = Buffer.from(signedResourceSignature(normalizedId, expiry));
const received = Buffer.from(String(signature || ""));
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) return null;
return getResource(normalizedId);
}
function serializeResource(resource, options = {}) {
const baseUrl = options.base_url || "";
return {
id: resource.id,
display_name: resource.display_name,
original_name: resource.original_name,
mime: resource.mime,
extension: resource.extension,
category: resource.category,
preview_kind: resource.preview_kind,
size: resource.size,
size_display: formatBytes(resource.size),
checksum_sha256: resource.checksum_sha256,
access_level: resource.access_level,
created_at: resource.created_at,
updated_at: resource.updated_at,
admin_url: adminUrl(resource, baseUrl),
download_url: downloadUrl(resource, baseUrl),
public_url: publicUrl(resource, baseUrl)
};
}
function frameworkApi() {
return Object.freeze({
list: (filters) => listResources(filters).map((resource) => serializeResource(resource)),
get: (id) => {
const resource = getResource(id);
return resource ? serializeResource(resource) : null;
},
resolvePath: resourceFilePath,
openReadStream,
publicUrl,
createSignedUrl,
storage: getStorageStats,
supportedFormats
});
}
function hydrateResource(row) {
let publicToken = "";
if (row.public_token_encrypted) {
try { publicToken = decryptSecret(row.public_token_encrypted); } catch { publicToken = ""; }
}
return {
...row,
size: Number(row.size),
created_at: Number(row.created_at),
updated_at: Number(row.updated_at),
public_token: publicToken
};
}
function requireResource(id) {
const resource = getResource(id);
if (!resource) {
const error = new Error("Resource not found.");
error.status = 404;
throw error;
}
return resource;
}
function safeStoragePath(storedName) {
const base = path.resolve(FILES_DIR);
const target = path.resolve(FILES_DIR, path.basename(String(storedName || "")));
if (path.dirname(target) !== base) throw new Error("Invalid resource storage path.");
return target;
}
function normalizeAccessLevel(value) {
const normalized = String(value || "locked").trim().toLowerCase();
if (!ACCESS_LEVELS.includes(normalized)) throw new Error("Choose locked or exposed access.");
return normalized;
}
function normalizeDisplayName(value, fallback = "Resource") {
const normalized = String(value || "")
.normalize("NFKC")
.replace(/[\u0000-\u001f\u007f]/g, "")
.trim()
.slice(0, 160);
return normalized || fallback;
}
function displayNameFromFilename(filename) {
const extension = path.extname(filename);
return normalizeDisplayName(path.basename(filename, extension), filename);
}
function moveFile(source, destination) {
try {
fs.renameSync(source, destination);
} catch (error) {
if (!["EXDEV", "EPERM", "EACCES"].includes(error.code)) throw error;
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
fs.rmSync(source, { force: true });
}
}
function readPrefix(filePath, length) {
const descriptor = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(descriptor, buffer, 0, length, 0);
return buffer.subarray(0, bytesRead);
} finally {
fs.closeSync(descriptor);
}
}
function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function encryptionKey() {
const secret = getSetting("session_secret", "");
if (!secret) throw new Error("Lumi's session secret is not initialized.");
return crypto.createHash("sha256").update(`lumi-content-library:${secret}`).digest();
}
function createStoredToken() {
const token = crypto.randomBytes(32).toString("base64url");
return { token, hash: tokenHash(token), encrypted: encryptSecret(token) };
}
function tokenHash(token) {
return crypto.createHash("sha256").update(String(token || "")).digest("hex");
}
function encryptSecret(value) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
}
function decryptSecret(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || "").split(".");
if (version !== "v1" || !ivValue || !tagValue || !encryptedValue) throw new Error("Invalid stored token.");
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
encryptionKey(),
Buffer.from(ivValue, "base64url")
);
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, "base64url")),
decipher.final()
]).toString("utf8");
}
function signedResourceSignature(id, expires) {
return crypto.createHmac("sha256", encryptionKey())
.update(`resource:${id}:${expires}`)
.digest("base64url");
}
function joinBaseUrl(baseUrl, route) {
return `${String(baseUrl || "").replace(/\/$/, "")}${route}`;
}
function matchPng(buffer) {
return startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
}
function matchJpeg(buffer) {
return startsWith(buffer, [0xff, 0xd8, 0xff]);
}
function matchWebp(buffer) {
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
}
function matchGif(buffer) {
const header = buffer.subarray(0, 6).toString("ascii");
return header === "GIF87a" || header === "GIF89a";
}
function matchTiff(buffer) {
return startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) || startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a]);
}
function matchMp3(buffer) {
return buffer.subarray(0, 3).toString("ascii") === "ID3" ||
(buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
}
function matchAac(buffer) {
return buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0;
}
function matchOgg(buffer) {
return buffer.subarray(0, 4).toString("ascii") === "OggS";
}
function matchRiff(buffer, formType) {
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === formType;
}
function matchForm(buffer, types) {
return buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "FORM" && types.includes(buffer.subarray(8, 12).toString("ascii"));
}
function matchIsoMedia(buffer) {
return buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp";
}
function matchIsoBrand(buffer, brands) {
if (!matchIsoMedia(buffer)) return false;
const brandBlock = buffer.subarray(8, Math.min(buffer.length, 64)).toString("ascii");
return brands.some((brand) => brandBlock.includes(brand));
}
function matchEbml(buffer) {
return startsWith(buffer, [0x1a, 0x45, 0xdf, 0xa3]);
}
function matchMpegVideo(buffer) {
for (let index = 0; index < Math.min(buffer.length - 3, 4096); index += 1) {
if (buffer[index] === 0x00 && buffer[index + 1] === 0x00 && buffer[index + 2] === 0x01 && [0xb3, 0xba].includes(buffer[index + 3])) return true;
}
return false;
}
function matchTransportStream(buffer) {
const offsets = [0, 4];
return offsets.some((offset) => buffer.length > offset + 376 && buffer[offset] === 0x47 && buffer[offset + 188] === 0x47 && buffer[offset + 376] === 0x47);
}
function matchWebVtt(buffer) {
const text = decodeText(buffer).replace(/^\uFEFF/, "");
return /^WEBVTT(?:[ \t]|\r?\n)/.test(text);
}
function matchSubRip(buffer) {
const text = decodeText(buffer);
return /^\s*\d+\s*\r?\n\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}/m.test(text);
}
function matchJson(buffer) {
try {
const value = JSON.parse(decodeText(buffer));
return value !== null && typeof value === "object";
} catch {
return false;
}
}
function matchAsf(buffer) {
return startsWith(buffer, [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c]);
}
function matchSubStationAlpha(buffer) {
const text = decodeText(buffer);
return /^\s*\[Script Info\]/im.test(text) && /^\s*\[Events\]/im.test(text);
}
function matchSafeSvg(buffer) {
const source = decodeText(buffer).replace(/^\uFEFF/, "").trim();
if (!source || (!/^<\?xml\b[^>]*>\s*/i.test(source) && !/^<svg\b/i.test(source))) return false;
if (!/<svg\b/i.test(source)) return false;
return !/<\s*(script|foreignObject|iframe|object|embed|audio|video)\b/i.test(source)
&& !/\bon[a-z]+\s*=/i.test(source)
&& !/javascript\s*:/i.test(source)
&& !/@import\b/i.test(source)
&& !/<\s*!DOCTYPE|<\s*!ENTITY/i.test(source)
&& !/\b(?:href|xlink:href)\s*=\s*["']\s*(?!#)/i.test(source)
&& !/\burl\s*\(\s*["']?\s*(?!#)/i.test(source);
}
function matchTrueType(buffer) {
return startsWith(buffer, [0x00, 0x01, 0x00, 0x00]) || buffer.subarray(0, 4).toString("ascii") === "true";
}
function decodeText(buffer) {
if (buffer.includes(0)) return "";
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
return "";
}
}
function startsWith(buffer, bytes) {
return buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte);
}
function formatBytes(value) {
const bytes = nonNegativeNumber(value);
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let amount = bytes;
let unit = "B";
for (const next of units) {
amount /= 1024;
unit = next;
if (amount < 1024) break;
}
return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`;
}
function percent(value, total) {
if (!total) return 0;
return Math.max(0, Math.min(100, Math.round((Number(value) / Number(total)) * 1000) / 10));
}
function nonNegativeNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function clampInteger(value, min, max, fallback) {
const number = Math.floor(Number(value));
if (!Number.isFinite(number)) return Math.floor(fallback);
return Math.max(min, Math.min(max, number));
}
function gibToBytes(value, options = {}) {
const number = Number(value);
if (options.allowZero && number === 0) return 0;
if (!Number.isFinite(number) || number < 0) throw new Error("Storage values must be positive numbers.");
const bounded = Math.min(options.max || number, number);
return Math.floor(bounded * 1024 * 1024 * 1024);
}
function mibToBytes(value, options = {}) {
const number = Number(value);
const fallbackBytes = Number(options.fallback || DEFAULT_MAX_FILE_BYTES);
if (!Number.isFinite(number)) return fallbackBytes;
const bounded = Math.max(options.min || 1, Math.min(options.max || number, number));
return Math.floor(bounded * 1024 * 1024);
}
module.exports = {
ACCESS_LEVELS,
DATA_DIR,
FILES_DIR,
FORMAT_DEFINITIONS,
HARD_MAX_FILE_BYTES,
HARD_UPLOAD_MAX_FILES,
INCOMING_DIR,
adminUrl,
createSignedUrl,
deleteResource,
downloadUrl,
ensureContentLibrary,
formatBytes,
frameworkApi,
getResource,
getResourceByExposedToken,
getStorageStats,
importUploadedFiles,
listResources,
normalizeAccessLevel,
openReadStream,
preflightIncomingRequest,
publicUrl,
resourceFilePath,
serializeResource,
setResourceAccess,
supportedExtensions,
supportedFormats,
updateResourceName,
updateStorageSettings,
uploadLimits,
verifySignedResource
};