mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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`.
|
||||
| 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 |
|
||||
Reference in New Issue
Block a user