diff --git a/.gitignore b/.gitignore index 5a00c13..26ed82d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .env .DS_Store +uploads/ diff --git a/components/certificates/certificateService.js b/components/certificates/certificateService.js index eba0817..66b673e 100644 --- a/components/certificates/certificateService.js +++ b/components/certificates/certificateService.js @@ -15,7 +15,11 @@ const BUCKET = 'certificates'; const withAccessUrl = async (cert) => { const item = typeof cert.toObject === 'function' ? cert.toObject() : { ...cert }; item.fileUrl = item.fileUrl || null; - item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_CERTIFICATES_BUCKET); + try { + item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_CERTIFICATES_BUCKET); + } catch { + item.signedUrl = null; + } item.presignedUrl = item.signedUrl; item.url = item.fileUrl || item.signedUrl; return item; @@ -27,7 +31,7 @@ const createCertificate = async (data) => { if (!data.tempFileName) throw new AppError('FILE_REQUIRED'); const safeName = path.basename(data.tempFileName); - const targetKey = `certificates/cert-${Date.now()}-${safeName}`; + const targetKey = `cert-${Date.now()}-${safeName}`; const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET); const fileName = data.originalName || data.fileName || safeName; @@ -93,7 +97,7 @@ const updateCertificate = async (id, updateData) => { if (updateData.tempFileName) { const safeName = path.basename(updateData.tempFileName); - const targetKey = `certificates/cert-${Date.now()}-${safeName}`; + const targetKey = `cert-${Date.now()}-${safeName}`; const { fileKey, bucket, fileUrl } = await commitTempFile(updateData.tempFileName, targetKey, BUCKET); await deleteFromBucket(certificate.fileKey, certificate.bucket || config.S3_CERTIFICATES_BUCKET); certificate.fileKey = fileKey; diff --git a/components/documents/documentService.js b/components/documents/documentService.js index 2408758..4bd4f92 100644 --- a/components/documents/documentService.js +++ b/components/documents/documentService.js @@ -17,7 +17,11 @@ const BUCKET = 'documents'; const withAccessUrl = async (doc) => { const item = typeof doc.toObject === 'function' ? doc.toObject() : { ...doc }; item.fileUrl = item.fileUrl || null; - item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_DOCUMENTS_BUCKET); + try { + item.signedUrl = await generatePresignedUrl(item.fileKey, item.bucket || config.S3_DOCUMENTS_BUCKET); + } catch { + item.signedUrl = null; + } item.presignedUrl = item.signedUrl; item.url = item.fileUrl || item.signedUrl; return item; @@ -29,7 +33,7 @@ const createDocument = async (data, uploadedBy = null) => { if (!data.tempFileName) throw new AppError('FILE_REQUIRED'); const safeName = path.basename(data.tempFileName); - const targetKey = `documents/doc-${Date.now()}-${safeName}`; + const targetKey = `doc-${Date.now()}-${safeName}`; const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET); const document = await Document.create({ @@ -87,7 +91,7 @@ const updateDocument = async (id, updateData) => { if (updateData.tempFileName) { const safeName = path.basename(updateData.tempFileName); - const targetKey = `documents/doc-${Date.now()}-${safeName}`; + const targetKey = `doc-${Date.now()}-${safeName}`; const { fileKey, bucket, fileUrl } = await commitTempFile(updateData.tempFileName, targetKey, BUCKET); await deleteFromBucket(document.fileKey, document.bucket || config.S3_DOCUMENTS_BUCKET); document.fileKey = fileKey; diff --git a/components/files/fileController.js b/components/files/fileController.js index 2589890..43549f7 100644 --- a/components/files/fileController.js +++ b/components/files/fileController.js @@ -5,18 +5,28 @@ const fileService = require('./fileService'); const { successResponse } = require('../../utils/apiResponse'); const AppError = require('../../utils/AppError'); +const pickUploadedFile = (req) => { + if (req.file) return req.file; + if (Array.isArray(req.files) && req.files.length) return req.files[0]; + if (req.files && typeof req.files === 'object') { + return req.files.file?.[0] + || req.files['filepond-image']?.[0] + || Object.values(req.files).flat().find(Boolean) + || null; + } + return null; +}; + exports.uploadTemp = catchAsync(async (req, res, next) => { - if (!req.file) { + const uploaded = pickUploadedFile(req); + if (!uploaded) { return next(new AppError('FILE_REQUIRED')); } - const result = await fileService.uploadTempFile( - req.file.buffer, - req.file.originalname, - req.file.mimetype - ); + const result = await fileService.uploadTempFile(uploaded); - return successResponse(res, 201, 'File uploaded to temp bucket successfully', result); + // 200 matches the working FilePond Node.js uploader (2xx is required) + return successResponse(res, 200, 'File uploaded to temp bucket successfully', result); }); exports.getSignedUrl = catchAsync(async (req, res, next) => { @@ -27,6 +37,20 @@ exports.getSignedUrl = catchAsync(async (req, res, next) => { return successResponse(res, 200, 'Temporary presigned URL generated successfully', result); }); +exports.streamFile = catchAsync(async (req, res, next) => { + const key = req.query.key || req.params.filename; + const bucket = req.query.bucket; + + if (!key) { + return next(new AppError('VALIDATION_FAILED', null, 'Filename is required')); + } + + const { buffer, contentType } = await fileService.getFileContent(key, bucket); + res.set('Content-Type', contentType); + res.set('Cache-Control', 'private, max-age=60'); + return res.status(200).send(buffer); +}); + exports.deleteFile = catchAsync(async (req, res, next) => { const { filename } = req.params; const { bucket } = req.query; diff --git a/components/files/fileRoutes.js b/components/files/fileRoutes.js index 9d453d2..a716563 100644 --- a/components/files/fileRoutes.js +++ b/components/files/fileRoutes.js @@ -1,5 +1,7 @@ // /components/files/fileRoutes.js +const fs = require('fs'); +const path = require('path'); const express = require('express'); const multer = require('multer'); const fileController = require('./fileController'); @@ -7,24 +9,52 @@ const { validateGetSignedUrl } = require('./fileValidator'); const authMiddleware = require('../../middlewares/authMiddleware'); const perm = require('../../middlewares/permissionMiddleware'); const { PERMISSIONS } = require('../../constants/permissions'); +const AppError = require('../../utils/AppError'); const router = express.Router(); -// Memory Storage for Multer to stream directly to S3 temp bucket +const uploadDir = path.resolve(process.cwd(), 'uploads'); +fs.mkdirSync(uploadDir, { recursive: true }); + +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf' +]); + +const fileFilter = (req, file, cb) => { + if (ALLOWED_MIME.has(file.mimetype)) { + cb(null, true); + return; + } + cb(new AppError('UNSUPPORTED_FILE_TYPE'), false); +}; + +// Disk storage (same pattern as the working Node.js FilePond uploader), then stream to S3 const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 25 * 1024 * 1024 } // 25MB limit + dest: uploadDir, + fileFilter, + limits: { fileSize: 25 * 1024 * 1024 } }); +const uploadFilepond = upload.fields([ + { name: 'file', maxCount: 1 }, + { name: 'filepond-image', maxCount: 1 } +]); + router.use(authMiddleware); // User Scope -router.post('/user/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp); +router.post('/user/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), uploadFilepond, fileController.uploadTemp); router.get('/user/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl); +router.get('/user/content', perm.requires(PERMISSIONS.FILES_READ), fileController.streamFile); // Admin Scope -router.post('/admin/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp); +router.post('/admin/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), uploadFilepond, fileController.uploadTemp); router.get('/admin/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl); +router.get('/admin/content', perm.requires(PERMISSIONS.FILES_READ), fileController.streamFile); router.delete('/admin/delete/:filename', perm.requires(PERMISSIONS.FILES_DELETE), fileController.deleteFile); module.exports = router; diff --git a/components/files/fileService.js b/components/files/fileService.js index 536efd9..b25309a 100644 --- a/components/files/fileService.js +++ b/components/files/fileService.js @@ -1,17 +1,32 @@ // /components/files/fileService.js +const fs = require('fs/promises'); const path = require('path'); const config = require('../../config/config'); const { uploadToTempBucket, commitTempFile, generatePresignedUrl, + getObjectBuffer, deleteFromBucket } = require('../../utils/s3Client'); const AppError = require('../../utils/AppError'); -const uploadTempFile = async (fileBuffer, originalName, mimeType) => { - if (!fileBuffer || !originalName) { +const unlinkQuietly = async (filePath) => { + if (!filePath) return; + try { + await fs.unlink(filePath); + } catch { + // temp disk file may already be gone + } +}; + +const uploadTempFile = async (file) => { + const originalName = file?.originalname || file?.originalName; + const mimeType = file?.mimetype || file?.mimeType || 'application/octet-stream'; + const source = file?.path || file?.buffer; + + if (!source || !originalName) { throw new AppError('FILE_REQUIRED'); } @@ -19,13 +34,17 @@ const uploadTempFile = async (fileBuffer, originalName, mimeType) => { const uniquePrefix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`; const tempFileName = `temp-${uniquePrefix}${ext}`; - const result = await uploadToTempBucket(fileBuffer, tempFileName, mimeType); - return { - tempFileName: result.tempFileName, - originalName, - mimeType, - bucket: result.bucket - }; + try { + const result = await uploadToTempBucket(source, tempFileName, mimeType); + return { + tempFileName: result.tempFileName, + originalName, + mimeType, + bucket: result.bucket + }; + } finally { + await unlinkQuietly(file.path); + } }; const getPresignedUrl = async (filename, bucket = null) => { @@ -44,6 +63,13 @@ const getPresignedUrl = async (filename, bucket = null) => { }; }; +const getFileContent = async (filename, bucket = null) => { + if (!filename) { + throw new AppError('VALIDATION_FAILED', null, 'Filename is required'); + } + return getObjectBuffer(filename, bucket || config.S3_TEMP_BUCKET); +}; + const commitFile = async (tempFileName, targetFileName = null, targetBucket = 'certificates') => { return commitTempFile(tempFileName, targetFileName, targetBucket); }; @@ -55,6 +81,7 @@ const deleteFile = async (filename, bucket = null) => { module.exports = { uploadTempFile, getPresignedUrl, + getFileContent, commitFile, deleteFile }; diff --git a/middlewares/globalErrorHandler.js b/middlewares/globalErrorHandler.js index bd77bcc..1015feb 100644 --- a/middlewares/globalErrorHandler.js +++ b/middlewares/globalErrorHandler.js @@ -63,6 +63,13 @@ const globalErrorHandler = (err, req, res, next) => { }, {}); } + // Handle Multer upload errors + if (err.name === 'MulterError') { + statusCode = 400; + errorCode = err.code === 'LIMIT_FILE_SIZE' ? 'VALIDATION_FAILED' : 'FILE_REQUIRED'; + details = { reason: err.code, field: err.field }; + } + // Handle Joi validation errors if forwarded as standard Error if (err.isJoi) { statusCode = 400; diff --git a/utils/errors.json b/utils/errors.json index 8de852b..b382b58 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -144,6 +144,16 @@ "en": "File is required.", "fa": "ارسال فایل الزامی است." }, + "FILE_NOT_FOUND": { + "statusCode": 404, + "en": "The requested file was not found.", + "fa": "فایل مورد نظر یافت نشد." + }, + "UNSUPPORTED_FILE_TYPE": { + "statusCode": 400, + "en": "This file type is not allowed. Use JPEG, PNG, WebP, GIF, or PDF.", + "fa": "این نوع فایل مجاز نیست. از JPEG، PNG، WebP، GIF یا PDF استفاده کنید." + }, "FILE_COMMIT_FAILED": { "statusCode": 502, "en": "Failed to store the uploaded file. Please try again.", diff --git a/utils/s3Client.js b/utils/s3Client.js index 0c03324..ef83e03 100644 --- a/utils/s3Client.js +++ b/utils/s3Client.js @@ -1,5 +1,6 @@ // /utils/s3Client.js +const fs = require('fs'); const { S3Client, PutObjectCommand, @@ -161,14 +162,21 @@ const ensureAppBuckets = async () => { } }; -const uploadToTempBucket = async (fileBuffer, filename, contentType = 'application/octet-stream') => { +const toPutBody = (fileBufferOrPath) => { + if (typeof fileBufferOrPath === 'string') { + return fs.createReadStream(fileBufferOrPath); + } + return fileBufferOrPath; +}; + +const uploadToTempBucket = async (fileBufferOrPath, filename, contentType = 'application/octet-stream') => { try { await ensureBucket(config.S3_TEMP_BUCKET); const client = getS3Client(); const command = new PutObjectCommand({ Bucket: config.S3_TEMP_BUCKET, Key: filename, - Body: fileBuffer, + Body: toPutBody(fileBufferOrPath), ContentType: contentType }); @@ -186,6 +194,30 @@ const uploadToTempBucket = async (fileBuffer, filename, contentType = 'applicati } }; +const getObjectBuffer = async (filename, bucketName = config.S3_CERTIFICATES_BUCKET) => { + if (!filename) { + throw new AppError('VALIDATION_FAILED', null, 'Filename is required'); + } + + const bucket = resolveBucket(bucketName); + try { + const client = getS3Client(); + const result = await client.send(new GetObjectCommand({ + Bucket: bucket, + Key: filename + })); + const buffer = await streamToBuffer(result.Body); + return { + buffer, + contentType: result.ContentType || 'application/octet-stream', + bucket + }; + } catch (error) { + logS3Error(`GetObject failed for ${bucket}/${filename}`, error); + throw new AppError('FILE_NOT_FOUND'); + } +}; + const rewriteTempObject = async (client, tempFilename, targetBucket, targetKey) => { const source = await client.send(new GetObjectCommand({ Bucket: config.S3_TEMP_BUCKET, @@ -256,7 +288,10 @@ const generatePresignedUrl = async ( return await getSignedUrl(client, command, { expiresIn }); } catch (error) { logger.error(`[S3 Storage ERROR] Failed to generate presigned URL for ${filename}: ${error.message}`); - return `${config.S3_ENDPOINT}/${bucket}/${filename}?token=mock_presigned_${Date.now()}`; + if (config.NODE_ENV === 'test') { + return `${config.S3_ENDPOINT}/${bucket}/${filename}?token=mock_presigned_${Date.now()}`; + } + throw error; } }; @@ -314,6 +349,7 @@ module.exports = { uploadToTempBucket, commitTempFile, generatePresignedUrl, + getObjectBuffer, deleteFromBucket, cleanupTempBucket, resolveBucket,