From 08dd6879121337b69a3f81dc9a0ee7b395f34690 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 1 Aug 2026 12:18:23 +0800 Subject: [PATCH] assets: go 15GB limit Signed-off-by: Kenneth Obsequio --- controllers/admin/assets.controller.js | 63 ++++++++++++-------------- middleware/asset_upload.middleware.js | 50 -------------------- routes/admin/assets.routes.js | 5 +- 3 files changed, 33 insertions(+), 85 deletions(-) delete mode 100644 middleware/asset_upload.middleware.js diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 102750c..6f63da3 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -1,7 +1,6 @@ // controllers/admin/assets.controller.js const path = require("path"); -const crypto = require("crypto"); const sequelize = require("../../config/db.config"); const Asset = require("../../models/assets/assets.mdl"); const chibi = require("../../services/chibisafe.service"); @@ -57,10 +56,6 @@ function resolveExtension(originalName = "") { return path.extname(originalName).replace(".", "").toLowerCase() || null; } -function resolveChecksum(buffer) { - return crypto.createHash("sha256").update(buffer).digest("hex"); -} - function streamToBuffer(stream) { return new Promise((resolve, reject) => { const chunks = []; @@ -112,32 +107,33 @@ async function rollbackUploads(uploads = []) { } } -// ─── uploadToProvider ───────────────────────────────────────────────────────── +// ─── finalizeReplacementUpload ───────────────────────────────────────────────── // -// Uploads a single file to the resolved provider. -// Returns { file_url, storage_key, mime_type, extension, checksum, file_type } +// Used by updateAsset() when replacing an asset's file (or a video's +// thumbnail): the browser already PUT the new file straight to storage via a +// presigned URL (see presignAssetUpload) — this reads back what actually +// landed there (HeadObjectCommand, no download) instead of ever buffering the +// file through this backend, the same strategy finalizeAssetFromStorage() +// uses for brand-new assets. +// Returns { file_url, storage_key, mime_type, extension, checksum, file_type, originalname } // -async function uploadToProvider(file, ownerType, storageProvider) { - const mime_type = file.mimetype; - const extension = resolveExtension(file.originalname); - const checksum = resolveChecksum(file.buffer); - const file_type = resolveFileType(mime_type); +async function finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider) { + const svc = getProvider(storageProvider); + if (!svc || !svc.getFileMetadata) { + throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 }); + } - const svc = getProvider(storageProvider); - const result = await svc.uploadFile({ - buffer: file.buffer, - originalname: file.originalname, - mimetype: mime_type, - ownerType, - }); + const meta = await svc.getFileMetadata(storage_key); + const mime_type = meta.mimetype || mimetype || "application/octet-stream"; return { - file_url: result.url, - storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB + file_url: await svc.buildPublicUrl(storage_key), + storage_key, mime_type, - extension, - checksum, - file_type, + extension: resolveExtension(original_name || storage_key), + checksum: meta.checksum, + file_type: resolveFileType(mime_type), + originalname: original_name || storage_key, }; } @@ -657,13 +653,15 @@ exports.updateAsset = async (req, res) => { 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; + // Browser already PUT the replacement file straight to storage via a + // presigned URL (see presignAssetUpload) — this is plain JSON, no + // multer/file buffer involved, same pattern as POST /admin/assets. + const { storage_key, original_name, mimetype } = req.body; 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); + if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400); + if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400); const storageProvider = asset.storage_provider; const usesProvider = ["chibisafe", "s3"].includes(storageProvider); @@ -673,9 +671,8 @@ exports.updateAsset = async (req, res) => { let uploaded = null; - if (file && usesProvider) { - const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); - uploaded = await uploadToProvider(file, ownerType, storageProvider); + if (storage_key && usesProvider) { + uploaded = await finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider); newUpload = { key: uploaded.storage_key, provider: storageProvider }; } @@ -683,7 +680,7 @@ exports.updateAsset = async (req, res) => { const t = await sequelize.transaction(); try { - await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body); + await applyAssetUpdate(asset, uploaded, req.body); await asset.save({ transaction: t }); await t.commit(); } catch (dbErr) { diff --git a/middleware/asset_upload.middleware.js b/middleware/asset_upload.middleware.js deleted file mode 100644 index b90d95f..0000000 --- a/middleware/asset_upload.middleware.js +++ /dev/null @@ -1,50 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: asset_upload.middleware.js - * Type of Program: Middleware - * Description: Multer config for admin asset uploads (image/video/audio/document). - * Uses memory storage — buffer is passed directly to S3. - * - * Previously this route had no `limits.fileSize` at all, so an - * oversized upload wasn't rejected cleanly — it either exhausted - * server memory buffering the whole file or blew up with a raw - * multer/Node error that never reached R.error's JSON envelope. - * The frontend's fallback then had nothing to read a `message` - * out of, so every failure surfaced as the same generic - * "Something went wrong" regardless of cause. - * - * Limits: - * fileSize: 500 MB (matches task_upload.middleware.js) - * - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jul. 13, 2026 - ***********************************************************************************************************************************************************************/ -const multer = require('multer'); -const R = require('../utils/response.util'); - -const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB - -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: MAX_FILE_SIZE }, -}); - -// Wraps any multer middleware (fields/array/single) so oversized or malformed -// uploads always come back as a clean R.error JSON response the frontend can -// read a `message` out of, instead of an unhandled exception or Express's -// default HTML error page. -function handleUpload(multerMiddleware) { - return (req, res, next) => { - multerMiddleware(req, res, (err) => { - if (!err) return next(); - - if (err.code === 'LIMIT_FILE_SIZE') { - return R.error(res, `File exceeds the ${MAX_FILE_SIZE / (1024 * 1024)} MB size limit.`, 400); - } - - console.error('[MULTER ERROR][ASSETS]', err); - return R.error(res, err.message ?? 'File upload failed.', 400); - }); - }; -} - -module.exports = { upload, handleUpload, MAX_FILE_SIZE }; diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index 93ea955..f4a1416 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -1,7 +1,6 @@ // routes/admin/assets.routes.js const router = require('express').Router(); -const { upload, handleUpload } = require('../../middleware/asset_upload.middleware'); const controller = require('../../controllers/admin/assets.controller'); const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); @@ -29,7 +28,9 @@ router.post('/', controller.uploadAsset); // ─── Dynamic routes last ────────────────────────────────────────────────────── router.get('/:assetId', controller.getAsset); router.post('/:assetId/convert-to-markdown', sensitiveOpsLimiter, controller.convertAssetToMarkdown); -router.patch('/:assetId', sensitiveOpsLimiter, handleUpload(upload.fields([{ name: 'file', maxCount: 1 }])), controller.updateAsset); +// Replace flow also goes through presign + direct PUT (browser -> storage) — +// this body is plain JSON (storage_key etc.), same as POST /. No multer here. +router.patch('/:assetId', sensitiveOpsLimiter, controller.updateAsset); router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset); router.delete('/:assetId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAsset);