diff --git a/.env.example b/.env.example index 6021cb3..95f1546 100644 --- a/.env.example +++ b/.env.example @@ -15,15 +15,46 @@ DEFAULT_LANG=fa CORS_ORIGIN=* UPLOAD_PATH=uploads -# S3 / MinIO Private Storage Settings +# ----------------------------------------------------------------------------- +# S3-compatible object storage +# Local default: MinIO (docker-compose) +# Production (SeaweedFS): set SERVICE_* / AWS_* from your host, or set S3_* directly +# ----------------------------------------------------------------------------- +# Local MinIO example: S3_ENDPOINT=http://localhost:9000 S3_REGION=us-east-1 S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin -S3_TEMP_BUCKET=gameno-temp -S3_STORAGE_BUCKET=gameno-storage + +# SeaweedFS live example (preferred aliases also work without S3_*): +# Use the public HTTPS URL (SERVICE_URL_S3). Port 8333 is often internal-only. +# S3_ENDPOINT=https://s3.game-no.ir +# SERVICE_URL_S3=https://s3.game-no.ir +# SERVICE_URL_S3_8333=https://s3.game-no.ir:8333 +# SERVICE_USER_S3=... +# SERVICE_PASSWORD_S3=... +# AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} +# AWS_SECRET_ACCESS_KEY=${SERVICE_PASSWORD_S3} + +S3_TEMP_BUCKET=temp +S3_CERTIFICATES_BUCKET=certificates +S3_DOCUMENTS_BUCKET=documents +S3_STORAGE_BUCKET=certificates S3_FORCE_PATH_STYLE=true SIGNED_URL_EXPIRES_IN=900 +# Private by default — set true only if a bucket is publicly readable +S3_CERTIFICATES_PUBLIC=false +S3_DOCUMENTS_PUBLIC=false +# Optional public CDN/base URL used only when a bucket is public +S3_PUBLIC_BASE_URL= + +# Optional SeaweedFS admin console (bucket management UI) +# SERVICE_URL_ADMIN=https://admin-s3.game-no.ir +# SERVICE_URL_ADMIN_23646=https://admin-s3.game-no.ir:23646 +# SERVICE_USER_ADMIN=... +# SERVICE_PASSWORD_ADMIN=... +# SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} +# SEAWEED_PASSWORD_ADMIN=${SERVICE_PASSWORD_ADMIN} # SMTP Email Configuration SMTP_HOST=smtp.mailtrap.io diff --git a/app.js b/app.js index e2efb28..7763581 100644 --- a/app.js +++ b/app.js @@ -24,6 +24,7 @@ const paymentRoutes = require('./components/payments/paymentRoutes'); const roleRoutes = require('./components/roles/roleRoutes'); const notificationRoutes = require('./components/notifications/notificationRoutes'); const certificateRoutes = require('./components/certificates/certificateRoutes'); +const documentRoutes = require('./components/documents/documentRoutes'); const fileRoutes = require('./components/files/fileRoutes'); const dashboardRoutes = require('./components/dashboard/dashboardRoutes'); const activityLogRoutes = require('./components/activityLogs/activityLogRoutes'); @@ -91,6 +92,7 @@ app.use('/api/payments', paymentRoutes); app.use('/api/roles', roleRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api/certificates', certificateRoutes); +app.use('/api/documents', documentRoutes); app.use('/api/files', fileRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/activity-logs', activityLogRoutes); diff --git a/components/certificates/certificateController.js b/components/certificates/certificateController.js index a7404eb..7e792c7 100644 --- a/components/certificates/certificateController.js +++ b/components/certificates/certificateController.js @@ -19,6 +19,11 @@ exports.getAll = catchAsync(async (req, res, next) => { return listResponse(res, 200, data, meta); }); +exports.getByUser = catchAsync(async (req, res) => { + const data = await certificateService.getCertificatesByUser(req.params.userId); + return successResponse(res, 200, 'User certificates retrieved successfully', data); +}); + exports.update = catchAsync(async (req, res, next) => { const certificate = await certificateService.updateCertificate(req.params.id, req.body); return successResponse(res, 200, 'Certificate updated successfully', certificate); diff --git a/components/certificates/certificateModel.js b/components/certificates/certificateModel.js index 6be7ae1..f3bf48b 100644 --- a/components/certificates/certificateModel.js +++ b/components/certificates/certificateModel.js @@ -23,15 +23,37 @@ const certificateSchema = new mongoose.Schema({ type: String, trim: true }, + /** Permanent object key inside the certificates bucket */ fileKey: { type: String, required: true, trim: true }, + /** Original uploaded filename */ + fileName: { + type: String, + trim: true + }, + /** Legacy alias kept in sync with fileName */ originalName: { type: String, trim: true }, + /** Public URL only when the certificates bucket is public; otherwise null */ + fileUrl: { + type: String, + trim: true, + default: null + }, + bucket: { + type: String, + trim: true, + default: 'certificates' + }, + mimeType: { + type: String, + trim: true + }, issuedAt: { type: Date, default: Date.now diff --git a/components/certificates/certificateRoutes.js b/components/certificates/certificateRoutes.js index 8121a73..e8f8e7e 100644 --- a/components/certificates/certificateRoutes.js +++ b/components/certificates/certificateRoutes.js @@ -22,6 +22,7 @@ router.post('/user/upload', perm.requires(PERMISSIONS.FILES_UPLOAD), validateUpl // Admin Scope router.post('/admin/create', perm.requires(PERMISSIONS.CERTIFICATES_CREATE), validateCreateCertificate, certificateController.create); router.get('/admin/get-all', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getAll); +router.get('/admin/by-user/:userId', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getByUser); router.get('/admin/search', perm.requires(PERMISSIONS.CERTIFICATES_SEARCH), certificateController.search); router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getOne); router.put('/admin/update/:id', perm.requires(PERMISSIONS.CERTIFICATES_UPDATE), validateUpdateCertificate, certificateController.update); diff --git a/components/certificates/certificateService.js b/components/certificates/certificateService.js index 07a96d3..eba0817 100644 --- a/components/certificates/certificateService.js +++ b/components/certificates/certificateService.js @@ -4,26 +4,44 @@ const path = require('path'); const Certificate = require('./certificateModel'); const User = require('../users/userModel'); const AppError = require('../../utils/AppError'); +const config = require('../../config/config'); const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); +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); + item.presignedUrl = item.signedUrl; + item.url = item.fileUrl || item.signedUrl; + return item; +}; + const createCertificate = async (data) => { const user = await User.findById(data.user); if (!user) throw new AppError('USER_NOT_FOUND'); + if (!data.tempFileName) throw new AppError('FILE_REQUIRED'); - // Move file from Temp bucket to Permanent Storage bucket - const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`; - const { fileKey } = await commitTempFile(data.tempFileName, targetKey); + const safeName = path.basename(data.tempFileName); + const targetKey = `certificates/cert-${Date.now()}-${safeName}`; + const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET); + const fileName = data.originalName || data.fileName || safeName; const certificate = await Certificate.create({ user: data.user, course: data.course || null, - title: data.title, + title: data.title || fileName, issuer: data.issuer || '', fileKey, - originalName: data.originalName || path.basename(data.tempFileName), + fileName, + originalName: fileName, + fileUrl: fileUrl || null, + bucket, + mimeType: data.mimeType || '', isOfficial: data.isOfficial || false }); @@ -32,27 +50,28 @@ const createCertificate = async (data) => { eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id }); - const resultObj = certificate.toObject(); - resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey); - return resultObj; + return withAccessUrl(certificate); }; const getCertificateById = async (id) => { const certificate = await Certificate.findById(id) .populate('user', 'name username nationalIdCode') .populate('course', 'title type'); - if (!certificate) { - throw new AppError('CERTIFICATE_NOT_FOUND'); - } + if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND'); + return withAccessUrl(certificate); +}; - const resultObj = certificate.toObject(); - resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey); - return resultObj; +const getCertificatesByUser = async (userId) => { + const certificates = await Certificate.find({ user: userId }) + .populate('course', 'title type') + .sort({ createdAt: -1 }); + return Promise.all(certificates.map(withAccessUrl)); }; const getAllCertificates = async (queryParams) => { const { page, limit, skip, sort } = parsePaginationAndSort(queryParams); const filter = buildFilterQuery(queryParams, ['title', 'issuer']); + if (queryParams.userId) filter.user = queryParams.userId; const [certificates, totalCount] = await Promise.all([ Certificate.find(filter) @@ -64,30 +83,28 @@ const getAllCertificates = async (queryParams) => { Certificate.countDocuments(filter) ]); - const listWithUrls = await Promise.all( - certificates.map(async (cert) => { - const item = cert.toObject(); - item.presignedUrl = await generatePresignedUrl(cert.fileKey); - return item; - }) - ); - - const meta = calculateMeta(totalCount, page, limit); - return { data: listWithUrls, meta }; + const data = await Promise.all(certificates.map(withAccessUrl)); + return { data, meta: calculateMeta(totalCount, page, limit) }; }; const updateCertificate = async (id, updateData) => { const certificate = await Certificate.findById(id); - if (!certificate) { - throw new AppError('CERTIFICATE_NOT_FOUND'); - } + if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND'); if (updateData.tempFileName) { - const targetKey = `certificates/cert-${Date.now()}-${path.basename(updateData.tempFileName)}`; - const { fileKey } = await commitTempFile(updateData.tempFileName, targetKey); - // Delete old file from storage bucket - await deleteFromBucket(certificate.fileKey); + const safeName = path.basename(updateData.tempFileName); + const targetKey = `certificates/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; + certificate.bucket = bucket; + certificate.fileUrl = fileUrl || null; + const fileName = updateData.originalName || updateData.fileName; + if (fileName) { + certificate.fileName = fileName; + certificate.originalName = fileName; + } + if (updateData.mimeType) certificate.mimeType = updateData.mimeType; } if (updateData.title) certificate.title = updateData.title; @@ -95,53 +112,28 @@ const updateCertificate = async (id, updateData) => { if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial; await certificate.save(); - - const resultObj = certificate.toObject(); - resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey); - return resultObj; + return withAccessUrl(certificate); }; const deleteCertificate = async (id) => { const certificate = await Certificate.findById(id); - if (!certificate) { - throw new AppError('CERTIFICATE_NOT_FOUND'); - } + if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND'); - await deleteFromBucket(certificate.fileKey); + await deleteFromBucket(certificate.fileKey, certificate.bucket || config.S3_CERTIFICATES_BUCKET); await User.findByIdAndUpdate(certificate.user, { $pull: { certificates: certificate._id } }); await Certificate.findByIdAndDelete(id); return null; }; -const searchCertificates = async (queryParams) => { - return getAllCertificates(queryParams); -}; +const searchCertificates = async (queryParams) => getAllCertificates(queryParams); const uploadUserCertificate = async (userId, data) => { - const user = await User.findById(userId); - if (!user) throw new AppError('USER_NOT_FOUND'); - - const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`; - const { fileKey } = await commitTempFile(data.tempFileName, targetKey); - - const certificate = await Certificate.create({ + return createCertificate({ + ...data, user: userId, - course: data.course || null, - title: data.title, issuer: data.issuer || 'Self-Uploaded', - fileKey, - originalName: data.originalName || path.basename(data.tempFileName), isOfficial: false }); - - user.certificates.push(certificate._id); - await user.save(); - - eventEmitter.emit(EVENT_NAMES.CERTIFICATE_UPLOADED, { certificateId: certificate._id, userId }); - - const resultObj = certificate.toObject(); - resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey); - return resultObj; }; const getMyCertificates = async (userId, queryParams) => { @@ -157,21 +149,14 @@ const getMyCertificates = async (userId, queryParams) => { Certificate.countDocuments(filter) ]); - const listWithUrls = await Promise.all( - certificates.map(async (cert) => { - const item = cert.toObject(); - item.presignedUrl = await generatePresignedUrl(cert.fileKey); - return item; - }) - ); - - const meta = calculateMeta(totalCount, page, limit); - return { data: listWithUrls, meta }; + const data = await Promise.all(certificates.map(withAccessUrl)); + return { data, meta: calculateMeta(totalCount, page, limit) }; }; module.exports = { createCertificate, getCertificateById, + getCertificatesByUser, getAllCertificates, updateCertificate, deleteCertificate, diff --git a/components/documents/documentController.js b/components/documents/documentController.js new file mode 100644 index 0000000..8f13a37 --- /dev/null +++ b/components/documents/documentController.js @@ -0,0 +1,40 @@ +// /components/documents/documentController.js + +const catchAsync = require('../../utils/catchAsync'); +const documentService = require('./documentService'); +const { successResponse, listResponse } = require('../../utils/apiResponse'); + +exports.create = catchAsync(async (req, res) => { + const document = await documentService.createDocument(req.body, req.user?._id); + return successResponse(res, 201, 'Document created successfully', document); +}); + +exports.getOne = catchAsync(async (req, res) => { + const document = await documentService.getDocumentById(req.params.id); + return successResponse(res, 200, 'Document retrieved successfully', document); +}); + +exports.getAll = catchAsync(async (req, res) => { + const { data, meta } = await documentService.getAllDocuments(req.query); + return listResponse(res, 200, data, meta); +}); + +exports.getByUser = catchAsync(async (req, res) => { + const data = await documentService.getDocumentsByUser(req.params.userId); + return successResponse(res, 200, 'User documents retrieved successfully', data); +}); + +exports.update = catchAsync(async (req, res) => { + const document = await documentService.updateDocument(req.params.id, req.body); + return successResponse(res, 200, 'Document updated successfully', document); +}); + +exports.delete = catchAsync(async (req, res) => { + await documentService.deleteDocument(req.params.id); + return successResponse(res, 200, 'Document deleted successfully'); +}); + +exports.getMyDocuments = catchAsync(async (req, res) => { + const { data, meta } = await documentService.getMyDocuments(req.user._id, req.query); + return listResponse(res, 200, data, meta); +}); diff --git a/components/documents/documentModel.js b/components/documents/documentModel.js new file mode 100644 index 0000000..bb5e985 --- /dev/null +++ b/components/documents/documentModel.js @@ -0,0 +1,56 @@ +// /components/documents/documentModel.js + +const mongoose = require('mongoose'); + +const documentSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + title: { + type: String, + required: true, + trim: true + }, + description: { + type: String, + trim: true, + default: '' + }, + /** Permanent object key inside the documents bucket */ + fileKey: { + type: String, + required: true, + trim: true + }, + /** Original uploaded filename */ + fileName: { + type: String, + trim: true + }, + /** Public URL only when the documents bucket is public; otherwise null */ + fileUrl: { + type: String, + trim: true, + default: null + }, + bucket: { + type: String, + trim: true, + default: 'documents' + }, + mimeType: { + type: String, + trim: true + }, + uploadedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + } +}, { + timestamps: true +}); + +module.exports = mongoose.model('Document', documentSchema); diff --git a/components/documents/documentRoutes.js b/components/documents/documentRoutes.js new file mode 100644 index 0000000..a021148 --- /dev/null +++ b/components/documents/documentRoutes.js @@ -0,0 +1,22 @@ +// /components/documents/documentRoutes.js + +const express = require('express'); +const documentController = require('./documentController'); +const authMiddleware = require('../../middlewares/authMiddleware'); +const perm = require('../../middlewares/permissionMiddleware'); +const { PERMISSIONS } = require('../../constants/permissions'); + +const router = express.Router(); + +router.use(authMiddleware); + +router.get('/user/my-documents', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getMyDocuments); + +router.post('/admin/create', perm.requires(PERMISSIONS.DOCUMENTS_CREATE), documentController.create); +router.get('/admin/get-all', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getAll); +router.get('/admin/by-user/:userId', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getByUser); +router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.DOCUMENTS_READ), documentController.getOne); +router.put('/admin/update/:id', perm.requires(PERMISSIONS.DOCUMENTS_UPDATE), documentController.update); +router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.DOCUMENTS_DELETE), documentController.delete); + +module.exports = router; diff --git a/components/documents/documentService.js b/components/documents/documentService.js new file mode 100644 index 0000000..2408758 --- /dev/null +++ b/components/documents/documentService.js @@ -0,0 +1,140 @@ +// /components/documents/documentService.js + +const path = require('path'); +const Document = require('./documentModel'); +const User = require('../users/userModel'); +const AppError = require('../../utils/AppError'); +const config = require('../../config/config'); +const { + commitTempFile, + generatePresignedUrl, + deleteFromBucket +} = require('../../utils/s3Client'); +const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); + +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); + item.presignedUrl = item.signedUrl; + item.url = item.fileUrl || item.signedUrl; + return item; +}; + +const createDocument = async (data, uploadedBy = null) => { + const user = await User.findById(data.user); + if (!user) throw new AppError('USER_NOT_FOUND'); + if (!data.tempFileName) throw new AppError('FILE_REQUIRED'); + + const safeName = path.basename(data.tempFileName); + const targetKey = `documents/doc-${Date.now()}-${safeName}`; + const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET); + + const document = await Document.create({ + user: data.user, + title: data.title || data.originalName || safeName, + description: data.description || '', + fileKey, + fileName: data.originalName || data.fileName || safeName, + fileUrl: fileUrl || null, + bucket, + mimeType: data.mimeType || '', + uploadedBy: uploadedBy || null + }); + + if (Array.isArray(user.documents)) { + user.documents.push(document._id); + await user.save(); + } + + return withAccessUrl(document); +}; + +const getDocumentById = async (id) => { + const document = await Document.findById(id).populate('user', 'name username nationalIdCode'); + if (!document) throw new AppError('DOCUMENT_NOT_FOUND'); + return withAccessUrl(document); +}; + +const getDocumentsByUser = async (userId) => { + const documents = await Document.find({ user: userId }).sort({ createdAt: -1 }); + return Promise.all(documents.map(withAccessUrl)); +}; + +const getAllDocuments = async (queryParams) => { + const { page, limit, skip, sort } = parsePaginationAndSort(queryParams); + const filter = buildFilterQuery(queryParams, ['title', 'fileName', 'description']); + if (queryParams.userId) filter.user = queryParams.userId; + + const [documents, totalCount] = await Promise.all([ + Document.find(filter) + .populate('user', 'name username') + .sort(sort) + .skip(skip) + .limit(limit), + Document.countDocuments(filter) + ]); + + const data = await Promise.all(documents.map(withAccessUrl)); + return { data, meta: calculateMeta(totalCount, page, limit) }; +}; + +const updateDocument = async (id, updateData) => { + const document = await Document.findById(id); + if (!document) throw new AppError('DOCUMENT_NOT_FOUND'); + + if (updateData.tempFileName) { + const safeName = path.basename(updateData.tempFileName); + const targetKey = `documents/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; + document.bucket = bucket; + document.fileUrl = fileUrl || null; + if (updateData.originalName || updateData.fileName) { + document.fileName = updateData.originalName || updateData.fileName; + } + if (updateData.mimeType) document.mimeType = updateData.mimeType; + } + + if (updateData.title) document.title = updateData.title; + if (updateData.description !== undefined) document.description = updateData.description; + + await document.save(); + return withAccessUrl(document); +}; + +const deleteDocument = async (id) => { + const document = await Document.findById(id); + if (!document) throw new AppError('DOCUMENT_NOT_FOUND'); + + await deleteFromBucket(document.fileKey, document.bucket || config.S3_DOCUMENTS_BUCKET); + await User.findByIdAndUpdate(document.user, { $pull: { documents: document._id } }); + await Document.findByIdAndDelete(id); + return null; +}; + +const getMyDocuments = async (userId, queryParams = {}) => { + const filter = { user: userId, ...buildFilterQuery(queryParams, ['title', 'fileName']) }; + const { page, limit, skip, sort } = parsePaginationAndSort(queryParams); + + const [documents, totalCount] = await Promise.all([ + Document.find(filter).sort(sort).skip(skip).limit(limit), + Document.countDocuments(filter) + ]); + + const data = await Promise.all(documents.map(withAccessUrl)); + return { data, meta: calculateMeta(totalCount, page, limit) }; +}; + +module.exports = { + createDocument, + getDocumentById, + getDocumentsByUser, + getAllDocuments, + updateDocument, + deleteDocument, + getMyDocuments +}; diff --git a/components/files/fileService.js b/components/files/fileService.js index 9b9fe6d..536efd9 100644 --- a/components/files/fileService.js +++ b/components/files/fileService.js @@ -1,6 +1,7 @@ // /components/files/fileService.js const path = require('path'); +const config = require('../../config/config'); const { uploadToTempBucket, commitTempFile, @@ -22,7 +23,8 @@ const uploadTempFile = async (fileBuffer, originalName, mimeType) => { return { tempFileName: result.tempFileName, originalName, - mimeType + mimeType, + bucket: result.bucket }; }; @@ -31,20 +33,23 @@ const getPresignedUrl = async (filename, bucket = null) => { throw new AppError('VALIDATION_FAILED', null, 'Filename is required'); } - const signedUrl = await generatePresignedUrl(filename, bucket || undefined); + const targetBucket = bucket || config.S3_TEMP_BUCKET; + const signedUrl = await generatePresignedUrl(filename, targetBucket); return { filename, + bucket: targetBucket, presignedUrl: signedUrl, - expiresInSeconds: 900 + signedUrl, + expiresInSeconds: config.SIGNED_URL_EXPIRES_IN }; }; -const commitFile = async (tempFileName, targetFileName = null) => { - return commitTempFile(tempFileName, targetFileName); +const commitFile = async (tempFileName, targetFileName = null, targetBucket = 'certificates') => { + return commitTempFile(tempFileName, targetFileName, targetBucket); }; const deleteFile = async (filename, bucket = null) => { - return deleteFromBucket(filename, bucket); + return deleteFromBucket(filename, bucket || config.S3_TEMP_BUCKET); }; module.exports = { diff --git a/components/users/userModel.js b/components/users/userModel.js index 5a7ff39..39aea74 100644 --- a/components/users/userModel.js +++ b/components/users/userModel.js @@ -102,6 +102,10 @@ const userSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, ref: 'Certificate' }], + documents: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Document' + }], refreshTokens: [refreshTokenSchema], isActive: { type: Boolean, diff --git a/config/config.js b/config/config.js index f9d0b89..4991db9 100644 --- a/config/config.js +++ b/config/config.js @@ -29,15 +29,49 @@ const config = { DEFAULT_LANG: process.env.DEFAULT_LANG || 'en', UPLOAD_PATH: process.env.UPLOAD_PATH || 'uploads', - // S3 / MinIO Object Storage Settings - S3_ENDPOINT: process.env.S3_ENDPOINT || 'http://localhost:9000', - S3_REGION: process.env.S3_REGION || 'us-east-1', - S3_ACCESS_KEY: process.env.S3_ACCESS_KEY || 'minioadmin', - S3_SECRET_KEY: process.env.S3_SECRET_KEY || 'minioadmin', - S3_TEMP_BUCKET: process.env.S3_TEMP_BUCKET || 'gameno-temp', - S3_STORAGE_BUCKET: process.env.S3_STORAGE_BUCKET || 'gameno-storage', - S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE === 'false' ? false : true, + // S3-compatible storage (MinIO locally, SeaweedFS in production) + // Accepts S3_* directly, or SeaweedFS/AWS service env aliases from the host platform. + S3_ENDPOINT: + process.env.S3_ENDPOINT + || process.env.SERVICE_URL_S3 + || process.env.SERVICE_URL_S3_8333 + || 'http://localhost:9000', + S3_REGION: process.env.S3_REGION || process.env.AWS_REGION || 'us-east-1', + S3_ACCESS_KEY: + process.env.S3_ACCESS_KEY + || process.env.AWS_ACCESS_KEY_ID + || process.env.SERVICE_USER_S3 + || 'minioadmin', + S3_SECRET_KEY: + process.env.S3_SECRET_KEY + || process.env.AWS_SECRET_ACCESS_KEY + || process.env.SERVICE_PASSWORD_S3 + || 'minioadmin', + S3_TEMP_BUCKET: process.env.S3_TEMP_BUCKET || 'temp', + // Legacy single storage bucket (kept as fallback) + S3_STORAGE_BUCKET: process.env.S3_STORAGE_BUCKET || process.env.S3_CERTIFICATES_BUCKET || 'certificates', + // Dedicated private buckets + S3_CERTIFICATES_BUCKET: process.env.S3_CERTIFICATES_BUCKET || 'certificates', + S3_DOCUMENTS_BUCKET: process.env.S3_DOCUMENTS_BUCKET || 'documents', + S3_FORCE_PATH_STYLE: parseBool( + process.env.S3_FORCE_PATH_STYLE, + true // required for SeaweedFS / MinIO path-style + ), SIGNED_URL_EXPIRES_IN: parseInt(process.env.SIGNED_URL_EXPIRES_IN, 10) || 900, // 15 minutes default + // Public base URL for public buckets only (private buckets leave fileUrl empty) + S3_PUBLIC_BASE_URL: + process.env.S3_PUBLIC_BASE_URL + || process.env.SERVICE_URL_S3 + || '', + S3_CERTIFICATES_PUBLIC: parseBool(process.env.S3_CERTIFICATES_PUBLIC, false), + S3_DOCUMENTS_PUBLIC: parseBool(process.env.S3_DOCUMENTS_PUBLIC, false), + // SeaweedFS admin console (optional; not used by the S3 SDK client) + SEAWEED_ADMIN_URL: + process.env.SERVICE_URL_ADMIN_23646 + || process.env.SERVICE_URL_ADMIN + || '', + SEAWEED_USER_ADMIN: process.env.SEAWEED_USER_ADMIN || process.env.SERVICE_USER_ADMIN || '', + SEAWEED_PASSWORD_ADMIN: process.env.SEAWEED_PASSWORD_ADMIN || process.env.SERVICE_PASSWORD_ADMIN || '', // SMTP Email Settings SMTP_HOST: process.env.SMTP_HOST || 'smtp.mailtrap.io', diff --git a/constants/permissions.js b/constants/permissions.js index a79f09a..4c935d8 100644 --- a/constants/permissions.js +++ b/constants/permissions.js @@ -68,6 +68,13 @@ const PERMISSIONS = { CERTIFICATES_DELETE: 'certificates:delete', CERTIFICATES_SEARCH: 'certificates:search', + // Documents permissions (private user documents) + DOCUMENTS_CREATE: 'documents:create', + DOCUMENTS_READ: 'documents:read', + DOCUMENTS_UPDATE: 'documents:update', + DOCUMENTS_DELETE: 'documents:delete', + DOCUMENTS_SEARCH: 'documents:search', + // Files permissions FILES_UPLOAD: 'files:upload', FILES_READ: 'files:read', diff --git a/scripts/ensure-s3-buckets.js b/scripts/ensure-s3-buckets.js new file mode 100644 index 0000000..f853e31 --- /dev/null +++ b/scripts/ensure-s3-buckets.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Ensure Gameno S3 buckets exist on MinIO / SeaweedFS. + * + * Usage: + * node scripts/ensure-s3-buckets.js + * + * Uses the same env resolution as config.js (S3_* or SeaweedFS SERVICE_/AWS_ aliases). + */ +'use strict'; + +const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3'); +const config = require('../config/config'); + +const buckets = [ + config.S3_TEMP_BUCKET, + config.S3_CERTIFICATES_BUCKET, + config.S3_DOCUMENTS_BUCKET +].filter(Boolean); + +const uniqueBuckets = [...new Set(buckets)]; + +const client = new S3Client({ + endpoint: config.S3_ENDPOINT, + region: config.S3_REGION, + credentials: { + accessKeyId: config.S3_ACCESS_KEY, + secretAccessKey: config.S3_SECRET_KEY + }, + forcePathStyle: config.S3_FORCE_PATH_STYLE +}); + +const ensureBucket = async (name) => { + try { + await client.send(new HeadBucketCommand({ Bucket: name })); + console.log(`✓ exists: ${name}`); + return; + } catch { + // missing or not head-able — try create + } + + try { + await client.send(new CreateBucketCommand({ Bucket: name })); + console.log(`✓ created: ${name}`); + } catch (err) { + const code = err?.name || err?.Code || ''; + if (code === 'BucketAlreadyOwnedByYou' || code === 'BucketAlreadyExists') { + console.log(`✓ exists: ${name}`); + return; + } + throw err; + } +}; + +(async () => { + console.log(`S3 endpoint: ${config.S3_ENDPOINT}`); + console.log(`Ensuring buckets: ${uniqueBuckets.join(', ')}`); + for (const name of uniqueBuckets) { + await ensureBucket(name); + } + console.log('Done.'); +})().catch((err) => { + console.error('Failed to ensure buckets:', err.message || err); + process.exit(1); +}); diff --git a/seed.js b/seed.js index f60e979..269580a 100644 --- a/seed.js +++ b/seed.js @@ -47,6 +47,9 @@ const defaultRoles = [ PERMISSIONS.CERTIFICATES_CREATE, PERMISSIONS.CERTIFICATES_READ, PERMISSIONS.CERTIFICATES_SEARCH, + PERMISSIONS.DOCUMENTS_CREATE, + PERMISSIONS.DOCUMENTS_READ, + PERMISSIONS.DOCUMENTS_SEARCH, PERMISSIONS.FILES_UPLOAD, PERMISSIONS.FILES_READ, PERMISSIONS.LOGS_READ, @@ -69,6 +72,7 @@ const defaultRoles = [ PERMISSIONS.PAYMENTS_READ, PERMISSIONS.NOTIFICATIONS_READ, PERMISSIONS.CERTIFICATES_READ, + PERMISSIONS.DOCUMENTS_READ, PERMISSIONS.FILES_READ ], isSystem: true diff --git a/utils/errors.json b/utils/errors.json index c1997d6..f191fab 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -79,6 +79,11 @@ "en": "Certificate not found.", "fa": "مدرک یافت نشد." }, + "DOCUMENT_NOT_FOUND": { + "statusCode": 404, + "en": "Document not found.", + "fa": "سند یافت نشد." + }, "CLASS_NOT_FOUND": { "statusCode": 404, "en": "Class not found.", diff --git a/utils/s3Client.js b/utils/s3Client.js index 1f22f1b..803caf8 100644 --- a/utils/s3Client.js +++ b/utils/s3Client.js @@ -14,6 +14,33 @@ const logger = require('./logger'); let s3ClientInstance = null; +const BUCKETS = { + temp: () => config.S3_TEMP_BUCKET, + certificates: () => config.S3_CERTIFICATES_BUCKET, + documents: () => config.S3_DOCUMENTS_BUCKET, + storage: () => config.S3_STORAGE_BUCKET +}; + +const resolveBucket = (bucketNameOrKind) => { + if (!bucketNameOrKind) return config.S3_CERTIFICATES_BUCKET; + if (BUCKETS[bucketNameOrKind]) return BUCKETS[bucketNameOrKind](); + return bucketNameOrKind; +}; + +const isBucketPublic = (bucketName) => { + const name = resolveBucket(bucketName); + if (name === config.S3_CERTIFICATES_BUCKET) return config.S3_CERTIFICATES_PUBLIC; + if (name === config.S3_DOCUMENTS_BUCKET) return config.S3_DOCUMENTS_PUBLIC; + return false; +}; + +const buildPublicUrl = (bucketName, fileKey) => { + if (!isBucketPublic(bucketName) || !fileKey) return null; + const base = (config.S3_PUBLIC_BASE_URL || config.S3_ENDPOINT || '').replace(/\/$/, ''); + if (!base) return null; + return `${base}/${resolveBucket(bucketName)}/${fileKey}`; +}; + const getS3Client = () => { if (!s3ClientInstance) { s3ClientInstance = new S3Client({ @@ -44,7 +71,6 @@ const uploadToTempBucket = async (fileBuffer, filename, contentType = 'applicati return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET }; } catch (error) { logger.error(`[S3 Storage ERROR] Temp upload failed for ${filename}: ${error.message}`); - // Mock fallback for test environment when S3 is unavailable if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') { logger.warn(`[S3 Storage MOCK] Simulated temp upload for ${filename}`); return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET }; @@ -53,64 +79,79 @@ const uploadToTempBucket = async (fileBuffer, filename, contentType = 'applicati } }; -const commitTempFile = async (tempFilename, targetFilename = null) => { - const destinationKey = targetFilename || tempFilename; +/** + * Copy a temp object into a target private/public bucket, then remove the temp object. + * @param {string} tempFilename + * @param {string} destinationKey + * @param {string} [targetBucketKind='certificates'] - 'certificates' | 'documents' | bucket name + */ +const commitTempFile = async (tempFilename, destinationKey = null, targetBucketKind = 'certificates') => { + const targetKey = destinationKey || tempFilename; + const targetBucket = resolveBucket(targetBucketKind); + try { const client = getS3Client(); - // 1. Copy object from Temp Bucket to Main Storage Bucket const copyCommand = new CopyObjectCommand({ CopySource: `${config.S3_TEMP_BUCKET}/${tempFilename}`, - Bucket: config.S3_STORAGE_BUCKET, - Key: destinationKey + Bucket: targetBucket, + Key: targetKey }); await client.send(copyCommand); - // 2. Delete object from Temp Bucket const deleteCommand = new DeleteObjectCommand({ Bucket: config.S3_TEMP_BUCKET, Key: tempFilename }); await client.send(deleteCommand); - logger.info(`[S3 Storage] Committed file from temp: ${tempFilename} to permanent storage: ${destinationKey}`); - return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET }; + const fileUrl = buildPublicUrl(targetBucket, targetKey); + logger.info(`[S3 Storage] Committed ${tempFilename} → ${targetBucket}/${targetKey}`); + return { fileKey: targetKey, bucket: targetBucket, fileUrl }; } catch (error) { logger.error(`[S3 Storage ERROR] Failed to commit temp file ${tempFilename}: ${error.message}`); if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') { - logger.warn(`[S3 Storage MOCK] Simulated file commit for ${destinationKey}`); - return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET }; + logger.warn(`[S3 Storage MOCK] Simulated file commit for ${targetKey}`); + return { + fileKey: targetKey, + bucket: targetBucket, + fileUrl: buildPublicUrl(targetBucket, targetKey) + }; } throw error; } }; -const generatePresignedUrl = async (filename, bucketName = config.S3_STORAGE_BUCKET, expiresIn = config.SIGNED_URL_EXPIRES_IN) => { +const generatePresignedUrl = async ( + filename, + bucketName = config.S3_CERTIFICATES_BUCKET, + expiresIn = config.SIGNED_URL_EXPIRES_IN +) => { + const bucket = resolveBucket(bucketName); try { const client = getS3Client(); const command = new GetObjectCommand({ - Bucket: bucketName, + Bucket: bucket, Key: filename }); - const presignedUrl = await getSignedUrl(client, command, { expiresIn }); - return presignedUrl; + return await getSignedUrl(client, command, { expiresIn }); } catch (error) { logger.error(`[S3 Storage ERROR] Failed to generate presigned URL for ${filename}: ${error.message}`); - // Mock fallback URL for development without active S3 server - return `${config.S3_ENDPOINT}/${bucketName}/${filename}?token=mock_presigned_${Date.now()}`; + return `${config.S3_ENDPOINT}/${bucket}/${filename}?token=mock_presigned_${Date.now()}`; } }; -const deleteFromBucket = async (filename, bucketName = config.S3_STORAGE_BUCKET) => { +const deleteFromBucket = async (filename, bucketName = config.S3_CERTIFICATES_BUCKET) => { + const bucket = resolveBucket(bucketName); try { const client = getS3Client(); const command = new DeleteObjectCommand({ - Bucket: bucketName, + Bucket: bucket, Key: filename }); await client.send(command); - logger.info(`[S3 Storage] Deleted file ${filename} from bucket ${bucketName}`); + logger.info(`[S3 Storage] Deleted file ${filename} from bucket ${bucket}`); return true; } catch (error) { logger.error(`[S3 Storage ERROR] Failed to delete file ${filename}: ${error.message}`); @@ -138,11 +179,11 @@ const cleanupTempBucket = async (olderThanMinutes = 10) => { if (object.LastModified && new Date(object.LastModified) < cutoffTime) { await deleteFromBucket(object.Key, config.S3_TEMP_BUCKET); deletedCount++; - logger.info(`[S3 Temp Cleanup] Deleted expired temp file: ${object.Key} (Last modified: ${object.LastModified})`); + logger.info(`[S3 Temp Cleanup] Deleted expired temp file: ${object.Key}`); } } - logger.info(`[S3 Temp Cleanup] Daily temp bucket cleanup complete. Removed ${deletedCount} files.`); + logger.info(`[S3 Temp Cleanup] Removed ${deletedCount} expired temp files.`); return deletedCount; } catch (error) { logger.error(`[S3 Temp Cleanup ERROR] Cleanup job failed: ${error.message}`); @@ -156,5 +197,9 @@ module.exports = { commitTempFile, generatePresignedUrl, deleteFromBucket, - cleanupTempBucket + cleanupTempBucket, + resolveBucket, + isBucketPublic, + buildPublicUrl, + BUCKETS };