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
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;
}