This commit is contained in:
rgrgogu
2026-05-14 12:59:42 +08:00
parent 06c4286c4a
commit c3663572ae
12 changed files with 570 additions and 536 deletions
+298 -292
View File
@@ -2,18 +2,17 @@
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { Op } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl"); const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service"); const chibi = require("../../services/chibisafe.service");
const { extractVideoMeta } = require("../../services/ffprobe.service"); const { extractVideoMeta } = require("../../services/ffprobe.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
const { const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes");
adminExclude, const mdl_Users = require('../../models/users/users.mdl');
jsonbSchemas,
computedAttributes, const { Op, Sequelize } = require('sequelize');
} = require("../../models/assets/assets.attributes"); const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -24,7 +23,7 @@ function resolveFileType(mimeType = "") {
if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
return "document"; return "document";
return "other"; return "image";
} }
function resolveExtension(originalName = "") { function resolveExtension(originalName = "") {
@@ -48,6 +47,74 @@ function resolveResolution(width, height) {
return `${width}x${height}`; return `${width}x${height}`;
} }
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function uploadToAlbum(file, ownerType) {
const mime_type = file.mimetype;
const extension = resolveExtension(file.originalname);
const checksum = resolveChecksum(file.buffer);
const file_type = resolveFileType(mime_type);
const chibiResult = await chibi.uploadFile({
buffer: file.buffer,
originalname: file.originalname,
mimetype: mime_type,
ownerType,
});
return {
file_url: chibiResult.url,
chibi_uuid: chibiResult.uuid,
mime_type,
extension,
checksum,
file_type,
};
}
async function applyAssetUpdate(asset, file, body) {
const isThumbnailOnly = asset.file_type === "video" && !!file;
if (body.display_name !== undefined) asset.display_name = body.display_name;
if (body.description !== undefined) asset.description = body.description;
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
asset.updatedBy = body.updatedBy ?? null;
if (file) {
if (isThumbnailOnly) {
asset.thumbnail_url = file.file_url;
asset.thumbnail_storage_key = file.chibi_uuid;
} else {
asset.original_name = file.originalname;
asset.file_url = file.file_url;
asset.file_size = file.size;
asset.mime_type = file.mime_type;
asset.extension = file.extension;
asset.checksum = file.checksum;
asset.file_type = file.file_type;
asset.storage_key = file.chibi_uuid;
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedHeight = body.height ? parseInt(body.height) : null;
if (parsedWidth || parsedHeight) {
asset.width = parsedWidth;
asset.height = parsedHeight;
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
}
}
}
}
async function deleteOldFile(asset, oldStorageKey, newUuid) {
if (asset.storage_provider !== "chibisafe") return;
if (!oldStorageKey || oldStorageKey === newUuid) return;
try {
await chibi.deleteFile(oldStorageKey);
} catch (err) {
console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message);
}
}
/** /**
* Best-effort cleanup of Chibisafe files after a failed transaction. * Best-effort cleanup of Chibisafe files after a failed transaction.
* Never throws — the original error is what matters. * Never throws — the original error is what matters.
@@ -71,6 +138,8 @@ exports.getAssets = async (req, res) => {
excludeAttributes: adminExclude, excludeAttributes: adminExclude,
jsonbSchemas, jsonbSchemas,
computedAttributes, computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: { findOptions: {
where: { deletedAt: null }, where: { deletedAt: null },
}, },
@@ -94,11 +163,43 @@ exports.getAsset = async (req, res) => {
const asset = await Asset.findOne({ const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted }, where: { asset_id: assetId, ...notDeleted },
attributes: { exclude: ["storage_key", "storage_bucket"] },
include: [
{
model: mdl_Users,
as: "creator",
attributes: ["user_id", "personal_info"],
foreignKey: "createdBy",
},
{
model: mdl_Users,
as: "updater",
attributes: ["user_id", "personal_info"],
foreignKey: "updatedBy",
},
],
}); });
if (!asset) return R.error(res, "Asset not found.", 404); if (!asset) return R.error(res, "Asset not found.", 404);
return R.success(res, "Asset retrieved.", { data: asset }); // ── Flatten creator / updater name from personal_info JSONB ──────────────
const json = asset.toJSON();
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
full_name: json.creator.personal_info?.name?.full_name ?? null,
};
}
if (json.updater) {
json.updater = {
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name ?? null,
};
}
return R.success(res, "Asset retrieved.", { data: json });
} catch (err) { } catch (err) {
console.error("[ASSET][GET ONE]", err); console.error("[ASSET][GET ONE]", err);
return R.error(res, "Internal server error.", 500); return R.error(res, "Internal server error.", 500);
@@ -129,8 +230,6 @@ exports.getAsset = async (req, res) => {
// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]) // 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 = []; const uploadedChibiUuids = [];
try { try {
@@ -144,18 +243,15 @@ exports.uploadAsset = async (req, res) => {
const { const {
display_name, display_name,
description, description,
owner_type,
owner_id,
is_public = false, is_public = false,
access_level = "private",
storage_provider = "local", storage_provider = "local",
storage_bucket, storage_bucket,
storage_key, storage_key,
uploadedBy, createdBy,
} = req.body; } = req.body;
if (!uploadedBy) { if (!createdBy) {
return R.error(res, "uploadedBy is required.", 400); return R.error(res, "createdBy is required.", 400);
} }
const mime_type = file.mimetype; const mime_type = file.mimetype;
@@ -172,20 +268,15 @@ exports.uploadAsset = async (req, res) => {
} }
// ── Phase 1b: Upload main file to Chibisafe ─────────────────────────────── // ── Phase 1b: Upload main file to Chibisafe ───────────────────────────────
// Heavy I/O — done BEFORE opening any DB transaction.
let file_url = null; let file_url = null;
let chibi_uuid = null; let chibi_uuid = null;
if (storage_provider === "chibisafe") { 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({ const chibiResult = await chibi.uploadFile({
buffer: file.buffer, buffer: file.buffer,
originalname: file.originalname, originalname: file.originalname,
mimetype: mime_type, mimetype: mime_type,
ownerType: owner_type || "",
}); });
file_url = chibiResult.url; file_url = chibiResult.url;
@@ -203,7 +294,6 @@ exports.uploadAsset = async (req, res) => {
} }
// ── Phase 1c: ffprobe + thumbnail upload ────────────────────────────────── // ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
// Also heavy — done BEFORE the DB transaction.
let width = null; let width = null;
let height = null; let height = null;
@@ -216,7 +306,6 @@ exports.uploadAsset = async (req, res) => {
let thumbnail_url = null; let thumbnail_url = null;
if (file_type === "video") { if (file_type === "video") {
// ffprobe — CPU-bound, can take a few seconds
const meta = await extractVideoMeta({ const meta = await extractVideoMeta({
buffer: file.buffer, buffer: file.buffer,
extension: extension || "mp4", extension: extension || "mp4",
@@ -231,10 +320,8 @@ exports.uploadAsset = async (req, res) => {
video_codec = meta.video_codec; video_codec = meta.video_codec;
audio_codec = meta.audio_codec; audio_codec = meta.audio_codec;
// Thumbnail upload — another network call, done outside the transaction
if (storage_provider === "chibisafe") { if (storage_provider === "chibisafe") {
if (!thumbFile.buffer) { if (!thumbFile.buffer) {
// Clean up the already-uploaded main file before returning
await rollbackChibiUploads(uploadedChibiUuids); await rollbackChibiUploads(uploadedChibiUuids);
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
} }
@@ -244,7 +331,7 @@ exports.uploadAsset = async (req, res) => {
buffer: thumbFile.buffer, buffer: thumbFile.buffer,
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
mimetype: thumbFile.mimetype, mimetype: thumbFile.mimetype,
ownerType: "thumbnail", // always routes to the thumbnails album ownerType: "thumbnail",
}); });
thumbnail_url = thumbResult.url; thumbnail_url = thumbResult.url;
@@ -264,7 +351,7 @@ exports.uploadAsset = async (req, res) => {
resolution = resolveResolution(parsedWidth, parsedHeight); resolution = resolveResolution(parsedWidth, parsedHeight);
} }
// ── Phase 2: DB insert — transaction is open for milliseconds only ──────── // ── Phase 2: DB insert ────────────────────────────────────────────────────
const t = await sequelize.transaction(); const t = await sequelize.transaction();
try { try {
@@ -291,20 +378,17 @@ exports.uploadAsset = async (req, res) => {
storage_bucket: storage_bucket || null, storage_bucket: storage_bucket || null,
storage_key: chibi_uuid || storage_key || file.filename, storage_key: chibi_uuid || storage_key || file.filename,
is_public, is_public,
access_level, createdBy,
owner_type: owner_type || null,
owner_id: owner_id || null,
uploadedBy,
}, { transaction: t }); }, { transaction: t });
await t.commit(); await t.commit();
return R.success(res, "Asset uploaded.", { data: asset }, 201); return R.success(res, "Asset uploaded.", { data: asset }, 201);
} catch (dbErr) { } catch (dbErr) {
// DB failed — rollback and clean up the Chibisafe uploads
try { await t.rollback(); } catch { /* connection already gone */ } try { await t.rollback(); } catch { /* connection already gone */ }
await rollbackChibiUploads(uploadedChibiUuids); await rollbackChibiUploads(uploadedChibiUuids);
throw dbErr; // re-throw to outer catch for logging + response throw dbErr;
} }
} catch (err) { } catch (err) {
@@ -318,305 +402,227 @@ exports.uploadAsset = async (req, res) => {
} }
}; };
// ─── UPDATE THUMBNAIL ───────────────────────────────────────────────────────── // ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
//
// 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;
exports.archiveAsset = async (req, res) => {
try { try {
const { assetId } = req.params; const { assetId } = req.params;
const thumbFile = req.files?.thumbnail?.[0] ?? req.file; if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
if (!thumbFile) {
return R.error(res, "No thumbnail file uploaded. Include it as the 'thumbnail' field.", 400);
}
if (!thumbFile.buffer) {
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
}
// ── 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 } }); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404); if (!asset) return R.error(res, "Asset not found.", 404);
const oldThumbUuid = asset.storage_provider === "chibisafe" await asset.update({ deletedBy: req.body.deletedBy ?? null });
? asset.thumbnail_storage_key ?? null // store separately if you have it, await asset.destroy();
: 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 R.success(res, "Asset archived.");
} catch (err) { } catch (err) {
if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); console.error("[ASSET][ARCHIVE]", err);
console.error("[ASSET][UPDATE THUMBNAIL]", err);
return R.error(res, "Internal server error.", 500); return R.error(res, "Internal server error.", 500);
} }
}; };
// ─── UPDATE METADATA ────────────────────────────────────────────────────────── // ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => { exports.archiveAssets = async (req, res) => {
const t = await sequelize.transaction();
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") {
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 },
transaction: t,
});
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",
"thumbnail_url",
"width", "height", "duration",
"frame_rate", "bitrate",
"video_codec", "audio_codec",
];
allowed.forEach((field) => {
if (req.body[field] !== undefined) asset[field] = req.body[field];
});
if (req.body.width || req.body.height) {
asset.resolution = resolveResolution(asset.width, asset.height);
}
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 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") {
await t.rollback();
return R.error(res, "Invalid asset ID.", 400);
}
const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted },
transaction: t,
lock: t.LOCK.UPDATE,
});
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({ transaction: t });
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 R.error(res, "Internal server error.", 500);
}
};
// ─── SOFT DELETE (bulk) ───────────────────────────────────────────────────────
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) return R.error(res, "ids must be a non-empty array.", 400);
if (!Array.isArray(ids) || !ids.length) { const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
await t.rollback(); if (!assets.length) return R.error(res, "No assets found.", 404);
return R.error(res, "ids must be a non-empty array.", 400);
}
const chibiAssets = await Asset.findAll({ const activeIds = assets.map((a) => a.asset_id);
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) await Asset.update(
if (chibiAssets.length) { { deletedBy: deletedBy ?? null },
try { { where: { asset_id: { [Op.in]: activeIds } } }
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 },
transaction: t,
},
); );
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
await t.commit(); return R.success(res, `${activeIds.length} asset(s) archived.`, {
return R.success(res, `${count} asset(s) deleted.`); archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) { } catch (err) {
try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][BULK ARCHIVE]", err);
console.error("[ASSET][BULK DELETE]", err);
return R.error(res, "Internal server error.", 500); 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 },
transaction: t, paranoid: false,
lock: t.LOCK.UPDATE,
}); });
if (!asset) { if (!asset) return R.error(res, "Asset not found.", 404);
await t.rollback(); if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
return R.error(res, "Asset not found or not deleted.", 404);
}
asset.deletedAt = null; await asset.restore();
asset.deletedBy = null; await asset.update({ deletedBy: null });
await asset.save({ transaction: t });
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 }); 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 R.error(res, "Internal server error.", 500); return R.error(res, "Internal server error.", 500);
} }
}; };
/** // ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
* Maps asset owner_type to the ALBUMS key in chibisafe.service.
* Returns null for types that have no dedicated album (e.g. "image"). exports.restoreAssets = async (req, res) => {
* try {
* @param {string} ownerType const { ids } = req.body;
* @returns {string|null} if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
*/
function ownerTypeToAlbumKey(ownerType) { const assets = await Asset.findAll({
const map = { where: { asset_id: { [Op.in]: ids } },
avatar: "avatars", paranoid: false,
video: "videos", });
document: "documents", if (!assets.length) return R.error(res, "No assets found.", 404);
};
return map[ownerType] ?? null; const archivedAssets = assets.filter((a) => a.deletedAt);
} if (!archivedAssets.length) return R.error(res, "All selected assets are already active.", 400);
const archivedIds = archivedAssets.map((a) => a.asset_id);
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
await Asset.update(
{ deletedBy: null },
{ where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false }
);
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[ASSET][BULK RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
exports.updateAsset = async (req, res) => {
let newChibiUuid = null;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const file = req.files?.file?.[0] ?? req.file ?? null;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
if (file && !file.buffer) return R.error(res, "File buffer is required.", 400);
// ── Phase 1: Upload ───────────────────────────────────────────────────────
let uploaded = null;
let oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
if (file && asset.storage_provider === "chibisafe") {
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
uploaded = await uploadToAlbum(file, ownerType);
newChibiUuid = uploaded.chibi_uuid;
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body);
await asset.save({ transaction: t });
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
throw dbErr;
}
// ── Phase 3: Cleanup ──────────────────────────────────────────────────────
if (uploaded) await deleteOldFile(asset, oldStorageKey, newChibiUuid);
return R.success(res, "Asset updated.", { data: asset });
} catch (err) {
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
console.error("[ASSET][REPLACE FILE]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
exports.getArchivedAssets = async (req, res) => {
try {
const result = await paginate(Asset, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
},
});
return R.success(res, "Archived assets retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived assets.", 500);
}
};
exports.getAssetFieldValues = async (req, res) => {
try {
const { field } = req.query;
if (!field) return R.error(res, "Field is required.", 400);
const allowedFields = Object.keys(Asset.rawAttributes);
const dateFields = ["createdAt", "updatedAt", "deletedAt"];
if (!allowedFields.includes(field))
return R.error(res, "Invalid or restricted field.", 400);
if (auditByFields.includes(field)) {
const [rows] = await sequelize.query(`
SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value
FROM assets a
JOIN users u ON u.user_id = a."${field}"
WHERE a."${field}" IS NOT NULL
AND u."personal_info"->'name'->>'full_name' IS NOT NULL
ORDER BY value ASC
`);
return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean));
}
if (dateFields.includes(field)) {
const results = await Asset.findAll({
attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]],
where: { [field]: { [Op.ne]: null } },
order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]],
raw: true,
});
return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean));
}
const results = await Asset.findAll({
attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]],
where: { [field]: { [Op.ne]: null } },
raw: true,
});
return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort());
} catch (err) {
console.error("[ASSET][GET FIELD VALUES]", err);
return R.error(res, "Could not retrieve field values.", 500);
}
};
@@ -28,6 +28,7 @@ exports.getGroups = async (req, res) => {
excludeAttributes: groupExclude, excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas, jsonbSchemas: groupSchemas,
computedAttributes: groupComputed, computedAttributes: groupComputed,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
}); });
@@ -50,6 +51,7 @@ exports.getGroup = async (req, res) => {
jsonbSchemas: usersSchemas, jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info', jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'User' },
context: "list",
findOptions: { findOptions: {
include: [{ include: [{
model: mdl_UserGroupMembers, model: mdl_UserGroupMembers,
@@ -217,6 +219,7 @@ exports.getArchivedGroups = async (req, res) => {
excludeAttributes: groupExclude, excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas, jsonbSchemas: groupSchemas,
computedAttributes: groupComputed, computedAttributes: groupComputed,
context: "archived",
auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: { findOptions: {
paranoid: false, paranoid: false,
+2
View File
@@ -33,6 +33,7 @@ exports.getUsers = async (req, res) => {
excludeAttributes: usersExclude, excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas, jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info', jsonbColumn: 'personal_info',
context: "list",
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'User' },
}); });
@@ -374,6 +375,7 @@ exports.getArchivedUsers = async (req, res) => {
excludeAttributes: usersExclude, excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas, jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info', jsonbColumn: 'personal_info',
context: "archived",
auditOptions: { mdl_Users, parentAlias: 'User' }, auditOptions: { mdl_Users, parentAlias: 'User' },
findOptions: { findOptions: {
paranoid: false, paranoid: false,
-1
View File
@@ -7,7 +7,6 @@ const excludeAttributes = [
"checksum", // internal integrity hash, not useful to clients "checksum", // internal integrity hash, not useful to clients
"storage_bucket", // internal storage config "storage_bucket", // internal storage config
"storage_key", // internal Chibisafe / S3 key "storage_key", // internal Chibisafe / S3 key
"deletedBy", // exposed via audit subquery as a name instead
]; ];
// Admins see everything except the base excludes // Admins see everything except the base excludes
+35 -42
View File
@@ -1,84 +1,77 @@
// models/Asset.js // models/Asset.js
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl")
const Asset = sequelize.define("Asset", { const Asset = sequelize.define("Asset", {
// ─── Identity ───────────────────────────────────────────────────────────── // ─── Identity ─────────────────────────────────────────────────────────────
asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Asset ID", order: 0, hidden: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── File info ──────────────────────────────────────────────────────────── // ─── File info ────────────────────────────────────────────────────────────
original_name: { type: DataTypes.STRING(255), allowNull: false }, original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true },
display_name: { type: DataTypes.STRING(255), allowNull: false }, display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0 },
file_url: { type: DataTypes.STRING(512), allowNull: false }, file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true },
file_size: { type: DataTypes.BIGINT, allowNull: false }, file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0, hidden: true },
mime_type: { type: DataTypes.STRING(100), allowNull: false }, mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true },
extension: { type: DataTypes.STRING(20) }, extension: { type: DataTypes.STRING(20), label: "File Type", order: 0 },
checksum: { type: DataTypes.STRING(64) }, checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true },
// ─── Classification ─────────────────────────────────────────────────────── // ─── Classification ───────────────────────────────────────────────────────
file_type: { file_type: {
type: DataTypes.ENUM("image", "video", "document", "other"), type: DataTypes.ENUM("avatar", "document", "video", "image"),
allowNull: false, allowNull: false,
defaultValue: "other", defaultValue: "image", label: "Classification", order: 0
}, },
// ─── Image & video dimensions ───────────────────────────────────────────── // ─── Image & video dimensions ─────────────────────────────────────────────
width: { type: DataTypes.INTEGER }, width: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
height: { type: DataTypes.INTEGER }, height: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
// ─── Video-specific ─────────────────────────────────────────────────────── // ─── Video-specific ───────────────────────────────────────────────────────
duration: { type: DataTypes.FLOAT }, // seconds duration: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // seconds
resolution: { type: DataTypes.STRING(20) }, // "1080p", "720p", "4K" resolution: { type: DataTypes.STRING(20), label: "", order: 0, hidden: true }, // "1080p", "720p", "4K"
frame_rate: { type: DataTypes.FLOAT }, // fps frame_rate: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // fps
bitrate: { type: DataTypes.BIGINT }, // bps bitrate: { type: DataTypes.BIGINT, label: "", order: 0, hidden: true }, // bps
video_codec: { type: DataTypes.STRING(50) }, // "H.264", "H.265" video_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "H.264", "H.265"
audio_codec: { type: DataTypes.STRING(50) }, // "AAC", "MP3" audio_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "AAC", "MP3"
thumbnail_url: { type: DataTypes.STRING(512) }, // face of the video / doc preview thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, // face of the video / doc preview
// ─── Description ────────────────────────────────────────────────────────── // ─── Description ──────────────────────────────────────────────────────────
description: { type: DataTypes.TEXT }, description: { type: DataTypes.TEXT, label: "Description", hidden: true },
// ─── Storage ────────────────────────────────────────────────────────────── // ─── Storage ──────────────────────────────────────────────────────────────
storage_provider: { storage_provider: {
type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"), type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"),
defaultValue: "local", defaultValue: "local", label: "Storage Provider", order: 0
}, },
storage_bucket: { type: DataTypes.STRING(255) }, storage_bucket: { type: DataTypes.STRING(255), label: "", order: 0, hidden: true },
storage_key: { type: DataTypes.STRING(512) }, storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
// ─── Access control ─────────────────────────────────────────────────────── // ─── Access control ───────────────────────────────────────────────────────
is_public: { is_public: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false, defaultValue: false, label: "Access Type", order: 0
},
access_level: {
type: DataTypes.ENUM("public", "private", "restricted"),
defaultValue: "private",
}, },
// ─── Polymorphic ownership ──────────────────────────────────────────────── // ── Audit trails ────────────────────────────────────────────────────────────
owner_type: { type: DataTypes.STRING(100) }, // avatar, document, video, image createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
owner_id: { type: DataTypes.BIGINT }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
// ─── Who did what ─────────────────────────────────────────────────────────
uploadedBy: { type: DataTypes.BIGINT, allowNull: false },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
// ─── Soft delete ──────────────────────────────────────────────────────────
deletedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
}, { }, {
tableName: "assets", tableName: "assets",
timestamps: true, // createdAt, updatedAt timestamps: true, // createdAt, updatedAt
paranoid: false, paranoid: true,
indexes: [ indexes: [
{ fields: ["uuid"] }, { fields: ["uuid"] },
{ fields: ["owner_type", "owner_id"] }, { fields: ["createdBy"] },
{ fields: ["uploadedBy"] },
{ fields: ["file_type"] }, { fields: ["file_type"] },
{ fields: ["deletedAt"] }, { fields: ["deletedAt"] },
], ],
}); });
Asset.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Asset.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
module.exports = Asset; module.exports = Asset;
+41
View File
@@ -8,6 +8,7 @@
"name": "star-auth-system", "name": "star-auth-system",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"axios": "^1.16.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -187,6 +188,17 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"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",
@@ -908,6 +920,26 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -1969,6 +2001,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1
View File
@@ -8,6 +8,7 @@
"dev": "nodemon server.js" "dev": "nodemon server.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.16.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
+11 -5
View File
@@ -6,14 +6,20 @@ 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');
// ─── Static routes first ──────────────────────────────────────────────────────
router.get('/archived', controller.getArchivedAssets);
router.get('/field-values', controller.getAssetFieldValues); // ← must be before /:assetId
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAssets);
router.patch('/bulk-restore', controller.restoreAssets);
// ─── Collection ───────────────────────────────────────────────────────────────
router.get('/', controller.getAssets); router.get('/', controller.getAssets);
router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset);
// ─── Dynamic routes last ──────────────────────────────────────────────────────
router.get('/:assetId', controller.getAsset); router.get('/:assetId', controller.getAsset);
router.post( "/upload", upload.fields([ { name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }, ]), controller.uploadAsset, ); router.patch('/:assetId', sensitiveOpsLimiter, upload.fields([{ name: 'file', maxCount: 1 }]), controller.updateAsset);
router.put('/:assetId', sensitiveOpsLimiter, controller.updateAsset);
router.patch('/:assetId/thumbnail', sensitiveOpsLimiter, upload.fields([{ name: 'thumbnail', maxCount: 1 }]), controller.updateThumbnail);
router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets);
router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset);
router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset);
module.exports = router; module.exports = router;
+15
View File
@@ -55,6 +55,21 @@ app.use(cors({
callback(new Error(`CORS: origin ${origin} not allowed`)); callback(new Error(`CORS: origin ${origin} not allowed`));
}, },
credentials: true, credentials: true,
preflightContinue: false, // cors() handles OPTIONS and stops — don't pass to next()
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Accept',
'Origin',
'X-Requested-With',
'csrf-token',
'x-csrf-token',
'CSRF-Token',
'X-CSRF-Token',
],
optionsSuccessStatus: 204,
maxAge: 86400,
})); }));
app.use(express.json()); app.use(express.json());
+40 -76
View File
@@ -12,7 +12,7 @@
// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album // CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album
const FormData = require("form-data"); const FormData = require("form-data");
const fetch = require("node-fetch"); // npm i node-fetch@2 (CJS-compatible) const axios = require("axios");
// ─── Config ─────────────────────────────────────────────────────────────────── // ─── Config ───────────────────────────────────────────────────────────────────
@@ -24,38 +24,23 @@ const ALBUMS = {
videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null, videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null,
documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null, documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null,
thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null, thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null,
images: process.env.CHIBISAFE_ALBUM_IMAGES || null, // ← new
archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null, archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null,
}; };
// ─── Internal helpers ───────────────────────────────────────────────────────── // ─── 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 = "") { function resolveAlbumUuid(ownerType = "") {
switch (ownerType) { switch (ownerType) {
case "avatar": return ALBUMS.avatars; case "avatar": return ALBUMS.avatars;
case "video": return ALBUMS.videos; case "video": return ALBUMS.videos;
case "document": return ALBUMS.documents; case "document": return ALBUMS.documents;
case "thumbnail": return ALBUMS.thumbnails; case "thumbnail": return ALBUMS.thumbnails;
default: return null; // "image" and unknowns → no album case "image": return ALBUMS.images;
default: return null;
} }
} }
/**
* Build default headers for every Chibisafe request.
*/
function baseHeaders(extra = {}) { function baseHeaders(extra = {}) {
return { return {
"x-api-key": API_KEY, "x-api-key": API_KEY,
@@ -63,44 +48,41 @@ function baseHeaders(extra = {}) {
}; };
} }
// ─── Axios instance ───────────────────────────────────────────────────────────
const chibi = axios.create({
baseURL: BASE_URL,
maxBodyLength: Infinity, // ← required for large file uploads
maxContentLength: Infinity,
});
/** /**
* Thin fetch wrapper that throws a descriptive error on non-2xx. * Thin axios wrapper that throws a descriptive error on non-2xx.
*/ */
async function chibiRequest(path, options = {}) { async function chibiRequest(path, { method = "GET", headers = {}, data } = {}) {
const url = `${BASE_URL}${path}`;
const res = await fetch(url, options);
let body;
try { try {
body = await res.json(); const res = await chibi.request({
} catch { url: path,
body = {}; method,
headers,
data,
});
return res.data;
} catch (err) {
const status = err.response?.status;
const body = err.response?.data ?? {};
const msg = body?.message || body?.error || err.message;
const friendly = new Error(`[Chibisafe] ${status ?? "?"} – ${msg}`);
friendly.status = status;
friendly.chibiBody = body;
throw friendly;
} }
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 ─────────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────────
/** /**
* Upload a file to Chibisafe, optionally straight into a typed album. * 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 = "" }) { async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
if (!BASE_URL || !API_KEY) { if (!BASE_URL || !API_KEY) {
@@ -115,21 +97,16 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
contentType: mimetype, 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", { const data = await chibiRequest("/api/upload", {
method: "POST", method: "POST",
headers, headers: {
body: form, ...baseHeaders(),
...form.getHeaders(), // ← axios needs these
...(albumUuid ? { albumuuid: albumUuid } : {}),
},
data: form,
}); });
// Chibisafe returns: { name, uuid, url, ... }
return { return {
uuid: data.uuid, uuid: data.uuid,
url: data.url, url: data.url,
@@ -139,10 +116,6 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
/** /**
* Permanently delete one file from Chibisafe by its UUID. * 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) { async function deleteFile(uuid) {
await chibiRequest(`/api/file/${uuid}`, { await chibiRequest(`/api/file/${uuid}`, {
@@ -152,11 +125,7 @@ async function deleteFile(uuid) {
} }
/** /**
* Move one or more files into the "archived" album (soft-delete equivalent). * Move one or more files into the "archived" album.
* 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) { async function archiveFiles(uuids) {
if (!ALBUMS.archived) { if (!ALBUMS.archived) {
@@ -172,20 +141,15 @@ async function archiveFiles(uuids) {
...baseHeaders(), ...baseHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ data: {
files: ids, files: ids,
albumUuid: ALBUMS.archived, albumUuid: ALBUMS.archived,
}), },
}); });
} }
/** /**
* Move one or more files into a specific album by UUID. * 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) { async function addFilesToAlbum(uuids, albumUuid) {
const ids = Array.isArray(uuids) ? uuids : [uuids]; const ids = Array.isArray(uuids) ? uuids : [uuids];
@@ -196,10 +160,10 @@ async function addFilesToAlbum(uuids, albumUuid) {
...baseHeaders(), ...baseHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ data: {
files: ids, files: ids,
albumUuid, albumUuid,
}), },
}); });
} }
+13 -10
View File
@@ -89,7 +89,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"]) * @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
* @returns {Array} attributes array * @returns {Array} attributes array
*/ */
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) { function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {}, context = "" } = {}) {
const rawAttrs = model.rawAttributes || model.tableAttributes; const rawAttrs = model.rawAttributes || model.tableAttributes;
const attributes = []; const attributes = [];
@@ -113,14 +113,14 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
// Ordered audit sequence // Ordered audit sequence
const auditSequence = [ const auditSequence = [
{ field: 'updatedAt', type: 'date' }, { field: "updatedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
{ field: 'modifiedAt', type: 'date' }, { field: "modifiedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
{ field: 'updatedBy', type: 'text' }, { field: "updatedBy", type: "text", hiddenOnList: false, hiddenOnArchived: true },
{ field: 'createdAt', type: 'date' }, { field: "createdAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
{ field: 'createdBy', type: 'text' }, { field: "createdBy", type: "text", hiddenOnList: false, hiddenOnArchived: true },
{ field: 'deletedAt', type: 'date' }, { field: "deletedAt", type: "date", hiddenOnList: true, hiddenOnArchived: false },
{ field: 'deletedBy', type: 'text' }, { field: "deletedBy", type: "text", hiddenOnList: true, hiddenOnArchived: false },
]; ];
// ── Normal fields (excluding audit) ───────────────────────────────────────── // ── Normal fields (excluding audit) ─────────────────────────────────────────
for (const [field, def] of Object.entries(rawAttrs)) { for (const [field, def] of Object.entries(rawAttrs)) {
@@ -148,15 +148,18 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
} }
// ── Audit fields in correct sequence ──────────────────────────────────────── // ── Audit fields in correct sequence ────────────────────────────────────────
for (const { field, type, order } of auditSequence) { for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) {
if (exclude.includes(field)) continue; if (exclude.includes(field)) continue;
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
const isArchived = context === "archived";
attributes.push({ attributes.push({
name: defaultTimestampLabels[field], name: defaultTimestampLabels[field],
type, type,
field, field,
order, order,
hidden: isArchived ? hiddenOnArchived : hiddenOnList,
options: resolveOptions(rawAttrs[field]?.type), options: resolveOptions(rawAttrs[field]?.type),
}); });
} }
+3 -2
View File
@@ -81,7 +81,8 @@ async function paginate(model, req, {
jsonbColumn = null, jsonbColumn = null,
findOptions = {}, findOptions = {},
auditOptions = null, // ← { mdl_Users, parentAlias }, auditOptions = null, // ← { mdl_Users, parentAlias },
computedAttributes = [] computedAttributes = [],
context = "list",
} = {}) { } = {}) {
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START);
const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
@@ -94,7 +95,7 @@ async function paginate(model, req, {
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas }); const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
const ALLOWED_FIELDS = attributes.map((a) => a.field); const ALLOWED_FIELDS = attributes.map((a) => a.field);
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS); const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);