mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -304,7 +304,7 @@ exports.getAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── UPLOAD ───────────────────────────────────────────────────────────────────
|
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// ┌─────────────────────────────────────────────────────────────────────────┐
|
// ┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
// │ TRANSACTION STRATEGY │
|
// │ TRANSACTION STRATEGY │
|
||||||
@@ -318,21 +318,21 @@ exports.getAsset = async (req, res) => {
|
|||||||
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
|
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
|
||||||
// │ • BEGIN → Asset.create() → COMMIT │
|
// │ • BEGIN → Asset.create() → COMMIT │
|
||||||
// │ │
|
// │ │
|
||||||
// │ On Phase 2 error: │
|
// │ On any error: │
|
||||||
// │ • ROLLBACK transaction │
|
// │ • ROLLBACK transaction (if opened) │
|
||||||
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
|
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
|
||||||
// └─────────────────────────────────────────────────────────────────────────┘
|
// └─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
//
|
||||||
exports.uploadAsset = async (req, res) => {
|
// Shared by the single-file POST / and the multi-file POST /batch routes.
|
||||||
|
// `requireVideoThumbnail` is false for batch uploads — a bulk drop has no
|
||||||
|
// per-file thumbnail step, so videos land with thumbnail_url null and pick
|
||||||
|
// one up later via the existing "thumbnail-only" path in updateAsset().
|
||||||
|
//
|
||||||
|
async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true }) {
|
||||||
const uploadedFiles = []; // [{ key, provider }]
|
const uploadedFiles = []; // [{ key, provider }]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Phase 1a: Validate ────────────────────────────────────────────────────
|
if (!file) throw Object.assign(new Error("No file uploaded."), { status: 400 });
|
||||||
|
|
||||||
const file = req.files?.file?.[0];
|
|
||||||
const thumbFile = req.files?.thumbnail?.[0];
|
|
||||||
|
|
||||||
if (!file) return R.error(res, "No file uploaded.", 400);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
display_name,
|
display_name,
|
||||||
@@ -342,22 +342,22 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
storage_bucket,
|
storage_bucket,
|
||||||
storage_key,
|
storage_key,
|
||||||
createdBy,
|
createdBy,
|
||||||
} = req.body;
|
} = body;
|
||||||
|
|
||||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
|
||||||
|
|
||||||
const mime_type = file.mimetype;
|
const mime_type = file.mimetype;
|
||||||
const file_type = resolveFileType(mime_type);
|
const file_type = resolveFileType(mime_type);
|
||||||
const extension = resolveExtension(file.originalname);
|
const extension = resolveExtension(file.originalname);
|
||||||
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
|
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
|
||||||
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
|
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
|
||||||
|
|
||||||
if (file_type === "video" && !thumbFile) {
|
if (file_type === "video" && requireVideoThumbnail && !thumbFile) {
|
||||||
return R.error(res, "A thumbnail image is required for video uploads.", 400);
|
throw Object.assign(new Error("A thumbnail image is required for video uploads."), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usesProvider && !file.buffer) {
|
if (usesProvider && !file.buffer) {
|
||||||
return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400);
|
throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1b: Upload main file ────────────────────────────────────────────
|
// ── Phase 1b: Upload main file ────────────────────────────────────────────
|
||||||
@@ -380,9 +380,9 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
file_url = storage_provider === "local"
|
file_url = storage_provider === "local"
|
||||||
? `/uploads/${file.filename}`
|
? `/uploads/${file.filename}`
|
||||||
: req.body.file_url;
|
: body.file_url;
|
||||||
|
|
||||||
if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400);
|
if (!file_url) throw Object.assign(new Error("file_url is required for non-local storage."), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
|
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
|
||||||
@@ -403,10 +403,10 @@ 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;
|
||||||
|
|
||||||
if (usesProvider) {
|
if (usesProvider && thumbFile) {
|
||||||
if (!thumbFile?.buffer) {
|
if (!thumbFile.buffer) {
|
||||||
await rollbackUploads(uploadedFiles);
|
await rollbackUploads(uploadedFiles);
|
||||||
return R.error(res, "Thumbnail buffer is required.", 400);
|
throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 });
|
||||||
}
|
}
|
||||||
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||||
const svc = getProvider(storage_provider);
|
const svc = getProvider(storage_provider);
|
||||||
@@ -418,7 +418,7 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
});
|
});
|
||||||
thumbnail_url = thumbResult.url;
|
thumbnail_url = thumbResult.url;
|
||||||
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
|
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
|
||||||
} else {
|
} else if (!usesProvider) {
|
||||||
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
if (usesProvider) {
|
if (usesProvider) {
|
||||||
if (!thumbFile.buffer) {
|
if (!thumbFile.buffer) {
|
||||||
await rollbackUploads(uploadedFiles);
|
await rollbackUploads(uploadedFiles);
|
||||||
return R.error(res, "Thumbnail buffer is required.", 400);
|
throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 });
|
||||||
}
|
}
|
||||||
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||||
const svc = getProvider(storage_provider);
|
const svc = getProvider(storage_provider);
|
||||||
@@ -443,8 +443,8 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
|
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||||
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
|
const parsedHeight = body.height ? parseInt(body.height) : null;
|
||||||
width = parsedWidth;
|
width = parsedWidth;
|
||||||
height = parsedHeight;
|
height = parsedHeight;
|
||||||
resolution = resolveResolution(parsedWidth, parsedHeight);
|
resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||||
@@ -481,9 +481,8 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
invalidateListCache();
|
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||||
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 asset;
|
||||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
|
||||||
|
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
try { await t.rollback(); } catch { /* connection gone */ }
|
try { await t.rollback(); } catch { /* connection gone */ }
|
||||||
@@ -491,6 +490,21 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
throw dbErr;
|
throw dbErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await rollbackUploads(uploadedFiles);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.uploadAsset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = req.files?.file?.[0];
|
||||||
|
const thumbFile = req.files?.thumbnail?.[0];
|
||||||
|
|
||||||
|
const asset = await createAssetFromUpload({ file, thumbFile, body: req.body, user: req.user });
|
||||||
|
invalidateListCache();
|
||||||
|
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][UPLOAD]", err);
|
console.error("[ASSET][UPLOAD]", err);
|
||||||
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
|
||||||
@@ -498,6 +512,53 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── UPLOAD (batch) ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Accepts multiple files under the "files" field in one multipart request,
|
||||||
|
// all sharing the same is_public / storage_provider / createdBy. Each file
|
||||||
|
// is uploaded independently — one failing (bad codec, DB constraint, etc.)
|
||||||
|
// does not roll back the others. display_name defaults to the filename
|
||||||
|
// (minus extension) since there's no per-file metadata step in bulk mode.
|
||||||
|
// Videos land without a thumbnail (see createAssetFromUpload) — add one
|
||||||
|
// later via the existing "replace thumbnail" path on PATCH /:assetId.
|
||||||
|
//
|
||||||
|
exports.uploadAssetsBatch = async (req, res) => {
|
||||||
|
const files = req.files ?? [];
|
||||||
|
if (!files.length) return R.error(res, "No files uploaded.", 400);
|
||||||
|
|
||||||
|
const { display_name, description, is_public = false, storage_provider = "chibisafe", createdBy } = req.body;
|
||||||
|
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const baseName = file.originalname.replace(/\.[^.]+$/, "");
|
||||||
|
const asset = await createAssetFromUpload({
|
||||||
|
file,
|
||||||
|
thumbFile: null,
|
||||||
|
body: {
|
||||||
|
display_name: files.length === 1 ? (display_name || baseName) : baseName,
|
||||||
|
description,
|
||||||
|
is_public,
|
||||||
|
storage_provider,
|
||||||
|
createdBy,
|
||||||
|
},
|
||||||
|
user: req.user,
|
||||||
|
requireVideoThumbnail: false,
|
||||||
|
});
|
||||||
|
results.push({ originalname: file.originalname, success: true, data: asset });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ASSET][UPLOAD BATCH]", file.originalname, err.stack || err);
|
||||||
|
results.push({ originalname: file.originalname, success: false, message: err.status ? err.message : "Internal server error." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateListCache();
|
||||||
|
const createdCount = results.filter((r) => r.success).length;
|
||||||
|
return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.updateAsset = async (req, res) => {
|
exports.updateAsset = async (req, res) => {
|
||||||
|
|||||||
@@ -77,23 +77,11 @@ exports.getGroupsDashboard = async (req, res) => {
|
|||||||
totalGroups,
|
totalGroups,
|
||||||
activeGroups,
|
activeGroups,
|
||||||
archivedGroups,
|
archivedGroups,
|
||||||
emptyGroups,
|
|
||||||
memberCountBreakdown,
|
memberCountBreakdown,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
mdl_UserGroups.count({ paranoid: false }),
|
mdl_UserGroups.count({ paranoid: false }),
|
||||||
mdl_UserGroups.count({ where: { is_active: true } }),
|
mdl_UserGroups.count({ where: { is_active: true } }),
|
||||||
mdl_UserGroups.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
mdl_UserGroups.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
||||||
mdl_UserGroups.count({
|
|
||||||
where: { is_active: true },
|
|
||||||
include: [{
|
|
||||||
model: mdl_UserGroupMembers,
|
|
||||||
required: false,
|
|
||||||
where: { deletedAt: null },
|
|
||||||
}],
|
|
||||||
having: literal('COUNT("UserGroupMembers"."user_id") = 0'),
|
|
||||||
group: ['UserGroup.group_id'],
|
|
||||||
subQuery: false,
|
|
||||||
}).catch(() => 0),
|
|
||||||
mdl_UserGroupMembers.findAll({
|
mdl_UserGroupMembers.findAll({
|
||||||
attributes: [
|
attributes: [
|
||||||
'group_id',
|
'group_id',
|
||||||
@@ -121,8 +109,6 @@ exports.getGroupsDashboard = async (req, res) => {
|
|||||||
{ key: 'total', label: 'Total Groups', value: totalGroups },
|
{ key: 'total', label: 'Total Groups', value: totalGroups },
|
||||||
{ key: 'active', label: 'Active Groups', value: activeGroups },
|
{ key: 'active', label: 'Active Groups', value: activeGroups },
|
||||||
{ key: 'inactive', label: 'Inactive Groups', value: totalGroups - archivedGroups - activeGroups },
|
{ key: 'inactive', label: 'Inactive Groups', value: totalGroups - archivedGroups - activeGroups },
|
||||||
{ key: 'archived', label: 'Archived', value: archivedGroups },
|
|
||||||
{ key: 'empty', label: 'Empty Groups', value: Array.isArray(emptyGroups) ? emptyGroups.length : emptyGroups },
|
|
||||||
],
|
],
|
||||||
breakdowns: [
|
breakdowns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// The app has resolved/handled "audio" as a file_type since day one (see
|
||||||
|
// resolveFileType() in assets.controller.js, ViewAudioAsset.jsx,
|
||||||
|
// mediaToken.SUPPORTED_TYPES) but the enum itself was never extended past
|
||||||
|
// the original ('avatar', 'document', 'video', 'image') — every audio
|
||||||
|
// upload has been silently 500ing at Asset.create() with an invalid-enum
|
||||||
|
// error. Discovered while adding batch asset uploads, which will hit this
|
||||||
|
// path directly for any audio file dropped into a batch.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`ALTER TYPE public.enum_assets_file_type ADD VALUE IF NOT EXISTS 'audio'`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Postgres has no ALTER TYPE ... DROP VALUE — reverting an enum value
|
||||||
|
// addition requires rebuilding the type, which isn't safe to do blindly
|
||||||
|
// in a down migration if any row already uses 'audio'.
|
||||||
|
async down() {},
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ const Asset = sequelize.define("Asset", {
|
|||||||
|
|
||||||
// ─── Classification ───────────────────────────────────────────────────────
|
// ─── Classification ───────────────────────────────────────────────────────
|
||||||
file_type: {
|
file_type: {
|
||||||
type: DataTypes.ENUM("avatar", "document", "video", "image"),
|
type: DataTypes.ENUM("avatar", "document", "video", "image", "audio"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: "image", label: "Classification", order: 0
|
defaultValue: "image", label: "Classification", order: 0
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ router.patch('/bulk-restore', controller.restoreAssets);
|
|||||||
// ─── Collection ───────────────────────────────────────────────────────────────
|
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||||
router.get('/', controller.getAssets);
|
router.get('/', controller.getAssets);
|
||||||
router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset);
|
router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset);
|
||||||
|
router.post('/batch', upload.array('files', 20), controller.uploadAssetsBatch);
|
||||||
|
|
||||||
// ─── Dynamic routes last ──────────────────────────────────────────────────────
|
// ─── Dynamic routes last ──────────────────────────────────────────────────────
|
||||||
router.get('/:assetId', controller.getAsset);
|
router.get('/:assetId', controller.getAsset);
|
||||||
|
|||||||
Reference in New Issue
Block a user