This commit is contained in:
rgrgogu
2026-05-12 00:09:09 +08:00
parent 4e6017c79b
commit d3ff140688
9 changed files with 1350 additions and 509 deletions
+429 -157
View File
@@ -1,9 +1,19 @@
// controllers/admin/assets.controller.js // controllers/admin/assets.controller.js
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const Asset = require("../../models/assets/assets.mdl"); 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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -38,62 +48,38 @@ function resolveResolution(width, height) {
return `${width}x${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 ────────────────────────────────────────────────────────────────── // ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAssets = async (req, res) => { exports.getAssets = async (req, res) => {
try { try {
const { const result = await paginate(Asset, req, {
page = 1, excludeAttributes: adminExclude,
limit = 20, jsonbSchemas,
file_type, computedAttributes,
owner_type, findOptions: {
owner_id, where: { deletedAt: null },
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)),
}, },
}); });
return R.success(res, "Assets retrieved.", result);
} catch (err) { } catch (err) {
console.error("[ASSET][GET ALL]", 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 { try {
const { assetId } = req.params; const { assetId } = req.params;
if (!assetId || assetId === "undefined") { 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({ const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted }, 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) { } catch (err) {
console.error("[ASSET][GET ONE]", 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 ─────────────────────────────────────────────────────────────────── // ─── 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) => { 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 { try {
const file = req.file; // ── Phase 1a: Validate inputs ─────────────────────────────────────────────
if (!file) return res.status(400).json({ message: "No file uploaded." });
const file = req.files?.file?.[0];
const thumbFile = req.files?.thumbnail?.[0];
if (!file) return R.error(res, "No file uploaded.", 400);
const { const {
display_name, display_name,
@@ -137,214 +152,471 @@ exports.uploadAsset = async (req, res) => {
storage_bucket, storage_bucket,
storage_key, storage_key,
uploadedBy, 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; } = req.body;
if (!uploadedBy) { 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 mime_type = file.mimetype;
const file_type = resolveFileType(mime_type); const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname); const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null; const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const parsedWidth = width ? parseInt(width) : null; if (file_type === "video" && !thumbFile) {
const parsedHeight = height ? parseInt(height) : null; return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400);
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." });
} }
const asset = await Asset.create({ if (storage_provider === "chibisafe" && !file.buffer) {
original_name: file.originalname, return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400);
display_name: display_name || file.originalname, }
file_url,
file_size: file.size, // ── Phase 1b: Upload main file to Chibisafe ───────────────────────────────
mime_type, // Heavy I/O — done BEFORE opening any DB transaction.
extension,
checksum, let file_url = null;
file_type, let chibi_uuid = null;
width: parsedWidth,
height: parsedHeight, if (storage_provider === "chibisafe") {
duration: duration ? parseFloat(duration) : null, // owner_type is the single source of truth for album routing.
frame_rate: frame_rate ? parseFloat(frame_rate) : null, // The service maps: avatar→avatars, video→videos, document→documents,
bitrate: bitrate ? parseInt(bitrate) : null, // thumbnail→thumbnails, image / anything else → no album.
video_codec: video_codec || null, const chibiResult = await chibi.uploadFile({
audio_codec: audio_codec || null, buffer: file.buffer,
thumbnail_url: thumbnail_url || null, originalname: file.originalname,
resolution, mimetype: mime_type,
description, ownerType: owner_type || "",
storage_provider, });
storage_bucket: storage_bucket || null,
storage_key: storage_key || file.filename, file_url = chibiResult.url;
is_public, chibi_uuid = chibiResult.uuid;
access_level, uploadedChibiUuids.push(chibi_uuid);
owner_type: owner_type || null,
owner_id: owner_id || null, } else {
uploadedBy, 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) { } catch (err) {
console.error("[ASSET][UPLOAD]", 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 ───────────────────────────────────────────────────────── // ─── 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) => { exports.updateThumbnail = async (req, res) => {
let newThumbUuid = null;
try { try {
const { assetId } = req.params; const { assetId } = req.params;
const { thumbnail_url } = req.body; const thumbFile = req.files?.thumbnail?.[0] ?? req.file;
if (!thumbnail_url) { if (!thumbFile) {
return res.status(400).json({ message: "thumbnail_url is required." }); return R.error(res, "No thumbnail file uploaded. Include it as the 'thumbnail' field.", 400);
} }
const asset = await Asset.findOne({ if (!thumbFile.buffer) {
where: { asset_id: assetId, ...notDeleted }, return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
}); }
if (!asset) return res.status(404).json({ message: "Asset not found." });
asset.thumbnail_url = thumbnail_url; // ── Phase 1: Fetch asset + upload new thumbnail ───────────────────────────
await asset.save(); // 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) { } catch (err) {
if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]);
console.error("[ASSET][UPDATE THUMBNAIL]", err); 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 ────────────────────────────────────────────────────────── // ─── UPDATE METADATA ──────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => { exports.updateAsset = async (req, res) => {
const t = await sequelize.transaction();
try { try {
const { assetId } = req.params; const { assetId } = req.params;
if (!assetId || assetId === "undefined") { 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({ 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 = [ const allowed = [
"display_name", "description", "display_name", "description",
"owner_type", "owner_id", "owner_type", "owner_id",
"is_public", "access_level", "is_public", "access_level",
"thumbnail_url", "thumbnail_url",
"width", "height", "duration", "width", "height", "duration",
"frame_rate", "bitrate", "frame_rate", "bitrate",
"video_codec", "audio_codec", "video_codec", "audio_codec",
]; ];
allowed.forEach((field) => { allowed.forEach((field) => {
if (req.body[field] !== undefined) asset[field] = req.body[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) { if (req.body.width || req.body.height) {
asset.resolution = resolveResolution(asset.width, asset.height); asset.resolution = resolveResolution(asset.width, asset.height);
} }
await asset.save(); await asset.save({ transaction: t });
await t.commit();
return res.status(200).json({ data: asset }); return R.success(res, "Asset updated.", { data: asset });
} catch (err) { } catch (err) {
try { await t.rollback(); } catch { /* already rolled back */ }
console.error("[ASSET][UPDATE]", err); 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) ───────────────────────────────────────────────────── // ─── SOFT DELETE (single) ─────────────────────────────────────────────────────
exports.deleteAsset = async (req, res) => { exports.deleteAsset = async (req, res) => {
const t = await sequelize.transaction();
try { try {
const { assetId } = req.params; const { assetId } = req.params;
if (!assetId || assetId === "undefined") { 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({ 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.deletedAt = new Date();
asset.deletedBy = req.body.deletedBy ?? null; 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) { } catch (err) {
try { await t.rollback(); } catch { /* already rolled back */ }
console.error("[ASSET][DELETE]", err); 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) ─────────────────────────────────────────────────────── // ─── SOFT DELETE (bulk) ───────────────────────────────────────────────────────
exports.deleteAssets = async (req, res) => { exports.deleteAssets = async (req, res) => {
const t = await sequelize.transaction();
try { try {
const { ids, deletedBy } = req.body; const { ids, deletedBy } = req.body;
if (!Array.isArray(ids) || !ids.length) { 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( const [count] = await Asset.update(
{ deletedAt: new Date(), deletedBy: deletedBy ?? null }, { 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) { } catch (err) {
try { await t.rollback(); } catch { /* already rolled back */ }
console.error("[ASSET][BULK DELETE]", err); 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) ───────────────────────────────────────────────────────── // ─── 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) => { exports.restoreAsset = async (req, res) => {
const t = await sequelize.transaction();
try { try {
const { assetId } = req.params; const { assetId } = req.params;
const asset = await Asset.findOne({ 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) { 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.deletedAt = null;
asset.deletedBy = 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) { } catch (err) {
try { await t.rollback(); } catch { /* already rolled back */ }
console.error("[ASSET][RESTORE]", err); console.error("[ASSET][RESTORE]", err);
return res.status(500).json({ message: "Internal server error." }); 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;
}
+384 -336
View File
@@ -1,380 +1,428 @@
# Assets Controller Documentation # Assets API
**File:** `controllers/admin/assets.controller.js` Base path: `/api/admin/assets`
**Base URL:** `/api/admin/assets` Controller: `controllers/admin/assets.controller.js`
**Guards:** `authenticate → requireAdmin() → adminLimiter` Storage: Chibisafe (CDN) + PostgreSQL via Sequelize
--- ---
## Table of Contents ## Prerequisites
- [Get All Assets](#get-all-assets)
- [Get Single Asset](#get-single-asset) ### Multer setup
- [Upload Asset](#upload-asset)
- [Update Asset Metadata](#update-asset-metadata) The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`:
- [Update Thumbnail](#update-thumbnail)
- [Delete Asset](#delete-asset) ```js
- [Bulk Delete Assets](#bulk-delete-assets) const multer = require("multer");
- [Restore Asset](#restore-asset) 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 List all assets (paginated).
| Parameter | Type | Required | Description |
|-------------|---------|----------|--------------------------------------------------| **Query params**
| page | number | No | Page number. Default: `1` |
| limit | number | No | Records per page. Default: `20` | | Param | Required | Description |
| file_type | string | No | Filter by type: `image`, `video`, `document`, `other` | |---|---|---|
| owner_type | string | No | Filter by owner type e.g. `User`, `Course` | | `page` | optional | Page number. Default: `1` |
| owner_id | number | No | Filter by owner ID | | `limit` | optional | Items per page. Default: `10`, max: `1000` |
| uploadedBy | number | No | Filter by uploader user ID | | `filters` | optional | JSON array of filter objects passed to `buildQuery` |
| is_public | boolean | No | Filter by visibility: `true` or `false` | | `sort` | optional | JSON array of sort objects passed to `buildQuery` |
| resolution | string | No | Filter by resolution e.g. `1080p`, `720p` |
| search | string | No | Search by `display_name`, `original_name`, `description` | **Response `200`**
| sort_by | string | No | Column to sort by. Default: `createdAt` |
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
### Response `200`
```json ```json
{ {
"status": "success",
"message": "Assets retrieved.", "message": "Assets retrieved.",
"data": { "data": [...],
"rows": [...], "pagination": {
"pagination": { "page": 1,
"total": 100, "limit": 10,
"page": 1, "totalRecords": 42,
"limit": 20, "totalPages": 5,
"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 **URL params**
| Parameter | Type | Required | Description |
|-----------|--------|----------|--------------|
| assetId | number | Yes | Asset ID |
### Response `200` | Param | Required | Description |
```json |---|---|---|
{ | `assetId` | **required** | Asset primary key |
"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
}
}
```
### Response `404` **File field**
```json
{ | Field | Required | Description |
"status": "error", |---|---|---|
"message": "Asset not found." | `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`. **Content-Type:** `application/json`
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.
### Request `multipart/form-data` **URL params**
| 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 |
### Resolution Auto-Derivation | Param | Required | Description |
| Height (px) | Derived Resolution | |---|---|---|
|-------------|-------------------| | `assetId` | **required** | Asset primary key |
| ≥ 2160 | `4K` |
| ≥ 1440 | `1440p` | **Body** — all fields optional, send only what changes
| ≥ 1080 | `1080p` |
| ≥ 720 | `720p` | | Field | Type | Description |
| ≥ 480 | `480p` | |---|---|---|
| ≥ 360 | `360p` | | `display_name` | string | New display name |
| ≥ 240 | `240p` | | `description` | string | New description |
| Other | `{width}x{height}`| | `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 ```json
// success
{ {
"status": "success",
"message": "Asset uploaded.", "message": "Asset uploaded.",
"data": { ...asset } "data": { ... }
} }
```
### Response `400` // error
```json
{ {
"status": "error", "message": "Asset not found.",
"message": "No file uploaded." "status": 404
} }
``` ```
--- ---
## Update Asset Metadata ## Related files
**`PUT /api/admin/assets/:assetId`** | File | Purpose |
|---|---|
Updates metadata of an existing asset. File replacement is not supported — upload a new asset instead. | `models/assets/assets.mdl.js` | Sequelize model |
`resolution` is **auto-re-derived** if `width` or `height` is updated. | `models/assets/assets.attributes.js` | Exclude sets, paginate config |
| `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) |
### Path Parameters | `services/ffprobe.service.js` | ffprobe metadata extraction for videos |
| Parameter | Type | Required | Description | | `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` |
|-----------|--------|----------|-------------| | `utils/response.util.js` | `R.success` / `R.error` response helpers |
| 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`.
+49
View File
@@ -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,
};
+1 -1
View File
@@ -59,7 +59,7 @@ const Asset = sequelize.define("Asset", {
}, },
// ─── Polymorphic ownership ──────────────────────────────────────────────── // ─── 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 }, owner_id: { type: DataTypes.BIGINT },
// ─── Who did what ───────────────────────────────────────────────────────── // ─── Who did what ─────────────────────────────────────────────────────────
+119
View File
@@ -17,6 +17,9 @@
"express-rate-limit": "^6.10.0", "express-rate-limit": "^6.10.0",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"express-validator": "^7.0.1", "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", "google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.1",
"multer": "^2.1.1", "multer": "^2.1.1",
@@ -173,6 +176,17 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -400,6 +414,18 @@
"node": ">=0.10.0" "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": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -578,6 +604,15 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -684,6 +719,21 @@
"node": ">= 0.4" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -807,6 +857,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -838,6 +894,36 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1042,6 +1128,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -1211,6 +1312,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/json-bigint": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
@@ -2507,6 +2614,18 @@
"webidl-conversions": "^3.0.0" "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": { "node_modules/wkx": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
+3
View File
@@ -17,6 +17,9 @@
"express-rate-limit": "^6.10.0", "express-rate-limit": "^6.10.0",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"express-validator": "^7.0.1", "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", "google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.1",
"multer": "^2.1.1", "multer": "^2.1.1",
+12 -15
View File
@@ -1,22 +1,19 @@
const express = require('express'); const express = require('express');
const router = require('express').Router(); const router = require('express').Router();
const multer = require('multer'); 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 { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10GB
});
router.get ('/', controller.getAssets); router.get('/', controller.getAssets);
router.get ('/:assetId', controller.getAsset); router.get('/:assetId', controller.getAsset);
router.post ('/upload', upload.single('file'), sensitiveOpsLimiter, controller.uploadAsset); router.post( "/upload", upload.fields([ { name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }, ]), controller.uploadAsset, );
router.put ('/:assetId', sensitiveOpsLimiter, controller.updateAsset); router.put('/:assetId', sensitiveOpsLimiter, controller.updateAsset);
router.patch ('/:assetId/thumbnail', sensitiveOpsLimiter, controller.updateThumbnail); router.patch('/:assetId/thumbnail', sensitiveOpsLimiter, upload.fields([{ name: 'thumbnail', maxCount: 1 }]), controller.updateThumbnail);
router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets); router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets);
router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset); router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset);
router.patch ('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
module.exports = router; module.exports = router;
+213
View File
@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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,
};
+140
View File
@@ -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<VideoMeta>}
*
* @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 };