// controllers/admin/assets.controller.js const path = require("path"); const crypto = require("crypto"); 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 { 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"); 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 }; 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 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}`; } // ─── 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); } } } // ─── uploadToProvider ───────────────────────────────────────────────────────── // // Uploads a single file to the resolved provider. // Returns { file_url, storage_key, mime_type, extension, checksum, file_type } // async function uploadToProvider(file, ownerType, storageProvider) { const mime_type = file.mimetype; const extension = resolveExtension(file.originalname); const checksum = resolveChecksum(file.buffer); const file_type = resolveFileType(mime_type); const svc = getProvider(storageProvider); const result = await svc.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType, }); return { file_url: result.url, storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB mime_type, extension, checksum, file_type, }; } // ─── 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; } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getAssets = async (req, res) => { try { const result = await paginate(Asset, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, findOptions: { where: { deletedAt: null } }, }); result.data = result.data.map(redactS3Url); 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 }, attributes: { exclude: ["storage_key", "storage_bucket"] }, include: [ { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, ], }); if (!asset) return R.error(res, "Asset not found.", 404); const json = asset.toJSON(); if (json.creator) { json.creator = { user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name ?? null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name ?? null, }; } redactS3Url(json); return R.success(res, "Asset retrieved.", { data: json }); } catch (err) { console.error("[ASSET][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── UPLOAD ─────────────────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ // │ │ // │ Phase 1 — SLOW WORK (outside transaction): │ // │ • Input validation │ // │ • ffprobe metadata extraction │ // │ • Provider upload (chibi or s3) → track for rollback │ // │ • Thumbnail upload → track for rollback │ // │ │ // │ Phase 2 — FAST WORK (transaction open milliseconds only): │ // │ • BEGIN → Asset.create() → COMMIT │ // │ │ // │ On Phase 2 error: │ // │ • ROLLBACK transaction │ // │ • rollbackUploads([{ key, provider }]) to clean orphans │ // └─────────────────────────────────────────────────────────────────────────┘ exports.uploadAsset = async (req, res) => { const uploadedFiles = []; // [{ key, provider }] try { // ── Phase 1a: Validate ──────────────────────────────────────────────────── 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, is_public = false, storage_provider = "chibisafe", storage_bucket, storage_key, createdBy, } = req.body; if (!createdBy) return R.error(res, "createdBy 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; const usesProvider = ["chibisafe", "s3"].includes(storage_provider); if (file_type === "video" && !thumbFile) { return R.error(res, "A thumbnail image is required for video uploads.", 400); } if (usesProvider && !file.buffer) { return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400); } // ── Phase 1b: Upload main file ──────────────────────────────────────────── let file_url = null; let storage_key_resolved = null; if (usesProvider) { const svc = getProvider(storage_provider); const result = await svc.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType: file_type, }); file_url = result.url; storage_key_resolved = result.uuid; uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider }); } 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 ───────────────────────────────────────── 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") { 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; if (usesProvider) { if (!thumbFile?.buffer) { await rollbackUploads(uploadedFiles); return R.error(res, "Thumbnail buffer is required.", 400); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const svc = getProvider(storage_provider); const thumbResult = await svc.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); } else { thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; } } else if (file_type === "audio" && thumbFile) { if (usesProvider) { if (!thumbFile.buffer) { await rollbackUploads(uploadedFiles); return R.error(res, "Thumbnail buffer is required.", 400); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const svc = getProvider(storage_provider); const thumbResult = await svc.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); } 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 ──────────────────────────────────────────────────── 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 || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null, storage_key: storage_key_resolved || storage_key || file.filename || null, is_public, createdBy, }, { transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } await rollbackUploads(uploadedFiles); throw dbErr; } } 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 asset ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "Asset not found.", 404); const file = req.files?.file?.[0] ?? req.file ?? null; const isVideo = asset.file_type === "video"; const isDocument = asset.file_type === "document"; if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400); if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400); if (file && !file.buffer) return R.error(res, "File buffer is required.", 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 (file && usesProvider) { const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); uploaded = await uploadToProvider(file, ownerType, storageProvider); newUpload = { key: uploaded.storage_key, provider: storageProvider }; } // ── Phase 2: DB update ──────────────────────────────────────────────────── const t = await sequelize.transaction(); try { await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, 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); logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset 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 asset ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "Asset not found.", 404); await asset.update({ deletedBy: req.body.deletedBy ?? null }); await asset.destroy(); logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset 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 assets 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 } } }); logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} asset(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, "Asset not found.", 404); if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400); await asset.restore(); await asset.update({ deletedBy: null }); logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset 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 assets found.", 404); const archivedAssets = assets.filter((a) => a.deletedAt); if (!archivedAssets.length) return R.error(res, "All selected assets 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 }); logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} asset(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); } }; // ─── 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 assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived assets.", 500); } }; exports.getAssetFieldValues = getFieldValues(Asset, "ASSET");