// controllers/admin/assets.controller.js const path = require("path"); const crypto = require("crypto"); const { Op } = require("sequelize"); const sequelize = require("../../config/db.config"); const Asset = require("../../models/assets/assets.mdl"); const chibi = require("../../services/chibisafe.service"); const { extractVideoMeta } = require("../../services/ffprobe.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes"); // ─── Helpers ────────────────────────────────────────────────────────────────── const notDeleted = { deletedAt: null }; function resolveFileType(mimeType = "") { if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; return "other"; } function resolveExtension(originalName = "") { return path.extname(originalName).replace(".", "").toLowerCase() || null; } function resolveChecksum(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } 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}`; } /** * Best-effort cleanup of Chibisafe files after a failed transaction. * Never throws — the original error is what matters. */ async function rollbackChibiUploads(uuids = []) { for (const uuid of uuids) { if (!uuid) continue; try { await chibi.deleteFile(uuid); } catch (cleanupErr) { console.error(`[ASSET][ROLLBACK] Failed to delete Chibisafe file ${uuid}:`, cleanupErr.message); } } } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getAssets = async (req, res) => { try { const result = await paginate(Asset, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, findOptions: { where: { deletedAt: null }, }, }); return R.success(res, "Assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ALL]", err); return R.error(res, "Could not retrieve assets.", 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getAsset = async (req, res) => { try { const { assetId } = req.params; if (!assetId || assetId === "undefined") { return R.error(res, "Invalid asset ID.", 400); } const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, }); if (!asset) return R.error(res, "Asset not found.", 404); return R.success(res, "Asset retrieved.", { data: asset }); } catch (err) { console.error("[ASSET][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── UPLOAD ─────────────────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ // │ │ // │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │ // │ • Input validation │ // │ • ffprobe metadata extraction │ // │ • Chibisafe file upload → track UUID for rollback │ // │ • Chibisafe thumb upload → track UUID for rollback │ // │ │ // │ Phase 2 — FAST WORK (transaction open for milliseconds only): │ // │ • BEGIN transaction │ // │ • Asset.create() │ // │ • COMMIT │ // │ │ // │ On any Phase 2 error: │ // │ • ROLLBACK transaction │ // │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │ // └─────────────────────────────────────────────────────────────────────────┘ // // Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]) exports.uploadAsset = async (req, res) => { // Tracks Chibisafe UUIDs uploaded during Phase 1 so they can be cleaned // up if Phase 2 (DB insert) fails. const uploadedChibiUuids = []; try { // ── Phase 1a: Validate inputs ───────────────────────────────────────────── const file = req.files?.file?.[0]; const thumbFile = req.files?.thumbnail?.[0]; if (!file) return R.error(res, "No file uploaded.", 400); const { display_name, description, owner_type, owner_id, is_public = false, access_level = "private", storage_provider = "local", storage_bucket, storage_key, uploadedBy, } = req.body; if (!uploadedBy) { return R.error(res, "uploadedBy is required.", 400); } const mime_type = file.mimetype; const file_type = resolveFileType(mime_type); const extension = resolveExtension(file.originalname); const checksum = file.buffer ? resolveChecksum(file.buffer) : null; if (file_type === "video" && !thumbFile) { return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400); } if (storage_provider === "chibisafe" && !file.buffer) { return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400); } // ── Phase 1b: Upload main file to Chibisafe ─────────────────────────────── // Heavy I/O — done BEFORE opening any DB transaction. let file_url = null; let chibi_uuid = null; if (storage_provider === "chibisafe") { // owner_type is the single source of truth for album routing. // The service maps: avatar→avatars, video→videos, document→documents, // thumbnail→thumbnails, image / anything else → no album. const chibiResult = await chibi.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType: owner_type || "", }); file_url = chibiResult.url; chibi_uuid = chibiResult.uuid; uploadedChibiUuids.push(chibi_uuid); } else { file_url = storage_provider === "local" ? `/uploads/${file.filename}` : req.body.file_url; if (!file_url) { return R.error(res, "file_url is required for non-local storage.", 400); } } // ── Phase 1c: ffprobe + thumbnail upload ────────────────────────────────── // Also heavy — done BEFORE the DB transaction. let width = null; let height = null; let resolution = null; let duration = null; let frame_rate = null; let bitrate = null; let video_codec = null; let audio_codec = null; let thumbnail_url = null; if (file_type === "video") { // ffprobe — CPU-bound, can take a few seconds const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4", }); width = meta.width; height = meta.height; resolution = meta.resolution; duration = meta.duration; frame_rate = meta.frame_rate; bitrate = meta.bitrate; video_codec = meta.video_codec; audio_codec = meta.audio_codec; // Thumbnail upload — another network call, done outside the transaction if (storage_provider === "chibisafe") { if (!thumbFile.buffer) { // Clean up the already-uploaded main file before returning await rollbackChibiUploads(uploadedChibiUuids); return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const thumbResult = await chibi.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", // always routes to the thumbnails album }); thumbnail_url = thumbResult.url; uploadedChibiUuids.push(thumbResult.uuid); } else { thumbnail_url = thumbFile.filename ? `/uploads/${thumbFile.filename}` : null; } } else { const parsedWidth = req.body.width ? parseInt(req.body.width) : null; const parsedHeight = req.body.height ? parseInt(req.body.height) : null; width = parsedWidth; height = parsedHeight; resolution = resolveResolution(parsedWidth, parsedHeight); } // ── Phase 2: DB insert — transaction is open for milliseconds only ──────── const t = await sequelize.transaction(); try { const asset = await Asset.create({ original_name: file.originalname, display_name: display_name || file.originalname, file_url, file_size: file.size, mime_type, extension, checksum, file_type, width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec, thumbnail_url, description, storage_provider, storage_bucket: storage_bucket || null, storage_key: chibi_uuid || storage_key || file.filename, is_public, access_level, owner_type: owner_type || null, owner_id: owner_id || null, uploadedBy, }, { transaction: t }); await t.commit(); return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (dbErr) { // DB failed — rollback and clean up the Chibisafe uploads try { await t.rollback(); } catch { /* connection already gone */ } await rollbackChibiUploads(uploadedChibiUuids); throw dbErr; // re-throw to outer catch for logging + response } } 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 THUMBNAIL ───────────────────────────────────────────────────────── // // Accepts a "thumbnail" file upload (multipart/form-data). // Strategy (same phase split as uploadAsset): // Phase 1 — upload new thumbnail to Chibisafe, delete old one (outside transaction) // Phase 2 — update asset.thumbnail_url in DB (transaction open milliseconds only) exports.updateThumbnail = async (req, res) => { let newThumbUuid = null; try { const { assetId } = req.params; const thumbFile = req.files?.thumbnail?.[0] ?? req.file; if (!thumbFile) { return R.error(res, "No thumbnail file uploaded. Include it as the 'thumbnail' field.", 400); } if (!thumbFile.buffer) { return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); } // ── Phase 1: Fetch asset + upload new thumbnail ─────────────────────────── // Done outside the transaction so the connection isn't held during I/O. const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "Asset not found.", 404); const oldThumbUuid = asset.storage_provider === "chibisafe" ? asset.thumbnail_storage_key ?? null // store separately if you have it, : null; // otherwise skip deletion // Upload new thumbnail to Chibisafe thumbnails album let thumbnail_url = null; if (asset.storage_provider === "chibisafe") { const baseName = asset.original_name.replace(/\.[^.]+$/, ""); const thumbExt = resolveExtension(thumbFile.originalname) || "jpg"; const thumbResult = await chibi.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${thumbExt}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; newThumbUuid = thumbResult.uuid; } else { thumbnail_url = thumbFile.filename ? `/uploads/${thumbFile.filename}` : null; } if (!thumbnail_url) { return R.error(res, "Could not resolve thumbnail URL.", 500); } // ── Phase 2: DB update (transaction open milliseconds only) ─────────────── const t = await sequelize.transaction(); try { asset.thumbnail_url = thumbnail_url; await asset.save({ transaction: t }); await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } // DB failed — delete the just-uploaded thumbnail from Chibisafe if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); throw dbErr; } // ── Phase 3: Delete old thumbnail from Chibisafe (best-effort) ─────────── // Done AFTER commit so a failed cleanup never blocks the success response. if (oldThumbUuid) { try { await chibi.deleteFile(oldThumbUuid); } catch (cleanupErr) { console.warn("[ASSET][UPDATE THUMBNAIL] Old thumbnail cleanup failed:", cleanupErr.message); } } return R.success(res, "Thumbnail updated.", { data: asset }); } catch (err) { if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); console.error("[ASSET][UPDATE THUMBNAIL]", err); return R.error(res, "Internal server error.", 500); } }; // ─── UPDATE METADATA ────────────────────────────────────────────────────────── exports.updateAsset = async (req, res) => { const t = await sequelize.transaction(); try { const { assetId } = req.params; if (!assetId || assetId === "undefined") { await t.rollback(); return R.error(res, "Invalid asset ID.", 400); } if (req.files?.file || req.file) { await t.rollback(); return R.error(res, "File uploads are not allowed on this endpoint. Use POST /assets to upload a new file.", 400); } const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, transaction: t, }); if (!asset) { await t.rollback(); return R.error(res, "Asset not found.", 404); } const allowed = [ "display_name", "description", "owner_type", "owner_id", "is_public", "access_level", "thumbnail_url", "width", "height", "duration", "frame_rate", "bitrate", "video_codec", "audio_codec", ]; allowed.forEach((field) => { if (req.body[field] !== undefined) asset[field] = req.body[field]; }); if (req.body.width || req.body.height) { asset.resolution = resolveResolution(asset.width, asset.height); } await asset.save({ transaction: t }); await t.commit(); return R.success(res, "Asset updated.", { data: asset }); } catch (err) { try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][UPDATE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── SOFT DELETE (single) ───────────────────────────────────────────────────── exports.deleteAsset = async (req, res) => { const t = await sequelize.transaction(); try { const { assetId } = req.params; if (!assetId || assetId === "undefined") { await t.rollback(); return R.error(res, "Invalid asset ID.", 400); } const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, transaction: t, lock: t.LOCK.UPDATE, }); if (!asset) { await t.rollback(); return R.error(res, "Asset not found.", 404); } // Archive on Chibisafe before the DB update (best-effort, non-fatal) if (asset.storage_provider === "chibisafe" && asset.storage_key) { try { await chibi.archiveFiles(asset.storage_key); } catch (chibiErr) { console.error("[ASSET][DELETE][CHIBI ARCHIVE]", chibiErr.message); } } asset.deletedAt = new Date(); asset.deletedBy = req.body.deletedBy ?? null; await asset.save({ transaction: t }); await t.commit(); return R.success(res, "Asset deleted."); } catch (err) { try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][DELETE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── SOFT DELETE (bulk) ─────────────────────────────────────────────────────── exports.deleteAssets = async (req, res) => { const t = await sequelize.transaction(); try { const { ids, deletedBy } = req.body; if (!Array.isArray(ids) || !ids.length) { await t.rollback(); return R.error(res, "ids must be a non-empty array.", 400); } const chibiAssets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, storage_provider: "chibisafe", storage_key: { [Op.not]: null }, ...notDeleted, }, attributes: ["storage_key"], transaction: t, }); // Archive on Chibisafe (best-effort, non-fatal) if (chibiAssets.length) { try { await chibi.archiveFiles(chibiAssets.map((a) => a.storage_key)); } catch (chibiErr) { console.error("[ASSET][BULK DELETE][CHIBI ARCHIVE]", chibiErr.message); } } const [count] = await Asset.update( { deletedAt: new Date(), deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: ids }, ...notDeleted }, transaction: t, }, ); await t.commit(); return R.success(res, `${count} asset(s) deleted.`); } catch (err) { try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][BULK DELETE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (single) ───────────────────────────────────────────────────────── // // Clears deletedAt/deletedBy in DB, then moves the file on Chibisafe from the // archived album back to the album it belongs to based on owner_type: // avatar → avatars album // video → videos album // document → documents album // image → no album (general images have no dedicated album) // // The Chibisafe move is best-effort — a failed move won't block the restore. exports.restoreAsset = async (req, res) => { const t = await sequelize.transaction(); try { const { assetId } = req.params; const asset = await Asset.findOne({ where: { asset_id: assetId, deletedAt: { [Op.not]: null } }, transaction: t, lock: t.LOCK.UPDATE, }); if (!asset) { await t.rollback(); return R.error(res, "Asset not found or not deleted.", 404); } asset.deletedAt = null; asset.deletedBy = null; await asset.save({ transaction: t }); await t.commit(); // ── Move file back to its home album on Chibisafe (best-effort) ─────────── // Done AFTER commit so a failed move never rolls back the restore. if (asset.storage_provider === "chibisafe" && asset.storage_key) { const homeAlbumUuid = chibi.ALBUMS[ownerTypeToAlbumKey(asset.owner_type)]; if (homeAlbumUuid) { try { await chibi.addFilesToAlbum(asset.storage_key, homeAlbumUuid); } catch (chibiErr) { console.warn("[ASSET][RESTORE] Failed to move file back to home album:", chibiErr.message); } } } return R.success(res, "Asset restored.", { data: asset }); } catch (err) { try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; /** * Maps asset owner_type to the ALBUMS key in chibisafe.service. * Returns null for types that have no dedicated album (e.g. "image"). * * @param {string} ownerType * @returns {string|null} */ function ownerTypeToAlbumKey(ownerType) { const map = { avatar: "avatars", video: "videos", document: "documents", }; return map[ownerType] ?? null; }