mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -5,15 +5,16 @@ const crypto = require("crypto");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const Asset = require("../../models/assets/assets.mdl");
|
||||
const chibi = require("../../services/chibisafe.service");
|
||||
const s3 = require("../../services/s3.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");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,9 +23,8 @@ const notDeleted = { deletedAt: null };
|
||||
function resolveFileType(mimeType = "") {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
|
||||
return "document";
|
||||
return "image";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
||||
}
|
||||
|
||||
function resolveExtension(originalName = "") {
|
||||
@@ -41,31 +41,64 @@ function resolveResolution(width, height) {
|
||||
if (h >= 2160) return "4K";
|
||||
if (h >= 1440) return "1440p";
|
||||
if (h >= 1080) return "1080p";
|
||||
if (h >= 720) return "720p";
|
||||
if (h >= 480) return "480p";
|
||||
if (h >= 360) return "360p";
|
||||
if (h >= 240) return "240p";
|
||||
if (h >= 720) return "720p";
|
||||
if (h >= 480) return "480p";
|
||||
if (h >= 360) return "360p";
|
||||
if (h >= 240) return "240p";
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// ─── Provider resolver ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Returns the correct service module based on storage_provider.
|
||||
// Both chibi and s3 expose the same interface: uploadFile / deleteFile.
|
||||
//
|
||||
function getProvider(storageProvider) {
|
||||
if (storageProvider === "s3") return s3;
|
||||
if (storageProvider === "chibisafe") return chibi;
|
||||
return null; // local / other — no remote provider needed
|
||||
}
|
||||
|
||||
async function uploadToAlbum(file, ownerType) {
|
||||
// ─── rollbackUploads ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Best-effort cleanup after a failed DB transaction.
|
||||
// uploads: [{ key, provider }]
|
||||
//
|
||||
async function rollbackUploads(uploads = []) {
|
||||
for (const { key, provider } of uploads) {
|
||||
if (!key || !provider) continue;
|
||||
const svc = getProvider(provider);
|
||||
if (!svc) continue;
|
||||
try {
|
||||
await svc.deleteFile(key);
|
||||
} catch (err) {
|
||||
console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── uploadToProvider ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Uploads a single file to the resolved provider.
|
||||
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type }
|
||||
//
|
||||
async function uploadToProvider(file, ownerType, storageProvider) {
|
||||
const mime_type = file.mimetype;
|
||||
const extension = resolveExtension(file.originalname);
|
||||
const checksum = resolveChecksum(file.buffer);
|
||||
const checksum = resolveChecksum(file.buffer);
|
||||
const file_type = resolveFileType(mime_type);
|
||||
|
||||
const chibiResult = await chibi.uploadFile({
|
||||
buffer: file.buffer,
|
||||
const svc = getProvider(storageProvider);
|
||||
const result = await svc.uploadFile({
|
||||
buffer: file.buffer,
|
||||
originalname: file.originalname,
|
||||
mimetype: mime_type,
|
||||
mimetype: mime_type,
|
||||
ownerType,
|
||||
});
|
||||
|
||||
return {
|
||||
file_url: chibiResult.url,
|
||||
chibi_uuid: chibiResult.uuid,
|
||||
file_url: result.url,
|
||||
storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB
|
||||
mime_type,
|
||||
extension,
|
||||
checksum,
|
||||
@@ -73,62 +106,64 @@ async function uploadToAlbum(file, ownerType) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── applyAssetUpdate ─────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
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;
|
||||
asset.thumbnail_url = file.file_url;
|
||||
asset.thumbnail_storage_key = file.storage_key;
|
||||
} 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;
|
||||
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.storage_key;
|
||||
|
||||
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||
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.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;
|
||||
// ─── deleteOldFile ────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
|
||||
if (!oldStorageKey || oldStorageKey === newKey) return;
|
||||
const svc = getProvider(storageProvider);
|
||||
if (!svc) return;
|
||||
try {
|
||||
await chibi.deleteFile(oldStorageKey);
|
||||
await svc.deleteFile(oldStorageKey);
|
||||
} catch (err) {
|
||||
console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message);
|
||||
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
// ─── Helper: hide S3 file_url from responses ──────────────────────────────────
|
||||
//
|
||||
// The raw S3 presigned/public URL is never sent to any browser.
|
||||
// Admin viewers request a short-lived stream token instead
|
||||
// (POST /api/admin/media/token → GET /api/client/media/stream/:token).
|
||||
// Chibisafe assets keep their file_url (CDN public URL, no proxy needed).
|
||||
//
|
||||
function redactS3Url(asset) {
|
||||
if (asset?.storage_provider === "s3") asset.file_url = null;
|
||||
return asset;
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
@@ -141,11 +176,9 @@ exports.getAssets = async (req, res) => {
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Assets retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ALL]", err);
|
||||
@@ -158,48 +191,35 @@ exports.getAssets = async (req, res) => {
|
||||
exports.getAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") {
|
||||
return R.error(res, "Invalid asset ID.", 400);
|
||||
}
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
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",
|
||||
},
|
||||
{ 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);
|
||||
|
||||
// ── Flatten creator / updater name from personal_info JSONB ──────────────
|
||||
const json = asset.toJSON();
|
||||
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
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,
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
redactS3Url(json);
|
||||
return R.success(res, "Asset retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ONE]", err);
|
||||
@@ -212,31 +232,27 @@ exports.getAsset = async (req, res) => {
|
||||
// ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
// │ TRANSACTION STRATEGY │
|
||||
// │ │
|
||||
// │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │
|
||||
// │ Phase 1 — SLOW WORK (outside transaction): │
|
||||
// │ • Input validation │
|
||||
// │ • ffprobe metadata extraction │
|
||||
// │ • Chibisafe file upload → track UUID for rollback │
|
||||
// │ • Chibisafe thumb upload → track UUID for rollback │
|
||||
// │ • Provider upload (chibi or s3) → track for rollback │
|
||||
// │ • Thumbnail upload → track for rollback │
|
||||
// │ │
|
||||
// │ Phase 2 — FAST WORK (transaction open for milliseconds only): │
|
||||
// │ • BEGIN transaction │
|
||||
// │ • Asset.create() │
|
||||
// │ • COMMIT │
|
||||
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
|
||||
// │ • BEGIN → Asset.create() → COMMIT │
|
||||
// │ │
|
||||
// │ On any Phase 2 error: │
|
||||
// │ On Phase 2 error: │
|
||||
// │ • ROLLBACK transaction │
|
||||
// │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │
|
||||
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
|
||||
// └─────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])
|
||||
|
||||
exports.uploadAsset = async (req, res) => {
|
||||
const uploadedChibiUuids = [];
|
||||
const uploadedFiles = []; // [{ key, provider }]
|
||||
|
||||
try {
|
||||
// ── Phase 1a: Validate inputs ─────────────────────────────────────────────
|
||||
// ── Phase 1a: Validate ────────────────────────────────────────────────────
|
||||
|
||||
const file = req.files?.file?.[0];
|
||||
const file = req.files?.file?.[0];
|
||||
const thumbFile = req.files?.thumbnail?.[0];
|
||||
|
||||
if (!file) return R.error(res, "No file uploaded.", 400);
|
||||
@@ -244,111 +260,116 @@ exports.uploadAsset = async (req, res) => {
|
||||
const {
|
||||
display_name,
|
||||
description,
|
||||
is_public = false,
|
||||
storage_provider = "local",
|
||||
is_public = false,
|
||||
storage_provider = "chibisafe",
|
||||
storage_bucket,
|
||||
storage_key,
|
||||
createdBy,
|
||||
} = req.body;
|
||||
|
||||
if (!createdBy) {
|
||||
return R.error(res, "createdBy is required.", 400);
|
||||
}
|
||||
if (!createdBy) return R.error(res, "createdBy 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 usesProvider = ["chibisafe", "s3"].includes(storage_provider);
|
||||
|
||||
if (file_type === "video" && !thumbFile) {
|
||||
return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400);
|
||||
return R.error(res, "A thumbnail image is required for video uploads.", 400);
|
||||
}
|
||||
|
||||
if (storage_provider === "chibisafe" && !file.buffer) {
|
||||
return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400);
|
||||
if (usesProvider && !file.buffer) {
|
||||
return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400);
|
||||
}
|
||||
|
||||
// ── Phase 1b: Upload main file to Chibisafe ───────────────────────────────
|
||||
// ── Phase 1b: Upload main file ────────────────────────────────────────────
|
||||
|
||||
let file_url = null;
|
||||
let chibi_uuid = null;
|
||||
let file_url = null;
|
||||
let storage_key_resolved = null;
|
||||
|
||||
if (storage_provider === "chibisafe") {
|
||||
const chibiResult = await chibi.uploadFile({
|
||||
buffer: file.buffer,
|
||||
if (usesProvider) {
|
||||
const svc = getProvider(storage_provider);
|
||||
const result = await svc.uploadFile({
|
||||
buffer: file.buffer,
|
||||
originalname: file.originalname,
|
||||
mimetype: mime_type,
|
||||
mimetype: mime_type,
|
||||
ownerType: file_type,
|
||||
});
|
||||
|
||||
file_url = chibiResult.url;
|
||||
chibi_uuid = chibiResult.uuid;
|
||||
uploadedChibiUuids.push(chibi_uuid);
|
||||
file_url = result.url;
|
||||
storage_key_resolved = result.uuid;
|
||||
uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider });
|
||||
|
||||
} 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);
|
||||
}
|
||||
if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400);
|
||||
}
|
||||
|
||||
// ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
|
||||
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
|
||||
|
||||
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 width = null, height = null, resolution = null;
|
||||
let duration = null, frame_rate = null, bitrate = null;
|
||||
let video_codec = null, audio_codec = null;
|
||||
let thumbnail_url = null;
|
||||
|
||||
if (file_type === "video") {
|
||||
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;
|
||||
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;
|
||||
|
||||
if (storage_provider === "chibisafe") {
|
||||
if (!thumbFile.buffer) {
|
||||
await rollbackChibiUploads(uploadedChibiUuids);
|
||||
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
|
||||
if (usesProvider) {
|
||||
if (!thumbFile?.buffer) {
|
||||
await rollbackUploads(uploadedFiles);
|
||||
return R.error(res, "Thumbnail buffer is required.", 400);
|
||||
}
|
||||
|
||||
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||
const thumbResult = await chibi.uploadFile({
|
||||
buffer: thumbFile.buffer,
|
||||
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||
const svc = getProvider(storage_provider);
|
||||
const thumbResult = await svc.uploadFile({
|
||||
buffer: thumbFile.buffer,
|
||||
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
|
||||
mimetype: thumbFile.mimetype,
|
||||
ownerType: "thumbnail",
|
||||
mimetype: thumbFile.mimetype,
|
||||
ownerType: "thumbnail",
|
||||
});
|
||||
|
||||
thumbnail_url = thumbResult.url;
|
||||
uploadedChibiUuids.push(thumbResult.uuid);
|
||||
|
||||
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
|
||||
} else {
|
||||
thumbnail_url = thumbFile.filename
|
||||
? `/uploads/${thumbFile.filename}`
|
||||
: null;
|
||||
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
||||
}
|
||||
|
||||
} else if (file_type === "audio" && thumbFile) {
|
||||
if (usesProvider) {
|
||||
if (!thumbFile.buffer) {
|
||||
await rollbackUploads(uploadedFiles);
|
||||
return R.error(res, "Thumbnail buffer is required.", 400);
|
||||
}
|
||||
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||
const svc = getProvider(storage_provider);
|
||||
const thumbResult = await svc.uploadFile({
|
||||
buffer: thumbFile.buffer,
|
||||
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
|
||||
mimetype: thumbFile.mimetype,
|
||||
ownerType: "thumbnail",
|
||||
});
|
||||
thumbnail_url = thumbResult.url;
|
||||
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
|
||||
} else {
|
||||
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
||||
}
|
||||
|
||||
} else {
|
||||
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
|
||||
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
|
||||
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
|
||||
width = parsedWidth;
|
||||
height = parsedHeight;
|
||||
width = parsedWidth;
|
||||
height = parsedHeight;
|
||||
resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||
}
|
||||
|
||||
@@ -357,10 +378,10 @@ exports.uploadAsset = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const asset = await Asset.create({
|
||||
original_name: file.originalname,
|
||||
display_name: display_name || file.originalname,
|
||||
original_name: file.originalname,
|
||||
display_name: display_name || file.originalname,
|
||||
file_url,
|
||||
file_size: file.size,
|
||||
file_size: file.size,
|
||||
mime_type,
|
||||
extension,
|
||||
checksum,
|
||||
@@ -376,29 +397,87 @@ exports.uploadAsset = async (req, res) => {
|
||||
thumbnail_url,
|
||||
description,
|
||||
storage_provider,
|
||||
storage_bucket: storage_bucket || null,
|
||||
storage_key: chibi_uuid || storage_key || file.filename,
|
||||
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
|
||||
storage_key: storage_key_resolved || storage_key || file.filename || null,
|
||||
is_public,
|
||||
createdBy,
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection already gone */ }
|
||||
await rollbackChibiUploads(uploadedChibiUuids);
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
await rollbackUploads(uploadedFiles);
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][UPLOAD]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
if (err.status) {
|
||||
return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateAsset = async (req, res) => {
|
||||
let newUpload = null; // { key, provider }
|
||||
|
||||
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);
|
||||
|
||||
const storageProvider = asset.storage_provider;
|
||||
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
|
||||
const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
|
||||
|
||||
// ── Phase 1: Upload ───────────────────────────────────────────────────────
|
||||
|
||||
let uploaded = null;
|
||||
|
||||
if (file && usesProvider) {
|
||||
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
|
||||
uploaded = await uploadToProvider(file, ownerType, storageProvider);
|
||||
newUpload = { key: uploaded.storage_key, provider: storageProvider };
|
||||
}
|
||||
|
||||
// ── 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 (newUpload) await rollbackUploads([newUpload]);
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
// ── Phase 3: Cleanup old file ─────────────────────────────────────────────
|
||||
|
||||
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
||||
|
||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset updated.", { data: asset });
|
||||
|
||||
} catch (err) {
|
||||
if (newUpload) await rollbackUploads([newUpload]);
|
||||
console.error("[ASSET][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
@@ -415,7 +494,7 @@ exports.archiveAsset = async (req, res) => {
|
||||
|
||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||
await asset.destroy();
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset archived.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][ARCHIVE]", err);
|
||||
@@ -435,12 +514,10 @@ exports.archiveAssets = async (req, res) => {
|
||||
|
||||
const activeIds = assets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.update(
|
||||
{ deletedBy: deletedBy ?? null },
|
||||
{ where: { asset_id: { [Op.in]: activeIds } } }
|
||||
);
|
||||
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
@@ -457,16 +534,13 @@ exports.restoreAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
where: { asset_id: assetId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
|
||||
|
||||
await asset.restore();
|
||||
await asset.update({ deletedBy: null });
|
||||
|
||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset restored.", { data: asset });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][RESTORE]", err);
|
||||
@@ -481,10 +555,7 @@ exports.restoreAssets = async (req, res) => {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const assets = await Asset.findAll({
|
||||
where: { asset_id: { [Op.in]: ids } },
|
||||
paranoid: false,
|
||||
});
|
||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
@@ -493,11 +564,9 @@ exports.restoreAssets = async (req, res) => {
|
||||
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 }
|
||||
);
|
||||
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
@@ -508,58 +577,7 @@ exports.restoreAssets = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAssets = async (req, res) => {
|
||||
try {
|
||||
@@ -569,12 +587,9 @@ exports.getArchivedAssets = async (req, res) => {
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: { deletedAt: { [Op.ne]: null } },
|
||||
},
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Archived assets retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ARCHIVED]", err);
|
||||
|
||||
Reference in New Issue
Block a user