diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 2bcaa4f..9fee3d5 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -1,9 +1,19 @@ // controllers/admin/assets.controller.js -const path = require("path"); -const crypto = require("crypto"); -const { Op } = require("sequelize"); -const Asset = require("../../models/assets/assets.mdl"); +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 ────────────────────────────────────────────────────────────────── @@ -38,62 +48,38 @@ function resolveResolution(width, height) { 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 { - page = 1, - limit = 20, - file_type, - owner_type, - owner_id, - uploadedBy, - is_public, - resolution, - search, - sort_by = "createdAt", - sort_dir = "DESC", - } = req.query; - - const where = { ...notDeleted }; - - if (file_type) where.file_type = file_type; - if (owner_type) where.owner_type = owner_type; - if (owner_id) where.owner_id = owner_id; - if (uploadedBy) where.uploadedBy = uploadedBy; - if (resolution) where.resolution = resolution; - if (is_public !== undefined) where.is_public = is_public === "true"; - - if (search) { - where[Op.or] = [ - { display_name: { [Op.iLike]: `%${search}%` } }, - { original_name: { [Op.iLike]: `%${search}%` } }, - { description: { [Op.iLike]: `%${search}%` } }, - ]; - } - - const offset = (parseInt(page) - 1) * parseInt(limit); - - const { count, rows } = await Asset.findAndCountAll({ - where, - order: [[sort_by, sort_dir.toUpperCase()]], - limit: parseInt(limit), - offset, - }); - - return res.status(200).json({ - data: rows, - pagination: { - total: count, - page: parseInt(page), - limit: parseInt(limit), - totalPages: Math.ceil(count / parseInt(limit)), + 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 res.status(500).json({ message: "Internal server error." }); + return R.error(res, "Could not retrieve assets.", 500); } }; @@ -103,28 +89,57 @@ exports.getAsset = async (req, res) => { try { const { assetId } = req.params; if (!assetId || assetId === "undefined") { - return res.status(400).json({ message: "Invalid asset ID." }); + return R.error(res, "Invalid asset ID.", 400); } const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, }); - if (!asset) return res.status(404).json({ message: "Asset not found." }); + if (!asset) return R.error(res, "Asset not found.", 404); - return res.status(200).json({ data: asset }); + return R.success(res, "Asset retrieved.", { data: asset }); } catch (err) { console.error("[ASSET][GET ONE]", err); - return res.status(500).json({ message: "Internal server error." }); + 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 { - const file = req.file; - if (!file) return res.status(400).json({ message: "No file uploaded." }); + // ── 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, @@ -137,214 +152,471 @@ exports.uploadAsset = async (req, res) => { storage_bucket, storage_key, uploadedBy, - file_url: bodyFileUrl, - - // video metadata — from ffprobe pipeline or client - width, - height, - duration, - frame_rate, - bitrate, - video_codec, - audio_codec, - thumbnail_url, } = req.body; if (!uploadedBy) { - return res.status(400).json({ message: "uploadedBy is required." }); + 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; + 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 parsedWidth = width ? parseInt(width) : null; - const parsedHeight = height ? parseInt(height) : null; - - const resolution = file_type === "video" - ? resolveResolution(parsedWidth, parsedHeight) - : null; - - const file_url = storage_provider === "local" - ? `/uploads/${file.filename}` - : bodyFileUrl; - - if (!file_url) { - return res.status(400).json({ message: "file_url is required for non-local storage." }); + if (file_type === "video" && !thumbFile) { + return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400); } - 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: parsedWidth, - height: parsedHeight, - duration: duration ? parseFloat(duration) : null, - frame_rate: frame_rate ? parseFloat(frame_rate) : null, - bitrate: bitrate ? parseInt(bitrate) : null, - video_codec: video_codec || null, - audio_codec: audio_codec || null, - thumbnail_url: thumbnail_url || null, - resolution, - description, - storage_provider, - storage_bucket: storage_bucket || null, - storage_key: storage_key || file.filename, - is_public, - access_level, - owner_type: owner_type || null, - owner_id: owner_id || null, - uploadedBy, - }); + 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 + } - return res.status(201).json({ data: asset }); } catch (err) { console.error("[ASSET][UPLOAD]", err); - return res.status(500).json({ message: "Internal server error." }); + + 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 { thumbnail_url } = req.body; + const thumbFile = req.files?.thumbnail?.[0] ?? req.file; - if (!thumbnail_url) { - return res.status(400).json({ message: "thumbnail_url is required." }); + if (!thumbFile) { + return R.error(res, "No thumbnail file uploaded. Include it as the 'thumbnail' field.", 400); } - const asset = await Asset.findOne({ - where: { asset_id: assetId, ...notDeleted }, - }); - if (!asset) return res.status(404).json({ message: "Asset not found." }); + if (!thumbFile.buffer) { + return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); + } - asset.thumbnail_url = thumbnail_url; - await asset.save(); + // ── 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 }); - return res.status(200).json({ data: asset }); } catch (err) { + if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); console.error("[ASSET][UPDATE THUMBNAIL]", err); - return res.status(500).json({ message: "Internal server error." }); + 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") { - return res.status(400).json({ message: "Invalid asset ID." }); + 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 }, + where: { asset_id: assetId, ...notDeleted }, + transaction: t, }); - if (!asset) return res.status(404).json({ message: "Asset not found." }); + 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", + "owner_type", "owner_id", + "is_public", "access_level", "thumbnail_url", - "width", "height", "duration", - "frame_rate", "bitrate", - "video_codec", "audio_codec", + "width", "height", "duration", + "frame_rate", "bitrate", + "video_codec", "audio_codec", ]; allowed.forEach((field) => { if (req.body[field] !== undefined) asset[field] = req.body[field]; }); - // Re-derive resolution if dimensions were updated if (req.body.width || req.body.height) { asset.resolution = resolveResolution(asset.width, asset.height); } - await asset.save(); - - return res.status(200).json({ data: asset }); + 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 res.status(500).json({ message: "Internal server error." }); + 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") { - return res.status(400).json({ message: "Invalid asset ID." }); + await t.rollback(); + return R.error(res, "Invalid asset ID.", 400); } const asset = await Asset.findOne({ - where: { asset_id: assetId, ...notDeleted }, + where: { asset_id: assetId, ...notDeleted }, + transaction: t, + lock: t.LOCK.UPDATE, }); - if (!asset) return res.status(404).json({ message: "Asset not found." }); + 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(); + await asset.save({ transaction: t }); - return res.status(200).json({ message: "Asset deleted." }); + 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 res.status(500).json({ message: "Internal server error." }); + 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) { - return res.status(400).json({ message: "ids must be a non-empty array." }); + 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 } }, + { + where: { asset_id: { [Op.in]: ids }, ...notDeleted }, + transaction: t, + }, ); - return res.status(200).json({ message: `${count} asset(s) deleted.` }); + 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 res.status(500).json({ message: "Internal server error." }); + 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 } }, + where: { asset_id: assetId, deletedAt: { [Op.not]: null } }, + transaction: t, + lock: t.LOCK.UPDATE, }); if (!asset) { - return res.status(404).json({ message: "Asset not found or not deleted." }); + await t.rollback(); + return R.error(res, "Asset not found or not deleted.", 404); } asset.deletedAt = null; asset.deletedBy = null; - await asset.save(); + await asset.save({ transaction: t }); - return res.status(200).json({ data: asset, message: "Asset restored." }); + 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 res.status(500).json({ message: "Internal server error." }); + return R.error(res, "Internal server error.", 500); } -}; \ No newline at end of file +}; + +/** + * 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; +} \ No newline at end of file diff --git a/controllers/admin/documentation/assets.md b/controllers/admin/documentation/assets.md index 114516d..e3d4e6c 100644 --- a/controllers/admin/documentation/assets.md +++ b/controllers/admin/documentation/assets.md @@ -1,380 +1,428 @@ -# Assets Controller Documentation +# Assets API -**File:** `controllers/admin/assets.controller.js` -**Base URL:** `/api/admin/assets` -**Guards:** `authenticate → requireAdmin() → adminLimiter` +Base path: `/api/admin/assets` +Controller: `controllers/admin/assets.controller.js` +Storage: Chibisafe (CDN) + PostgreSQL via Sequelize --- -## Table of Contents -- [Get All Assets](#get-all-assets) -- [Get Single Asset](#get-single-asset) -- [Upload Asset](#upload-asset) -- [Update Asset Metadata](#update-asset-metadata) -- [Update Thumbnail](#update-thumbnail) -- [Delete Asset](#delete-asset) -- [Bulk Delete Assets](#bulk-delete-assets) -- [Restore Asset](#restore-asset) +## Prerequisites + +### Multer setup + +The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`: + +```js +const multer = require("multer"); +const upload = multer({ storage: multer.memoryStorage() }); + +// Upload +router.post("/", upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]), assetsCtrl.uploadAsset); + +// Update thumbnail +router.patch("/:assetId/thumbnail", upload.fields([{ name: "thumbnail", maxCount: 1 }]), assetsCtrl.updateThumbnail); +``` + +### Environment variables + +```env +CHIBISAFE_BASE_URL=https://cdn.yourdomain.com +CHIBISAFE_API_KEY=your-api-key + +CHIBISAFE_ALBUM_AVATARS=uuid +CHIBISAFE_ALBUM_VIDEOS=uuid +CHIBISAFE_ALBUM_DOCUMENTS=uuid +CHIBISAFE_ALBUM_THUMBNAILS=uuid +CHIBISAFE_ALBUM_ARCHIVED=uuid +``` + +### Album routing + +`owner_type` is the single source of truth for which Chibisafe album a file lands in: + +| `owner_type` | Chibisafe album | Intended use | +|---|---|---| +| `avatar` | avatars | Profile pictures | +| `video` | videos | Course / content videos | +| `document` | documents | PDF, DOCX, PPT, TXT, etc. | +| `thumbnail` | thumbnails | Set automatically — do not send manually | +| `image` | *(none)* | General-purpose images | +| anything else | *(none)* | Unclassified | --- -## Get All Assets +## Endpoints -**`GET /api/admin/assets`** +--- -Returns a paginated list of non-deleted assets with optional filtering. +### GET `/` -### Query Parameters -| Parameter | Type | Required | Description | -|-------------|---------|----------|--------------------------------------------------| -| page | number | No | Page number. Default: `1` | -| limit | number | No | Records per page. Default: `20` | -| file_type | string | No | Filter by type: `image`, `video`, `document`, `other` | -| owner_type | string | No | Filter by owner type e.g. `User`, `Course` | -| owner_id | number | No | Filter by owner ID | -| uploadedBy | number | No | Filter by uploader user ID | -| is_public | boolean | No | Filter by visibility: `true` or `false` | -| resolution | string | No | Filter by resolution e.g. `1080p`, `720p` | -| search | string | No | Search by `display_name`, `original_name`, `description` | -| sort_by | string | No | Column to sort by. Default: `createdAt` | -| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` | +List all assets (paginated). + +**Query params** + +| Param | Required | Description | +|---|---|---| +| `page` | optional | Page number. Default: `1` | +| `limit` | optional | Items per page. Default: `10`, max: `1000` | +| `filters` | optional | JSON array of filter objects passed to `buildQuery` | +| `sort` | optional | JSON array of sort objects passed to `buildQuery` | + +**Response `200`** -### Response `200` ```json { - "status": "success", "message": "Assets retrieved.", - "data": { - "rows": [...], - "pagination": { - "total": 100, - "page": 1, - "limit": 20, - "totalPages": 5 - } - } + "data": [...], + "pagination": { + "page": 1, + "limit": 10, + "totalRecords": 42, + "totalPages": 5, + "hasPrevPage": false, + "hasNextPage": true + }, + "attributes": [...] } ``` +Soft-deleted assets are excluded automatically. Hidden fields (per `adminExclude`): `checksum`, `storage_bucket`, `storage_key`, `deletedBy`. + +--- + +### GET `/:assetId` + +Get a single asset by primary key. + +**URL params** + +| Param | Required | Description | +|---|---|---| +| `assetId` | required | Asset primary key (BIGINT) | + +**Responses** + +| Status | Description | +|---|---| +| `200` | `{ data: asset }` | +| `400` | Invalid asset ID | +| `404` | Asset not found | +| `500` | Internal server error | + +--- + +### POST `/` + +Upload a new asset. + +**Content-Type:** `multipart/form-data` + +#### File fields + +| Field | Required | Description | +|---|---|---| +| `file` | **required** | The main asset (image, video, document, etc.) | +| `thumbnail` | **required if video** | Cover image for the video. Ignored for non-video files. | + +#### Text fields + +| Field | Required | Default | Description | +|---|---|---|---| +| `uploadedBy` | **required** | — | User ID (BIGINT) of the uploader | +| `storage_provider` | **required** | — | `chibisafe` \| `local` \| `s3` \| `gcs` \| `cloudinary` | +| `owner_type` | optional | `null` | Determines album routing: `avatar`, `video`, `document`, `image` | +| `owner_id` | optional | `null` | ID of the owning entity (course ID, user ID, etc.) | +| `display_name` | optional | original filename | Human-readable name shown in the UI | +| `description` | optional | `null` | Free-text description | +| `is_public` | optional | `false` | `true` \| `false` | +| `access_level` | optional | `private` | `public` \| `private` \| `restricted` | +| `storage_bucket` | optional | `null` | Bucket name (S3 / GCS only) | +| `storage_key` | optional | `null` | Override storage key. Auto-set for Chibisafe (uses Chibisafe file UUID). | +| `file_url` | conditional | — | Required when `storage_provider` is not `local` or `chibisafe` | +| `width` | optional (non-video) | `null` | Image/document width in px. Ignored for videos. | +| `height` | optional (non-video) | `null` | Image/document height in px. Ignored for videos. | + +#### Auto-extracted fields (videos only — do not send) + +These are extracted server-side via **ffprobe** and will override anything the client sends: + +| Field | Source | Example | +|---|---|---| +| `width` | ffprobe | `1920` | +| `height` | ffprobe | `1080` | +| `resolution` | derived | `1080p`, `720p`, `4K` | +| `duration` | ffprobe | `281.49` (seconds) | +| `frame_rate` | ffprobe | `23.976` (fps) | +| `bitrate` | ffprobe | `447933` (bps) | +| `video_codec` | ffprobe | `H.264`, `H.265`, `AV1`, `VP9` | +| `audio_codec` | ffprobe | `AAC`, `MP3`, `Opus` | +| `thumbnail_url` | Chibisafe upload | CDN URL of the uploaded thumbnail | + +#### Transaction strategy + +``` +Phase 1 (no DB connection held — slow I/O): + ├─ Validate inputs + ├─ Upload main file to Chibisafe → track UUID for rollback + ├─ Run ffprobe on video buffer → extract metadata + └─ Upload thumbnail to Chibisafe → track UUID for rollback + +Phase 2 (transaction open ~milliseconds): + └─ Asset.create() → commit + +On Phase 2 failure: + └─ rollback DB + deleteFile() all tracked Chibisafe UUIDs +``` + +**Responses** + +| Status | Description | +|---|---| +| `201` | `{ data: asset }` — fully populated asset record | +| `400` | Missing `file`, `uploadedBy`, or `thumbnail` (for videos); buffer issues | +| `500` | DB or Chibisafe error — Chibisafe uploads are cleaned up automatically | + +#### Example — video upload (Postman) + +``` +POST /api/admin/assets +Content-Type: multipart/form-data + +file → (attach .mp4) +thumbnail → (attach .jpg) +uploadedBy → 1 +storage_provider → chibisafe +owner_type → video +owner_id → 10 +display_name → Intro to React +is_public → true +access_level → public +``` + +#### Example — avatar upload + +``` +POST /api/admin/assets +Content-Type: multipart/form-data + +file → (attach .jpg) +uploadedBy → 1 +storage_provider → chibisafe +owner_type → avatar +owner_id → 5 +``` + +#### Example — document upload + +``` +POST /api/admin/assets +Content-Type: multipart/form-data + +file → (attach .pdf) +uploadedBy → 1 +storage_provider → chibisafe +owner_type → document +owner_id → 7 +display_name → Module 1 Handout +``` + --- -## Get Single Asset +### PATCH `/:assetId/thumbnail` -**`GET /api/admin/assets/:assetId`** +Replace the thumbnail image of an existing asset by uploading a new file. -Returns a single non-deleted asset by ID. +**Content-Type:** `multipart/form-data` -### Path Parameters -| Parameter | Type | Required | Description | -|-----------|--------|----------|--------------| -| assetId | number | Yes | Asset ID | +**URL params** -### Response `200` -```json -{ - "status": "success", - "message": "Asset found.", - "data": { - "asset_id": 1, - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "original_name": "intro.mp4", - "display_name": "Course Intro Video", - "file_url": "/uploads/intro.mp4", - "file_size": 104857600, - "mime_type": "video/mp4", - "extension": "mp4", - "checksum": "a3f5...", - "file_type": "video", - "width": 1920, - "height": 1080, - "duration": 120.5, - "resolution": "1080p", - "frame_rate": 29.97, - "bitrate": 8000000, - "video_codec": "H.264", - "audio_codec": "AAC", - "thumbnail_url": "/uploads/thumbnails/intro.jpg", - "description": "Introduction to the course.", - "storage_provider": "local", - "storage_bucket": null, - "storage_key": "intro.mp4", - "is_public": true, - "access_level": "public", - "owner_type": "Course", - "owner_id": 3, - "uploadedBy": 1, - "deletedBy": null, - "createdAt": "2025-01-01T00:00:00.000Z", - "updatedAt": "2025-01-01T00:00:00.000Z", - "deletedAt": null - } -} -``` +| Param | Required | Description | +|---|---|---| +| `assetId` | **required** | Asset primary key | -### Response `404` -```json -{ - "status": "error", - "message": "Asset not found." -} -``` +**File field** + +| Field | Required | Description | +|---|---|---| +| `thumbnail` | **required** | New thumbnail image file | + +**How it works** + +1. Uploads the new thumbnail to Chibisafe (thumbnails album). +2. Updates `thumbnail_url` on the asset record. +3. Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails). + +> **Note:** Old thumbnail cleanup requires a `thumbnail_storage_key` column on the Asset model to track the previous Chibisafe file UUID. Without it, the old thumbnail remains on Chibisafe but the DB record is updated correctly. + +**Responses** + +| Status | Description | +|---|---| +| `200` | `{ data: asset }` — updated asset with new `thumbnail_url` | +| `400` | No thumbnail file attached | +| `404` | Asset not found | +| `500` | Internal server error | --- -## Upload Asset +### PUT `/:assetId` -**`POST /api/admin/assets/upload`** +Update asset metadata. **File uploads are blocked on this endpoint.** -Uploads a new asset. Expects `multipart/form-data`. -Video metadata (`width`, `height`, `duration`, etc.) should be extracted via **ffprobe** server-side or passed from the client. -`resolution` is **auto-derived** from `width` and `height` — do not pass it manually. +**Content-Type:** `application/json` -### Request `multipart/form-data` -| Field | Type | Required | Description | -|-----------------|---------|----------|----------------------------------------------------------| -| file | File | Yes | The file to upload | -| uploadedBy | number | Yes | User ID of the uploader | -| display_name | string | No | Display name shown on platform. Defaults to filename | -| description | string | No | Description of the asset | -| owner_type | string | No | Owning entity type e.g. `Course`, `User` | -| owner_id | number | No | Owning entity ID | -| is_public | boolean | No | Whether asset is publicly accessible. Default: `false` | -| access_level | string | No | `public`, `private`, `restricted`. Default: `private` | -| storage_provider| string | No | `local`, `s3`, `gcs`, `cloudinary`, `chibisafe`, `other`. Default: `local` | -| storage_bucket | string | No | Bucket/container name for cloud storage | -| storage_key | string | No | Object key/path in bucket | -| file_url | string | No* | Required for non-local storage providers | -| width | number | No | Video/image width in px | -| height | number | No | Video/image height in px | -| duration | number | No | Video duration in seconds | -| frame_rate | number | No | Video frame rate in fps | -| bitrate | number | No | Video bitrate in bps | -| video_codec | string | No | Video codec e.g. `H.264`, `H.265` | -| audio_codec | string | No | Audio codec e.g. `AAC`, `MP3` | -| thumbnail_url | string | No | URL of the video/document preview thumbnail | +**URL params** -### Resolution Auto-Derivation -| Height (px) | Derived Resolution | -|-------------|-------------------| -| ≥ 2160 | `4K` | -| ≥ 1440 | `1440p` | -| ≥ 1080 | `1080p` | -| ≥ 720 | `720p` | -| ≥ 480 | `480p` | -| ≥ 360 | `360p` | -| ≥ 240 | `240p` | -| Other | `{width}x{height}`| +| Param | Required | Description | +|---|---|---| +| `assetId` | **required** | Asset primary key | + +**Body** — all fields optional, send only what changes + +| Field | Type | Description | +|---|---|---| +| `display_name` | string | New display name | +| `description` | string | New description | +| `owner_type` | string | New owner type | +| `owner_id` | number | New owner entity ID | +| `is_public` | boolean | `true` \| `false` | +| `access_level` | string | `public` \| `private` \| `restricted` | +| `thumbnail_url` | string | Manually replace thumbnail URL (use PATCH `/thumbnail` to upload a file instead) | +| `width` | number | Width in px. Re-derives `resolution` automatically. | +| `height` | number | Height in px. Re-derives `resolution` automatically. | +| `duration` | number | Duration in seconds | +| `frame_rate` | number | fps | +| `bitrate` | number | bps | +| `video_codec` | string | e.g. `H.264` | +| `audio_codec` | string | e.g. `AAC` | + +**Responses** + +| Status | Description | +|---|---| +| `200` | `{ data: asset }` | +| `400` | Invalid ID or file attached to request | +| `404` | Asset not found | +| `500` | Internal server error | + +--- + +### DELETE `/:assetId` + +Soft-delete a single asset. + +Sets `deletedAt` on the DB record and moves the file to the **archived** album on Chibisafe (best-effort — non-fatal if Chibisafe is unavailable). + +**Content-Type:** `application/json` + +**URL params** + +| Param | Required | Description | +|---|---|---| +| `assetId` | **required** | Asset primary key | + +**Body** + +| Field | Required | Description | +|---|---|---| +| `deletedBy` | optional | User ID performing the delete | + +**Responses** + +| Status | Description | +|---|---| +| `200` | Asset deleted | +| `400` | Invalid asset ID | +| `404` | Asset not found | +| `500` | Internal server error | + +--- + +### DELETE `/bulk` + +Soft-delete multiple assets in one call. + +All matching Chibisafe files are moved to the **archived** album in a single API call. + +**Content-Type:** `application/json` + +**Body** + +| Field | Required | Description | +|---|---|---| +| `ids` | **required** | Non-empty array of asset IDs: `[1, 2, 3]` | +| `deletedBy` | optional | User ID performing the delete | + +**Responses** + +| Status | Description | +|---|---| +| `200` | `N asset(s) deleted` | +| `400` | `ids` missing or empty | +| `500` | Internal server error | + +--- + +### POST `/:assetId/restore` + +Restore a soft-deleted asset. + +Clears `deletedAt` and `deletedBy` on the DB record, then moves the file on Chibisafe from the **archived** album back to its home album based on `owner_type`: + +| `owner_type` | Moved back to | +|---|---| +| `video` | videos album | +| `avatar` | avatars album | +| `document` | documents album | +| `image` / anything else | no move (no dedicated album) | + +The Chibisafe move is best-effort — a failed move will not block or roll back the DB restore. + +**URL params** + +| Param | Required | Description | +|---|---|---| +| `assetId` | **required** | Asset primary key (must be soft-deleted) | + +**Body:** none required. + +**Responses** + +| Status | Description | +|---|---| +| `200` | `{ data: asset }` — Asset restored | +| `404` | Asset not found or not deleted | +| `500` | Internal server error | + +--- + +## Response shape + +All responses use `R.success` / `R.error` from `response.util`: -### Response `201` ```json +// success { - "status": "success", "message": "Asset uploaded.", - "data": { ...asset } + "data": { ... } } -``` -### Response `400` -```json +// error { - "status": "error", - "message": "No file uploaded." + "message": "Asset not found.", + "status": 404 } ``` --- -## Update Asset Metadata +## Related files -**`PUT /api/admin/assets/:assetId`** - -Updates metadata of an existing asset. File replacement is not supported — upload a new asset instead. -`resolution` is **auto-re-derived** if `width` or `height` is updated. - -### Path Parameters -| Parameter | Type | Required | Description | -|-----------|--------|----------|-------------| -| assetId | number | Yes | Asset ID | - -### Request Body `application/json` -| Field | Type | Required | Description | -|--------------|---------|----------|------------------------------------------| -| display_name | string | No | Updated display name | -| description | string | No | Updated description | -| owner_type | string | No | Updated owner type | -| owner_id | number | No | Updated owner ID | -| is_public | boolean | No | Updated visibility | -| access_level | string | No | Updated access level | -| thumbnail_url | string | No | Updated thumbnail URL | -| width | number | No | Updated width — re-derives resolution | -| height | number | No | Updated height — re-derives resolution | -| duration | number | No | Updated duration | -| frame_rate | number | No | Updated frame rate | -| bitrate | number | No | Updated bitrate | -| video_codec | string | No | Updated video codec | -| audio_codec | string | No | Updated audio codec | - -### Response `200` -```json -{ - "status": "success", - "message": "Asset updated.", - "data": { ...asset } -} -``` - ---- - -## Update Thumbnail - -**`PATCH /api/admin/assets/:assetId/thumbnail`** - -Updates only the thumbnail of an asset. Useful for video platforms where users frequently change the video cover independently. - -### Path Parameters -| Parameter | Type | Required | Description | -|-----------|--------|----------|-------------| -| assetId | number | Yes | Asset ID | - -### Request Body `application/json` -| Field | Type | Required | Description | -|--------------|--------|----------|-------------------------| -| thumbnail_url | string | Yes | New thumbnail URL | - -### Response `200` -```json -{ - "status": "success", - "message": "Thumbnail updated.", - "data": { ...asset } -} -``` - -### Response `400` -```json -{ - "status": "error", - "message": "thumbnail_url is required." -} -``` - ---- - -## Delete Asset - -**`DELETE /api/admin/assets/:assetId`** - -Soft deletes a single asset by setting `deletedAt` and `deletedBy`. - -### Path Parameters -| Parameter | Type | Required | Description | -|-----------|--------|----------|-------------| -| assetId | number | Yes | Asset ID | - -### Request Body `application/json` -| Field | Type | Required | Description | -|----------|--------|----------|--------------------------------| -| deletedBy | number | No | User ID of who deleted the asset | - -### Response `200` -```json -{ - "status": "success", - "message": "Asset deleted." -} -``` - ---- - -## Bulk Delete Assets - -**`DELETE /api/admin/assets/bulk`** - -Soft deletes multiple assets at once. - -### Request Body `application/json` -| Field | Type | Required | Description | -|----------|----------|----------|----------------------------------| -| ids | number[] | Yes | Array of asset IDs to delete | -| deletedBy | number | No | User ID of who deleted the assets | - -### Response `200` -```json -{ - "status": "success", - "message": "3 asset(s) deleted." -} -``` - -### Response `400` -```json -{ - "status": "error", - "message": "ids must be a non-empty array." -} -``` - ---- - -## Restore Asset - -**`PATCH /api/admin/assets/:assetId/restore`** - -Restores a soft-deleted asset by clearing `deletedAt` and `deletedBy`. - -### Path Parameters -| Parameter | Type | Required | Description | -|-----------|--------|----------|-------------| -| assetId | number | Yes | Asset ID | - -### Response `200` -```json -{ - "status": "success", - "message": "Asset restored.", - "data": { ...asset } -} -``` - -### Response `404` -```json -{ - "status": "error", - "message": "Asset not found or not deleted." -} -``` - ---- - -## Error Responses - -All endpoints return the following on server error: - -```json -{ - "status": "error", - "message": "Internal server error." -} -``` - ---- - -## File Size Limits - -| Type | Max Size | -|----------|----------| -| Images | 10 GB | -| Videos | 10 GB | -| Documents| 10 GB | - -> Limit is applied at the multer middleware level. Adjust in `assets.routes.js` if needed. - ---- - -## Notes - -- **File replacement** is not supported. To replace a file, delete the old asset and upload a new one. -- **Checksum** (SHA-256) is computed on upload for duplicate detection. -- **Polymorphic ownership** via `owner_type` + `owner_id` allows any entity (`Course`, `User`, `Post`, etc.) to own assets without a direct foreign key. -- **Resolution** is always auto-derived from `width` and `height` — never set manually. -- **Soft delete** sets `deletedAt` timestamp. Assets are excluded from all queries unless explicitly queried with `paranoid: false`. \ No newline at end of file +| File | Purpose | +|---|---| +| `models/assets/assets.mdl.js` | Sequelize model | +| `models/assets/assets.attributes.js` | Exclude sets, paginate config | +| `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) | +| `services/ffprobe.service.js` | ffprobe metadata extraction for videos | +| `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` | +| `utils/response.util.js` | `R.success` / `R.error` response helpers | \ No newline at end of file diff --git a/models/assets/assets.attributes.js b/models/assets/assets.attributes.js new file mode 100644 index 0000000..a56fc9b --- /dev/null +++ b/models/assets/assets.attributes.js @@ -0,0 +1,49 @@ +// models/assets/assets.attributes.js + +// ─── Exclude sets ───────────────────────────────────────────────────────────── + +// Fields hidden from all roles (sensitive / internal storage details) +const excludeAttributes = [ + "checksum", // internal integrity hash, not useful to clients + "storage_bucket", // internal storage config + "storage_key", // internal Chibisafe / S3 key + "deletedBy", // exposed via audit subquery as a name instead +]; + +// Admins see everything except the base excludes +const adminExclude = [ + ...excludeAttributes, +]; + +// Regular users also cannot see audit trails or soft-delete info +const userExclude = [ + ...excludeAttributes, + "uploadedBy", + "deletedAt", +]; + +// ─── No JSONB columns on assets ─────────────────────────────────────────────── +// Assets has no JSONB columns so jsonbSchemas stays empty. +const jsonbSchemas = {}; + +// ─── Computed attributes ────────────────────────────────────────────────────── +// Add any SQL-computed fields here (e.g. a view count join). +// Format: { key, label, type, order, literal } +const computedAttributes = [ + // Example: + // { + // key: "viewCount", + // label: "Views", + // type: "number", + // order: 99, + // literal: `(SELECT COUNT(*) FROM "asset_views" WHERE "asset_views"."asset_id" = "Asset"."asset_id")`, + // }, +]; + +module.exports = { + excludeAttributes, + adminExclude, + userExclude, + jsonbSchemas, + computedAttributes, +}; \ No newline at end of file diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index 9f1c9f0..9df2fed 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -59,7 +59,7 @@ const Asset = sequelize.define("Asset", { }, // ─── Polymorphic ownership ──────────────────────────────────────────────── - owner_type: { type: DataTypes.STRING(100) }, // e.g. "Course", "Channel", "Post", "User" + owner_type: { type: DataTypes.STRING(100) }, // avatar, document, video, image owner_id: { type: DataTypes.BIGINT }, // ─── Who did what ───────────────────────────────────────────────────────── diff --git a/package-lock.json b/package-lock.json index bede074..86cc89b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,9 @@ "express-rate-limit": "^6.10.0", "express-session": "^1.17.3", "express-validator": "^7.0.1", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.5", "google-auth-library": "^9.0.0", "jsonwebtoken": "^9.0.1", "multer": "^2.1.1", @@ -173,6 +176,17 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -400,6 +414,18 @@ "node": ">=0.10.0" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -578,6 +604,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -684,6 +719,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -807,6 +857,12 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -838,6 +894,36 @@ "node": ">= 0.8" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1042,6 +1128,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1211,6 +1312,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -2507,6 +2614,18 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/package.json b/package.json index 6f1dd9c..ad21f34 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "express-rate-limit": "^6.10.0", "express-session": "^1.17.3", "express-validator": "^7.0.1", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.5", "google-auth-library": "^9.0.0", "jsonwebtoken": "^9.0.1", "multer": "^2.1.1", diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index e2f3900..18ead7f 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -1,22 +1,19 @@ const express = require('express'); -const router = require('express').Router(); -const multer = require('multer'); +const router = require('express').Router(); +const multer = require('multer'); +const upload = multer({ storage: multer.memoryStorage() }); -const controller = require('../../controllers/admin/assets.controller'); +const controller = require('../../controllers/admin/assets.controller'); const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10GB -}); -router.get ('/', controller.getAssets); -router.get ('/:assetId', controller.getAsset); -router.post ('/upload', upload.single('file'), sensitiveOpsLimiter, controller.uploadAsset); -router.put ('/:assetId', sensitiveOpsLimiter, controller.updateAsset); -router.patch ('/:assetId/thumbnail', sensitiveOpsLimiter, controller.updateThumbnail); -router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets); -router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset); -router.patch ('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); +router.get('/', controller.getAssets); +router.get('/:assetId', controller.getAsset); +router.post( "/upload", upload.fields([ { name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }, ]), controller.uploadAsset, ); +router.put('/:assetId', sensitiveOpsLimiter, controller.updateAsset); +router.patch('/:assetId/thumbnail', sensitiveOpsLimiter, upload.fields([{ name: 'thumbnail', maxCount: 1 }]), controller.updateThumbnail); +router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets); +router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset); +router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); module.exports = router; \ No newline at end of file diff --git a/services/chibisafe.service.js b/services/chibisafe.service.js new file mode 100644 index 0000000..392f7d4 --- /dev/null +++ b/services/chibisafe.service.js @@ -0,0 +1,213 @@ +// services/chibisafe.service.js +// +// Wraps the Chibisafe REST API. +// +// Environment variables expected: +// CHIBISAFE_BASE_URL – e.g. https://cdn.yourdomain.com +// CHIBISAFE_API_KEY – your personal / service-account API key +// CHIBISAFE_ALBUM_AVATARS – album UUID for avatar images +// CHIBISAFE_ALBUM_VIDEOS – album UUID for videos +// CHIBISAFE_ALBUM_DOCUMENTS – album UUID for documents (pdf, docx, ppt, txt…) +// CHIBISAFE_ALBUM_THUMBNAILS – album UUID for video thumbnails +// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album + +const FormData = require("form-data"); +const fetch = require("node-fetch"); // npm i node-fetch@2 (CJS-compatible) + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const BASE_URL = (process.env.CHIBISAFE_BASE_URL || "").replace(/\/$/, ""); +const API_KEY = process.env.CHIBISAFE_API_KEY || ""; + +const ALBUMS = { + avatars: process.env.CHIBISAFE_ALBUM_AVATARS || null, + videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null, + documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null, + thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null, + archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null, +}; + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Resolve which Chibisafe album UUID should receive a file based on owner_type. + * owner_type is the single source of truth for album routing: + * + * "avatar" → avatars album (profile pictures) + * "video" → videos album (course/content videos) + * "document" → documents album (pdf, docx, ppt, txt…) + * "thumbnail" → thumbnails album (video cover images) + * "image" → no album (general-purpose images) + * anything else / null → no album + * + * @param {string} ownerType – value of the asset's owner_type field + * @returns {string|null} + */ +function resolveAlbumUuid(ownerType = "") { + switch (ownerType) { + case "avatar": return ALBUMS.avatars; + case "video": return ALBUMS.videos; + case "document": return ALBUMS.documents; + case "thumbnail": return ALBUMS.thumbnails; + default: return null; // "image" and unknowns → no album + } +} + +/** + * Build default headers for every Chibisafe request. + */ +function baseHeaders(extra = {}) { + return { + "x-api-key": API_KEY, + ...extra, + }; +} + +/** + * Thin fetch wrapper that throws a descriptive error on non-2xx. + */ +async function chibiRequest(path, options = {}) { + const url = `${BASE_URL}${path}`; + const res = await fetch(url, options); + + let body; + try { + body = await res.json(); + } catch { + body = {}; + } + + if (!res.ok) { + const msg = body?.message || body?.error || res.statusText; + const err = new Error(`[Chibisafe] ${res.status} – ${msg}`); + err.status = res.status; + err.chibiBody = body; + throw err; + } + + return body; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Upload a file to Chibisafe, optionally straight into a typed album. + * + * @param {object} opts + * @param {Buffer} opts.buffer – raw file bytes + * @param {string} opts.originalname – original filename (for Content-Disposition) + * @param {string} opts.mimetype – MIME type + * @param {string} opts.ownerType – asset owner_type value used to resolve the album + * ("avatar" | "video" | "document" | "thumbnail" | "image") + * + * @returns {Promise<{ uuid: string, url: string, name: string }>} + */ +async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) { + if (!BASE_URL || !API_KEY) { + throw new Error("[Chibisafe] CHIBISAFE_BASE_URL or CHIBISAFE_API_KEY is not configured."); + } + + const albumUuid = resolveAlbumUuid(ownerType); + + const form = new FormData(); + form.append("file", buffer, { + filename: originalname, + contentType: mimetype, + }); + + const headers = { + ...baseHeaders(), + ...form.getHeaders(), + // Pass the album UUID at upload time so the file lands in the right album + // in a single round-trip (official Chibisafe header). + ...(albumUuid ? { albumuuid: albumUuid } : {}), + }; + + const data = await chibiRequest("/api/upload", { + method: "POST", + headers, + body: form, + }); + + // Chibisafe returns: { name, uuid, url, ... } + return { + uuid: data.uuid, + url: data.url, + name: data.name, + }; +} + +/** + * Permanently delete one file from Chibisafe by its UUID. + * Used for rollback cleanup when a DB transaction fails after a successful upload. + * + * @param {string} uuid – Chibisafe file UUID + * @returns {Promise} + */ +async function deleteFile(uuid) { + await chibiRequest(`/api/file/${uuid}`, { + method: "DELETE", + headers: baseHeaders(), + }); +} + +/** + * Move one or more files into the "archived" album (soft-delete equivalent). + * Preserves the file on Chibisafe but keeps it out of active albums. + * + * @param {string|string[]} uuids – Chibisafe file UUID(s) + * @returns {Promise} + */ +async function archiveFiles(uuids) { + if (!ALBUMS.archived) { + throw new Error("[Chibisafe] CHIBISAFE_ALBUM_ARCHIVED is not configured."); + } + + const ids = Array.isArray(uuids) ? uuids : [uuids]; + if (!ids.length) return; + + await chibiRequest("/api/files/album/add", { + method: "POST", + headers: { + ...baseHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + files: ids, + albumUuid: ALBUMS.archived, + }), + }); +} + +/** + * Move one or more files into a specific album by UUID. + * Used internally; you can also call it directly for custom album operations. + * + * @param {string|string[]} uuids + * @param {string} albumUuid + * @returns {Promise} + */ +async function addFilesToAlbum(uuids, albumUuid) { + const ids = Array.isArray(uuids) ? uuids : [uuids]; + + await chibiRequest("/api/files/album/add", { + method: "POST", + headers: { + ...baseHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + files: ids, + albumUuid, + }), + }); +} + +module.exports = { + uploadFile, + deleteFile, + archiveFiles, + addFilesToAlbum, + ALBUMS, + resolveAlbumUuid, +}; \ No newline at end of file diff --git a/services/ffprobe.service.js b/services/ffprobe.service.js new file mode 100644 index 0000000..86ddc6d --- /dev/null +++ b/services/ffprobe.service.js @@ -0,0 +1,140 @@ +// services/ffprobe.service.js +// +// Extracts video metadata only (dimensions, duration, codecs, bitrate, frame rate). +// Thumbnail is provided by the client as a separate uploaded file — not generated here. +// +// Dependencies: +// npm install fluent-ffmpeg ffprobe-static + +const ffmpeg = require("fluent-ffmpeg"); +const ffprobeStatic = require("ffprobe-static"); +const os = require("os"); +const path = require("path"); +const fs = require("fs"); + +// Use system ffprobe if available, otherwise fall back to the static binary. +try { + const { execSync } = require("child_process"); + execSync("which ffprobe", { stdio: "ignore" }); + // system binary found — fluent-ffmpeg picks it up automatically +} catch { + ffmpeg.setFfprobePath(ffprobeStatic.path); +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function writeTempFile(buffer, extension) { + const tmpPath = path.join( + os.tmpdir(), + `asset_${Date.now()}_${Math.random().toString(36).slice(2)}.${extension}`, + ); + fs.writeFileSync(tmpPath, buffer); + return tmpPath; +} + +function cleanupTempFile(filePath) { + try { fs.unlinkSync(filePath); } catch { /* ignore */ } +} + +/** + * Parse frame rate from ffprobe's fraction string (e.g. "30/1", "24000/1001"). + */ +function parseFrameRate(rateStr = "") { + if (!rateStr || rateStr === "0/0") return null; + const [num, den] = rateStr.split("/").map(Number); + if (!den || den === 0) return num || null; + return parseFloat((num / den).toFixed(3)); +} + +/** + * Resolve human-readable resolution label. Mirrors the controller helper. + */ +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}`; +} + +function probeFile(filePath) { + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) return reject(err); + resolve(metadata); + }); + }); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Extract video metadata from a Buffer. + * Thumbnail is NOT generated here — the client uploads it as a separate file. + * + * @param {object} opts + * @param {Buffer} opts.buffer raw video bytes (multer memoryStorage) + * @param {string} opts.extension file extension without dot, e.g. "mp4" + * + * @returns {Promise} + * + * @typedef {object} VideoMeta + * @property {number|null} width + * @property {number|null} height + * @property {string|null} resolution "1080p", "720p", "4K", … + * @property {number|null} duration seconds + * @property {number|null} frame_rate fps + * @property {number|null} bitrate bps + * @property {string|null} video_codec "H.264", "H.265", … + * @property {string|null} audio_codec "AAC", "MP3", … + */ +async function extractVideoMeta({ buffer, extension }) { + const tmpPath = writeTempFile(buffer, extension || "mp4"); + + try { + const raw = await probeFile(tmpPath); + + const videoStream = raw.streams?.find((s) => s.codec_type === "video") || {}; + const audioStream = raw.streams?.find((s) => s.codec_type === "audio") || {}; + const format = raw.format || {}; + + const width = videoStream.width || null; + const height = videoStream.height || null; + const duration = parseFloat(format.duration || videoStream.duration || 0) || null; + const bitrate = parseInt(format.bit_rate || videoStream.bit_rate || 0, 10) || null; + const frame_rate = parseFrameRate(videoStream.r_frame_rate || videoStream.avg_frame_rate); + const resolution = resolveResolution(width, height); + + const VIDEO_CODEC_MAP = { + h264: "H.264", avc1: "H.264", + h265: "H.265", hevc: "H.265", + vp8: "VP8", vp9: "VP9", + av1: "AV1", + }; + const AUDIO_CODEC_MAP = { + aac: "AAC", + mp3: "MP3", mp3float: "MP3", + opus: "Opus", + vorbis: "Vorbis", + flac: "FLAC", + pcm_s16le: "PCM", + }; + + const video_codec = VIDEO_CODEC_MAP[(videoStream.codec_name || "").toLowerCase()] + || videoStream.codec_name || null; + const audio_codec = AUDIO_CODEC_MAP[(audioStream.codec_name || "").toLowerCase()] + || audioStream.codec_name || null; + + return { width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec }; + + } finally { + cleanupTempFile(tmpPath); + } +} + +module.exports = { extractVideoMeta }; \ No newline at end of file