Files

850 lines
38 KiB
JavaScript

// controllers/admin/assets.controller.js
const path = require("path");
const fs = require("fs");
const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const ffmpegSvc = require("../../services/ffmpeg.service");
const assetTranscode = require("../../services/assetTranscode.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
const mdl_Users = require('../../models/users/users.mdl');
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
const notDeleted = { deletedAt: null };
// List queries keep storage_key selected (unlike adminExclude) so
// attachStreamTokens can sign a stream token server-side without a second
// query — it's deleted from every row before the response is sent.
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
// Single-process only; fine for one instance, won't stay consistent across
// multiple app instances without a shared store like Redis.
const LIST_CACHE_TTL_MS = 20_000;
const listCache = new Map(); // queryKey -> { result, expiresAt }
function listCacheKey(req) {
return JSON.stringify({
page: req.query.page, limit: req.query.limit,
filters: req.query.filters, sort: req.query.sort,
});
}
function invalidateListCache() { listCache.clear(); }
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
}
function resolveExtension(originalName = "") {
return path.extname(originalName).replace(".", "").toLowerCase() || null;
}
function resolveResolution(width, height) {
if (!width || !height) return null;
const h = Math.min(width, height);
if (h >= 2160) return "4K";
if (h >= 1440) return "1440p";
if (h >= 1080) return "1080p";
if (h >= 720) return "720p";
if (h >= 480) return "480p";
if (h >= 360) return "360p";
if (h >= 240) return "240p";
return `${width}x${height}`;
}
// ─── Provider resolver ────────────────────────────────────────────────────────
//
// Returns the correct service module based on storage_provider.
// Both chibi and s3 expose the same interface: uploadFile / deleteFile.
//
function getProvider(storageProvider) {
if (storageProvider === "s3") return s3;
if (storageProvider === "chibisafe") return chibi;
return null; // local / other — no remote provider needed
}
// ─── rollbackUploads ──────────────────────────────────────────────────────────
//
// Best-effort cleanup after a failed DB transaction.
// uploads: [{ key, provider }]
//
async function rollbackUploads(uploads = []) {
for (const { key, provider } of uploads) {
if (!key || !provider) continue;
const svc = getProvider(provider);
if (!svc) continue;
try {
await svc.deleteFile(key);
} catch (err) {
console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message);
}
}
}
// ─── finalizeReplacementUpload ─────────────────────────────────────────────────
//
// Used by updateAsset() when replacing an asset's file (or a video's
// thumbnail): the browser already PUT the new file straight to storage via a
// presigned URL (see presignAssetUpload) — this reads back what actually
// landed there (HeadObjectCommand, no download) instead of ever buffering the
// file through this backend, the same strategy finalizeAssetFromStorage()
// uses for brand-new assets.
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type, originalname }
//
async function finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider) {
const svc = getProvider(storageProvider);
if (!svc || !svc.getFileMetadata) {
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
}
const meta = await svc.getFileMetadata(storage_key);
const mime_type = meta.mimetype || mimetype || "application/octet-stream";
return {
file_url: await svc.buildPublicUrl(storage_key),
storage_key,
mime_type,
extension: resolveExtension(original_name || storage_key),
checksum: meta.checksum,
file_type: resolveFileType(mime_type),
originalname: original_name || storage_key,
};
}
// ─── applyAssetUpdate ─────────────────────────────────────────────────────────
async function applyAssetUpdate(asset, file, body) {
const isThumbnailOnly = asset.file_type === "video" && !!file;
if (body.display_name !== undefined) asset.display_name = body.display_name;
if (body.description !== undefined) asset.description = body.description;
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
asset.updatedBy = body.updatedBy ?? null;
if (file) {
if (isThumbnailOnly) {
asset.thumbnail_url = file.file_url;
asset.thumbnail_storage_key = file.storage_key;
} else {
asset.original_name = file.originalname;
asset.file_url = file.file_url;
asset.file_size = file.size;
asset.mime_type = file.mime_type;
asset.extension = file.extension;
asset.checksum = file.checksum;
asset.file_type = file.file_type;
asset.storage_key = file.storage_key;
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedHeight = body.height ? parseInt(body.height) : null;
if (parsedWidth || parsedHeight) {
asset.width = parsedWidth;
asset.height = parsedHeight;
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
}
}
}
}
// ─── deleteOldFile ────────────────────────────────────────────────────────────
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
if (!oldStorageKey || oldStorageKey === newKey) return;
const svc = getProvider(storageProvider);
if (!svc) return;
try {
await svc.deleteFile(oldStorageKey);
} catch (err) {
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
}
}
// ─── Helper: hide S3 file_url from responses ──────────────────────────────────
//
// The raw S3 presigned/public URL is never sent to any browser.
// Admin viewers request a short-lived stream token instead
// (POST /api/admin/media/token → GET /api/client/media/stream/:token).
// Chibisafe assets keep their file_url (CDN public URL, no proxy needed).
//
function redactS3Url(asset) {
if (asset?.storage_provider === "s3") asset.file_url = null;
return asset;
}
// ─── attachStreamTokens ─────────────────────────────────────────────────────
//
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
// so pickers/tables reading the list can render thumbnails immediately instead
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
// adminExclude) purely so it's available here to sign the token — it's still
// stripped from every row before the response goes out.
//
// Operates on shallow copies: `result.data` is shared with listCache, and
// mutating those rows in place would delete storage_key from the cached
// objects, breaking token issuance for the next request that hits the cache.
//
async function attachStreamTokens(rows, req) {
const ip = mediaToken.resolveIp(req);
const userId = req.user?.user_id;
return Promise.all(rows.map(async (original) => {
const row = { ...original };
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
if (eligible) {
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
row.stream_token = token;
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
}
delete row.storage_key;
return row;
}));
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAssets = async (req, res) => {
try {
const key = listCacheKey(req);
const cached = listCache.get(key);
let result;
if (cached && Date.now() < cached.expiresAt) {
result = cached.result;
} else {
result = await paginate(Asset, req, {
excludeAttributes: LIST_QUERY_EXCLUDE,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: { where: { deletedAt: null } },
});
result.data = result.data.map(redactS3Url);
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
}
const data = await attachStreamTokens(result.data, req);
return R.success(res, "Files retrieved.", { ...result, data });
} catch (err) {
console.error("[ASSET][GET ALL]", err);
return R.error(res, "Could not retrieve files.", 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
exports.getAsset = async (req, res) => {
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted },
// storage_key stays selected here (unlike the list query) so it's
// available below to sign a stream token — stripped before the response.
attributes: { exclude: ["storage_bucket"] },
include: [
{ model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" },
],
});
if (!asset) return R.error(res, "File not found.", 404);
const json = asset.toJSON();
// Falls back to email when full_name hasn't been filled in — better than
// surfacing the raw numeric user_id in the admin UI.
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null,
};
}
if (json.updater) {
json.updater = {
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null,
};
}
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
const ip = mediaToken.resolveIp(req);
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
json.stream_token = token;
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
}
delete json.storage_key;
redactS3Url(json);
return R.success(res, "File retrieved.", { data: json });
} catch (err) {
console.error("[ASSET][GET ONE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
//
// ┌─────────────────────────────────────────────────────────────────────────┐
// │ PRESIGNED-UPLOAD STRATEGY │
// │ │
// │ The browser already PUT the file's bytes straight to storage via a │
// │ presigned URL (see presignAssetUpload below) — this backend never │
// │ buffers or even touches them (this is what removes the old 500MB │
// │ multer-memoryStorage RAM ceiling entirely, regardless of file size). │
// │ Finalizing an asset from an already-uploaded object is just: │
// │ • HeadObjectCommand → real file_size/mime_type/checksum (=ETag) │
// │ • ffprobe by URL → video/audio metadata only, no download │
// │ • BEGIN → Asset.create() → COMMIT │
// │ │
// │ On any error: rollbackUploads([{ key, provider }]) deletes the │
// │ already-uploaded object(s) — same cleanup as before, just always │
// │ covering both file + thumbnail upfront, since both already exist in │
// │ storage by the time this runs (the browser uploaded them first). │
// └─────────────────────────────────────────────────────────────────────────┘
//
// Thumbnails are optional for both video and audio — a video/audio asset can
// land with thumbnail_url null and pick one up later via the existing
// "thumbnail-only" path in updateAsset().
//
async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body, user }) {
const {
display_name,
description,
is_public = false,
storage_provider = "s3",
storage_bucket,
createdBy,
} = body;
const uploadedFiles = [{ key: storage_key, provider: storage_provider }];
if (thumbnail_storage_key) uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider });
try {
if (!storage_key) throw Object.assign(new Error("storage_key is required."), { status: 400 });
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
const svc = getProvider(storage_provider);
if (!svc || !svc.getFileMetadata) {
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
}
let meta;
try {
meta = await svc.getFileMetadata(storage_key);
} catch {
throw Object.assign(new Error("Uploaded file not found in storage — the upload may have failed or expired."), { status: 400 });
}
// S3's own Content-Type is authoritative when present, but browsers only
// send one automatically when the File object's own .type is non-empty —
// fall back to whatever the client reported at presign time, and finally
// to a generic default, rather than ever letting a NOT NULL column see
// null here (mime_type also drives file_type below, so a null here would
// misclassify the asset entirely, not just leave a field blank).
const mime_type = meta.mimetype || body.mimetype || "application/octet-stream";
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(original_name || storage_key);
const file_url = await svc.buildPublicUrl(storage_key);
// ── ffprobe (video/audio only) ────────────────────────────────────────────
let width = null, height = null, resolution = null;
let duration = null, frame_rate = null, bitrate = null;
let video_codec = null, audio_codec = null;
let thumbnail_url = null;
if (file_type === "video" || file_type === "audio") {
const probeUrl = await svc.getSignedDownloadUrl(storage_key);
const videoMeta = await extractVideoMeta({ url: probeUrl });
width = videoMeta.width;
height = videoMeta.height;
resolution = videoMeta.resolution;
duration = videoMeta.duration;
frame_rate = videoMeta.frame_rate;
bitrate = videoMeta.bitrate;
video_codec = videoMeta.video_codec;
audio_codec = videoMeta.audio_codec;
if (thumbnail_storage_key) {
thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
} else if (file_type === "video") {
// No client-provided thumbnail — grab a frame from the video itself so
// the asset doesn't sit with no preview at all in every picker/library
// grid. Best-effort: a failure here must not fail the whole upload.
let framePath = null;
try {
framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration);
const uploaded = await svc.uploadStream({
stream: fs.createReadStream(framePath),
originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`,
mimetype: "image/jpeg",
ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails
});
thumbnail_storage_key = uploaded.uuid;
thumbnail_url = uploaded.url;
uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure
} catch (err) {
console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message);
// leave thumbnail_url null — same fallback as before, admin can add one manually later
} finally {
if (framePath) fs.promises.unlink(framePath).catch(() => {});
}
}
} else {
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedHeight = body.height ? parseInt(body.height) : null;
width = parsedWidth;
height = parsedHeight;
resolution = resolveResolution(parsedWidth, parsedHeight);
}
// ── DB insert ──────────────────────────────────────────────────────────────
// .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of
// the file) — flag them for the background remux job (see
// assetTranscode.service.js) fired below, right after commit.
const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension);
const t = await sequelize.transaction();
try {
const asset = await Asset.create({
original_name: original_name || storage_key,
display_name: display_name || original_name || storage_key,
file_url,
file_size: meta.size,
mime_type,
extension,
checksum: meta.checksum,
file_type,
width,
height,
resolution,
duration,
frame_rate,
bitrate,
video_codec,
audio_codec,
thumbnail_url,
thumbnail_storage_key,
description,
storage_provider,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
storage_key,
is_public,
createdBy,
transcode_status: needsTranscode ? "pending" : "none",
}, { transaction: t });
await t.commit();
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
if (needsTranscode) {
assetTranscode.transcodeAsset(asset).catch((err) => {
console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message);
});
}
return asset;
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
await rollbackUploads(uploadedFiles);
throw dbErr;
}
} catch (err) {
await rollbackUploads(uploadedFiles);
throw err;
}
}
// ─── PRESIGN UPLOAD ─────────────────────────────────────────────────────────
//
// Mints a short-lived presigned PUT URL so the browser can upload the file's
// bytes directly to storage — this backend never buffers them. Called once
// for the main file, and once more for a thumbnail if the admin picked one
// (see s3.service.js#presignUpload for the key-naming convention).
//
exports.presignAssetUpload = async (req, res) => {
try {
const { filename, mimetype, file_type, size = 0, storage_provider = "s3" } = req.body;
if (!filename) return R.error(res, "filename is required.", 400);
const svc = getProvider(storage_provider);
if (!svc || !svc.presignUpload) {
return R.error(res, "Presigned uploads are only supported for S3 storage.", 400);
}
const ownerType = file_type || resolveFileType(mimetype || "") || "document";
// Result is either { key, uploadUrl } or, above the single-PUT ceiling,
// { key, multipart: true, uploadId, partSize, parts } — see
// s3.service.js#presignUpload. The client branches on `multipart`.
const presigned = await svc.presignUpload(filename, ownerType, Number(size) || 0);
return R.success(res, "Presigned URL generated.", presigned);
} catch (err) {
console.error("[ASSET][PRESIGN]", err);
return R.error(res, "Could not generate upload URL.", 500);
}
};
// ─── COMPLETE / ABORT MULTIPART ─────────────────────────────────────────────
//
// Only used above presignAssetUpload's single-PUT ceiling (see
// s3.service.js's MULTIPART_THRESHOLD) — the browser PUTs every part
// directly, then calls complete-multipart with the ETags each part's PUT
// response returned. abort-multipart is the failure-path cleanup (a part
// exhausted its retries, or the admin cancelled) so an abandoned multipart
// upload doesn't linger as orphaned storage forever.
//
exports.completeMultipartAssetUpload = async (req, res) => {
try {
const { storage_key, uploadId, parts, storage_provider = "s3" } = req.body;
if (!storage_key || !uploadId || !Array.isArray(parts) || !parts.length) {
return R.error(res, "storage_key, uploadId, and parts are required.", 400);
}
const svc = getProvider(storage_provider);
if (!svc || !svc.completeMultipartUpload) {
return R.error(res, "Multipart uploads are only supported for S3 storage.", 400);
}
await svc.completeMultipartUpload(storage_key, uploadId, parts);
return R.success(res, "Multipart upload completed.", {});
} catch (err) {
console.error("[ASSET][COMPLETE MULTIPART]", err);
return R.error(res, "Could not complete multipart upload.", 500);
}
};
exports.abortMultipartAssetUpload = async (req, res) => {
try {
const { storage_key, uploadId, storage_provider = "s3" } = req.body;
if (!storage_key || !uploadId) return R.error(res, "storage_key and uploadId are required.", 400);
const svc = getProvider(storage_provider);
if (svc?.abortMultipartUpload) {
try {
await svc.abortMultipartUpload(storage_key, uploadId);
} catch (err) {
// Best-effort, same tolerance as rollbackUploads() — an already-gone
// or already-completed upload isn't worth failing the request over.
console.error(`[ASSET][ABORT MULTIPART] Failed to abort "${storage_key}":`, err.message);
}
}
return R.success(res, "Multipart upload aborted.", {});
} catch (err) {
console.error("[ASSET][ABORT MULTIPART]", err);
return R.error(res, "Could not abort multipart upload.", 500);
}
};
// ─── UPLOAD (finalize) ──────────────────────────────────────────────────────
//
// Called once the browser's direct-to-storage PUT(s) have completed. Body is
// plain JSON — no file bytes here, just the storage key(s) presignAssetUpload
// handed back plus asset metadata. Also the endpoint the bulk queue
// (UploadQueueContext.jsx) calls once per file, reusing this single-asset
// path instead of a separate batch endpoint.
//
exports.uploadAsset = async (req, res) => {
try {
const { storage_key, thumbnail_storage_key, original_name } = req.body;
const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user });
invalidateListCache();
return R.success(res, "File uploaded.", { data: asset }, 201);
} catch (err) {
console.error("[ASSET][UPLOAD]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {
let newUpload = null; // { key, provider }
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "File not found.", 404);
// Browser already PUT the replacement file straight to storage via a
// presigned URL (see presignAssetUpload) — this is plain JSON, no
// multer/file buffer involved, same pattern as POST /admin/assets.
const { storage_key, original_name, mimetype } = req.body;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new file instead.", 400);
const storageProvider = asset.storage_provider;
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
// ── Phase 1: Upload ───────────────────────────────────────────────────────
let uploaded = null;
if (storage_key && usesProvider) {
uploaded = await finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider);
newUpload = { key: uploaded.storage_key, provider: storageProvider };
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded, req.body);
await asset.save({ transaction: t });
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
if (newUpload) await rollbackUploads([newUpload]);
throw dbErr;
}
// ── Phase 3: Cleanup old file ─────────────────────────────────────────────
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
invalidateListCache();
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "File updated.", { data: asset });
} catch (err) {
if (newUpload) await rollbackUploads([newUpload]);
console.error("[ASSET][UPDATE]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAsset = async (req, res) => {
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "File not found.", 404);
await asset.update({ deletedBy: req.body.deletedBy ?? null });
await asset.destroy();
invalidateListCache();
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "File archived.");
} catch (err) {
console.error("[ASSET][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
exports.archiveAssets = async (req, res) => {
try {
const { ids, deletedBy } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
if (!assets.length) return R.error(res, "No files found.", 404);
const activeIds = assets.map((a) => a.asset_id);
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
invalidateListCache();
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} file(s) archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error("[ASSET][BULK ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
exports.restoreAsset = async (req, res) => {
try {
const { assetId } = req.params;
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
if (!asset) return R.error(res, "File not found.", 404);
if (!asset.deletedAt) return R.error(res, "File is not archived.", 400);
await asset.restore();
await asset.update({ deletedBy: null });
invalidateListCache();
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "File restored.", { data: asset });
} catch (err) {
console.error("[ASSET][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
exports.restoreAssets = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
if (!assets.length) return R.error(res, "No files found.", 404);
const archivedAssets = assets.filter((a) => a.deletedAt);
if (!archivedAssets.length) return R.error(res, "All selected files are already active.", 400);
const archivedIds = archivedAssets.map((a) => a.asset_id);
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
invalidateListCache();
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} file(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[ASSET][BULK RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
exports.permanentlyDeleteAsset = async (req, res) => {
try {
const { assetId } = req.params;
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
if (!asset) return R.error(res, "File not found.", 404);
if (!asset.deletedAt) return R.error(res, "File must be archived before it can be permanently deleted.", 400);
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
await asset.destroy({ force: true });
const svc = getProvider(storage_provider);
if (svc) {
if (storage_key) {
try { await svc.deleteFile(storage_key); }
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
}
if (thumbnail_storage_key) {
try { await svc.deleteFile(thumbnail_storage_key); }
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
}
}
invalidateListCache();
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "File permanently deleted.");
} catch (err) {
console.error("[ASSET][PERMANENT DELETE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
exports.permanentlyDeleteAssets = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
if (!assets.length) return R.error(res, "No files found.", 404);
const archivedAssets = assets.filter((a) => a.deletedAt);
if (!archivedAssets.length) return R.error(res, "All selected files must be archived before they can be permanently deleted.", 400);
const archivedIds = archivedAssets.map((a) => a.asset_id);
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
for (const asset of archivedAssets) {
const svc = getProvider(asset.storage_provider);
if (!svc) continue;
if (asset.storage_key) {
try { await svc.deleteFile(asset.storage_key); }
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
}
if (asset.thumbnail_storage_key) {
try { await svc.deleteFile(asset.thumbnail_storage_key); }
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
}
}
invalidateListCache();
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} file(s) permanently deleted.`, {
deleted_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[ASSET][BULK PERMANENT DELETE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
exports.getArchivedAssets = async (req, res) => {
try {
const result = await paginate(Asset, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Archived files retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived files.", 500);
}
};
exports.getAssetFieldValues = getFieldValues(Asset, "ASSET");