893 lines
34 KiB
JavaScript
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
|
|
};
|