feat: private certificates/documents buckets with SeaweedFS support
Add Document CRUD, temp→bucket commit flow, SeaweedFS env aliases, and default temp bucket name to temp.
This commit is contained in:
+34
-3
@@ -15,15 +15,46 @@ DEFAULT_LANG=fa
|
|||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=*
|
||||||
UPLOAD_PATH=uploads
|
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_ENDPOINT=http://localhost:9000
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
S3_SECRET_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
|
S3_FORCE_PATH_STYLE=true
|
||||||
SIGNED_URL_EXPIRES_IN=900
|
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 Email Configuration
|
||||||
SMTP_HOST=smtp.mailtrap.io
|
SMTP_HOST=smtp.mailtrap.io
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const paymentRoutes = require('./components/payments/paymentRoutes');
|
|||||||
const roleRoutes = require('./components/roles/roleRoutes');
|
const roleRoutes = require('./components/roles/roleRoutes');
|
||||||
const notificationRoutes = require('./components/notifications/notificationRoutes');
|
const notificationRoutes = require('./components/notifications/notificationRoutes');
|
||||||
const certificateRoutes = require('./components/certificates/certificateRoutes');
|
const certificateRoutes = require('./components/certificates/certificateRoutes');
|
||||||
|
const documentRoutes = require('./components/documents/documentRoutes');
|
||||||
const fileRoutes = require('./components/files/fileRoutes');
|
const fileRoutes = require('./components/files/fileRoutes');
|
||||||
const dashboardRoutes = require('./components/dashboard/dashboardRoutes');
|
const dashboardRoutes = require('./components/dashboard/dashboardRoutes');
|
||||||
const activityLogRoutes = require('./components/activityLogs/activityLogRoutes');
|
const activityLogRoutes = require('./components/activityLogs/activityLogRoutes');
|
||||||
@@ -91,6 +92,7 @@ app.use('/api/payments', paymentRoutes);
|
|||||||
app.use('/api/roles', roleRoutes);
|
app.use('/api/roles', roleRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api/certificates', certificateRoutes);
|
app.use('/api/certificates', certificateRoutes);
|
||||||
|
app.use('/api/documents', documentRoutes);
|
||||||
app.use('/api/files', fileRoutes);
|
app.use('/api/files', fileRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/activity-logs', activityLogRoutes);
|
app.use('/api/activity-logs', activityLogRoutes);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ exports.getAll = catchAsync(async (req, res, next) => {
|
|||||||
return listResponse(res, 200, data, meta);
|
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) => {
|
exports.update = catchAsync(async (req, res, next) => {
|
||||||
const certificate = await certificateService.updateCertificate(req.params.id, req.body);
|
const certificate = await certificateService.updateCertificate(req.params.id, req.body);
|
||||||
return successResponse(res, 200, 'Certificate updated successfully', certificate);
|
return successResponse(res, 200, 'Certificate updated successfully', certificate);
|
||||||
|
|||||||
@@ -23,15 +23,37 @@ const certificateSchema = new mongoose.Schema({
|
|||||||
type: String,
|
type: String,
|
||||||
trim: true
|
trim: true
|
||||||
},
|
},
|
||||||
|
/** Permanent object key inside the certificates bucket */
|
||||||
fileKey: {
|
fileKey: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
trim: true
|
trim: true
|
||||||
},
|
},
|
||||||
|
/** Original uploaded filename */
|
||||||
|
fileName: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
/** Legacy alias kept in sync with fileName */
|
||||||
originalName: {
|
originalName: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true
|
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: {
|
issuedAt: {
|
||||||
type: Date,
|
type: Date,
|
||||||
default: Date.now
|
default: Date.now
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ router.post('/user/upload', perm.requires(PERMISSIONS.FILES_UPLOAD), validateUpl
|
|||||||
// Admin Scope
|
// Admin Scope
|
||||||
router.post('/admin/create', perm.requires(PERMISSIONS.CERTIFICATES_CREATE), validateCreateCertificate, certificateController.create);
|
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/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/search', perm.requires(PERMISSIONS.CERTIFICATES_SEARCH), certificateController.search);
|
||||||
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getOne);
|
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);
|
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CERTIFICATES_UPDATE), validateUpdateCertificate, certificateController.update);
|
||||||
|
|||||||
@@ -4,26 +4,44 @@ const path = require('path');
|
|||||||
const Certificate = require('./certificateModel');
|
const Certificate = require('./certificateModel');
|
||||||
const User = require('../users/userModel');
|
const User = require('../users/userModel');
|
||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
|
const config = require('../../config/config');
|
||||||
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
|
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
|
||||||
const eventEmitter = require('../../events/eventEmitter');
|
const eventEmitter = require('../../events/eventEmitter');
|
||||||
const EVENT_NAMES = require('../../constants/eventNames');
|
const EVENT_NAMES = require('../../constants/eventNames');
|
||||||
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
|
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 createCertificate = async (data) => {
|
||||||
const user = await User.findById(data.user);
|
const user = await User.findById(data.user);
|
||||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
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 safeName = path.basename(data.tempFileName);
|
||||||
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
|
const targetKey = `certificates/cert-${Date.now()}-${safeName}`;
|
||||||
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
|
const { fileKey, bucket, fileUrl } = await commitTempFile(data.tempFileName, targetKey, BUCKET);
|
||||||
|
const fileName = data.originalName || data.fileName || safeName;
|
||||||
|
|
||||||
const certificate = await Certificate.create({
|
const certificate = await Certificate.create({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
course: data.course || null,
|
course: data.course || null,
|
||||||
title: data.title,
|
title: data.title || fileName,
|
||||||
issuer: data.issuer || '',
|
issuer: data.issuer || '',
|
||||||
fileKey,
|
fileKey,
|
||||||
originalName: data.originalName || path.basename(data.tempFileName),
|
fileName,
|
||||||
|
originalName: fileName,
|
||||||
|
fileUrl: fileUrl || null,
|
||||||
|
bucket,
|
||||||
|
mimeType: data.mimeType || '',
|
||||||
isOfficial: data.isOfficial || false
|
isOfficial: data.isOfficial || false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,27 +50,28 @@ const createCertificate = async (data) => {
|
|||||||
|
|
||||||
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
|
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
|
||||||
|
|
||||||
const resultObj = certificate.toObject();
|
return withAccessUrl(certificate);
|
||||||
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
|
|
||||||
return resultObj;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCertificateById = async (id) => {
|
const getCertificateById = async (id) => {
|
||||||
const certificate = await Certificate.findById(id)
|
const certificate = await Certificate.findById(id)
|
||||||
.populate('user', 'name username nationalIdCode')
|
.populate('user', 'name username nationalIdCode')
|
||||||
.populate('course', 'title type');
|
.populate('course', 'title type');
|
||||||
if (!certificate) {
|
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
|
||||||
throw new AppError('CERTIFICATE_NOT_FOUND');
|
return withAccessUrl(certificate);
|
||||||
}
|
};
|
||||||
|
|
||||||
const resultObj = certificate.toObject();
|
const getCertificatesByUser = async (userId) => {
|
||||||
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
|
const certificates = await Certificate.find({ user: userId })
|
||||||
return resultObj;
|
.populate('course', 'title type')
|
||||||
|
.sort({ createdAt: -1 });
|
||||||
|
return Promise.all(certificates.map(withAccessUrl));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllCertificates = async (queryParams) => {
|
const getAllCertificates = async (queryParams) => {
|
||||||
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
|
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
|
||||||
const filter = buildFilterQuery(queryParams, ['title', 'issuer']);
|
const filter = buildFilterQuery(queryParams, ['title', 'issuer']);
|
||||||
|
if (queryParams.userId) filter.user = queryParams.userId;
|
||||||
|
|
||||||
const [certificates, totalCount] = await Promise.all([
|
const [certificates, totalCount] = await Promise.all([
|
||||||
Certificate.find(filter)
|
Certificate.find(filter)
|
||||||
@@ -64,30 +83,28 @@ const getAllCertificates = async (queryParams) => {
|
|||||||
Certificate.countDocuments(filter)
|
Certificate.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const listWithUrls = await Promise.all(
|
const data = await Promise.all(certificates.map(withAccessUrl));
|
||||||
certificates.map(async (cert) => {
|
return { data, meta: calculateMeta(totalCount, page, limit) };
|
||||||
const item = cert.toObject();
|
|
||||||
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta = calculateMeta(totalCount, page, limit);
|
|
||||||
return { data: listWithUrls, meta };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCertificate = async (id, updateData) => {
|
const updateCertificate = async (id, updateData) => {
|
||||||
const certificate = await Certificate.findById(id);
|
const certificate = await Certificate.findById(id);
|
||||||
if (!certificate) {
|
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
|
||||||
throw new AppError('CERTIFICATE_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateData.tempFileName) {
|
if (updateData.tempFileName) {
|
||||||
const targetKey = `certificates/cert-${Date.now()}-${path.basename(updateData.tempFileName)}`;
|
const safeName = path.basename(updateData.tempFileName);
|
||||||
const { fileKey } = await commitTempFile(updateData.tempFileName, targetKey);
|
const targetKey = `certificates/cert-${Date.now()}-${safeName}`;
|
||||||
// Delete old file from storage bucket
|
const { fileKey, bucket, fileUrl } = await commitTempFile(updateData.tempFileName, targetKey, BUCKET);
|
||||||
await deleteFromBucket(certificate.fileKey);
|
await deleteFromBucket(certificate.fileKey, certificate.bucket || config.S3_CERTIFICATES_BUCKET);
|
||||||
certificate.fileKey = fileKey;
|
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;
|
if (updateData.title) certificate.title = updateData.title;
|
||||||
@@ -95,53 +112,28 @@ const updateCertificate = async (id, updateData) => {
|
|||||||
if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial;
|
if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial;
|
||||||
|
|
||||||
await certificate.save();
|
await certificate.save();
|
||||||
|
return withAccessUrl(certificate);
|
||||||
const resultObj = certificate.toObject();
|
|
||||||
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
|
|
||||||
return resultObj;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCertificate = async (id) => {
|
const deleteCertificate = async (id) => {
|
||||||
const certificate = await Certificate.findById(id);
|
const certificate = await Certificate.findById(id);
|
||||||
if (!certificate) {
|
if (!certificate) throw new AppError('CERTIFICATE_NOT_FOUND');
|
||||||
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 User.findByIdAndUpdate(certificate.user, { $pull: { certificates: certificate._id } });
|
||||||
await Certificate.findByIdAndDelete(id);
|
await Certificate.findByIdAndDelete(id);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchCertificates = async (queryParams) => {
|
const searchCertificates = async (queryParams) => getAllCertificates(queryParams);
|
||||||
return getAllCertificates(queryParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadUserCertificate = async (userId, data) => {
|
const uploadUserCertificate = async (userId, data) => {
|
||||||
const user = await User.findById(userId);
|
return createCertificate({
|
||||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
...data,
|
||||||
|
|
||||||
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
|
|
||||||
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
|
|
||||||
|
|
||||||
const certificate = await Certificate.create({
|
|
||||||
user: userId,
|
user: userId,
|
||||||
course: data.course || null,
|
|
||||||
title: data.title,
|
|
||||||
issuer: data.issuer || 'Self-Uploaded',
|
issuer: data.issuer || 'Self-Uploaded',
|
||||||
fileKey,
|
|
||||||
originalName: data.originalName || path.basename(data.tempFileName),
|
|
||||||
isOfficial: false
|
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) => {
|
const getMyCertificates = async (userId, queryParams) => {
|
||||||
@@ -157,21 +149,14 @@ const getMyCertificates = async (userId, queryParams) => {
|
|||||||
Certificate.countDocuments(filter)
|
Certificate.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const listWithUrls = await Promise.all(
|
const data = await Promise.all(certificates.map(withAccessUrl));
|
||||||
certificates.map(async (cert) => {
|
return { data, meta: calculateMeta(totalCount, page, limit) };
|
||||||
const item = cert.toObject();
|
|
||||||
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const meta = calculateMeta(totalCount, page, limit);
|
|
||||||
return { data: listWithUrls, meta };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createCertificate,
|
createCertificate,
|
||||||
getCertificateById,
|
getCertificateById,
|
||||||
|
getCertificatesByUser,
|
||||||
getAllCertificates,
|
getAllCertificates,
|
||||||
updateCertificate,
|
updateCertificate,
|
||||||
deleteCertificate,
|
deleteCertificate,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// /components/files/fileService.js
|
// /components/files/fileService.js
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const config = require('../../config/config');
|
||||||
const {
|
const {
|
||||||
uploadToTempBucket,
|
uploadToTempBucket,
|
||||||
commitTempFile,
|
commitTempFile,
|
||||||
@@ -22,7 +23,8 @@ const uploadTempFile = async (fileBuffer, originalName, mimeType) => {
|
|||||||
return {
|
return {
|
||||||
tempFileName: result.tempFileName,
|
tempFileName: result.tempFileName,
|
||||||
originalName,
|
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');
|
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 {
|
return {
|
||||||
filename,
|
filename,
|
||||||
|
bucket: targetBucket,
|
||||||
presignedUrl: signedUrl,
|
presignedUrl: signedUrl,
|
||||||
expiresInSeconds: 900
|
signedUrl,
|
||||||
|
expiresInSeconds: config.SIGNED_URL_EXPIRES_IN
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitFile = async (tempFileName, targetFileName = null) => {
|
const commitFile = async (tempFileName, targetFileName = null, targetBucket = 'certificates') => {
|
||||||
return commitTempFile(tempFileName, targetFileName);
|
return commitTempFile(tempFileName, targetFileName, targetBucket);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFile = async (filename, bucket = null) => {
|
const deleteFile = async (filename, bucket = null) => {
|
||||||
return deleteFromBucket(filename, bucket);
|
return deleteFromBucket(filename, bucket || config.S3_TEMP_BUCKET);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ const userSchema = new mongoose.Schema({
|
|||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Certificate'
|
ref: 'Certificate'
|
||||||
}],
|
}],
|
||||||
|
documents: [{
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Document'
|
||||||
|
}],
|
||||||
refreshTokens: [refreshTokenSchema],
|
refreshTokens: [refreshTokenSchema],
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
+42
-8
@@ -29,15 +29,49 @@ const config = {
|
|||||||
DEFAULT_LANG: process.env.DEFAULT_LANG || 'en',
|
DEFAULT_LANG: process.env.DEFAULT_LANG || 'en',
|
||||||
UPLOAD_PATH: process.env.UPLOAD_PATH || 'uploads',
|
UPLOAD_PATH: process.env.UPLOAD_PATH || 'uploads',
|
||||||
|
|
||||||
// S3 / MinIO Object Storage Settings
|
// S3-compatible storage (MinIO locally, SeaweedFS in production)
|
||||||
S3_ENDPOINT: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
// Accepts S3_* directly, or SeaweedFS/AWS service env aliases from the host platform.
|
||||||
S3_REGION: process.env.S3_REGION || 'us-east-1',
|
S3_ENDPOINT:
|
||||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY || 'minioadmin',
|
process.env.S3_ENDPOINT
|
||||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY || 'minioadmin',
|
|| process.env.SERVICE_URL_S3
|
||||||
S3_TEMP_BUCKET: process.env.S3_TEMP_BUCKET || 'gameno-temp',
|
|| process.env.SERVICE_URL_S3_8333
|
||||||
S3_STORAGE_BUCKET: process.env.S3_STORAGE_BUCKET || 'gameno-storage',
|
|| 'http://localhost:9000',
|
||||||
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE === 'false' ? false : true,
|
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
|
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 Email Settings
|
||||||
SMTP_HOST: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
SMTP_HOST: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ const PERMISSIONS = {
|
|||||||
CERTIFICATES_DELETE: 'certificates:delete',
|
CERTIFICATES_DELETE: 'certificates:delete',
|
||||||
CERTIFICATES_SEARCH: 'certificates:search',
|
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 permissions
|
||||||
FILES_UPLOAD: 'files:upload',
|
FILES_UPLOAD: 'files:upload',
|
||||||
FILES_READ: 'files:read',
|
FILES_READ: 'files:read',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -47,6 +47,9 @@ const defaultRoles = [
|
|||||||
PERMISSIONS.CERTIFICATES_CREATE,
|
PERMISSIONS.CERTIFICATES_CREATE,
|
||||||
PERMISSIONS.CERTIFICATES_READ,
|
PERMISSIONS.CERTIFICATES_READ,
|
||||||
PERMISSIONS.CERTIFICATES_SEARCH,
|
PERMISSIONS.CERTIFICATES_SEARCH,
|
||||||
|
PERMISSIONS.DOCUMENTS_CREATE,
|
||||||
|
PERMISSIONS.DOCUMENTS_READ,
|
||||||
|
PERMISSIONS.DOCUMENTS_SEARCH,
|
||||||
PERMISSIONS.FILES_UPLOAD,
|
PERMISSIONS.FILES_UPLOAD,
|
||||||
PERMISSIONS.FILES_READ,
|
PERMISSIONS.FILES_READ,
|
||||||
PERMISSIONS.LOGS_READ,
|
PERMISSIONS.LOGS_READ,
|
||||||
@@ -69,6 +72,7 @@ const defaultRoles = [
|
|||||||
PERMISSIONS.PAYMENTS_READ,
|
PERMISSIONS.PAYMENTS_READ,
|
||||||
PERMISSIONS.NOTIFICATIONS_READ,
|
PERMISSIONS.NOTIFICATIONS_READ,
|
||||||
PERMISSIONS.CERTIFICATES_READ,
|
PERMISSIONS.CERTIFICATES_READ,
|
||||||
|
PERMISSIONS.DOCUMENTS_READ,
|
||||||
PERMISSIONS.FILES_READ
|
PERMISSIONS.FILES_READ
|
||||||
],
|
],
|
||||||
isSystem: true
|
isSystem: true
|
||||||
|
|||||||
@@ -79,6 +79,11 @@
|
|||||||
"en": "Certificate not found.",
|
"en": "Certificate not found.",
|
||||||
"fa": "مدرک یافت نشد."
|
"fa": "مدرک یافت نشد."
|
||||||
},
|
},
|
||||||
|
"DOCUMENT_NOT_FOUND": {
|
||||||
|
"statusCode": 404,
|
||||||
|
"en": "Document not found.",
|
||||||
|
"fa": "سند یافت نشد."
|
||||||
|
},
|
||||||
"CLASS_NOT_FOUND": {
|
"CLASS_NOT_FOUND": {
|
||||||
"statusCode": 404,
|
"statusCode": 404,
|
||||||
"en": "Class not found.",
|
"en": "Class not found.",
|
||||||
|
|||||||
+68
-23
@@ -14,6 +14,33 @@ const logger = require('./logger');
|
|||||||
|
|
||||||
let s3ClientInstance = null;
|
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 = () => {
|
const getS3Client = () => {
|
||||||
if (!s3ClientInstance) {
|
if (!s3ClientInstance) {
|
||||||
s3ClientInstance = new S3Client({
|
s3ClientInstance = new S3Client({
|
||||||
@@ -44,7 +71,6 @@ const uploadToTempBucket = async (fileBuffer, filename, contentType = 'applicati
|
|||||||
return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET };
|
return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[S3 Storage ERROR] Temp upload failed for ${filename}: ${error.message}`);
|
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') {
|
if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') {
|
||||||
logger.warn(`[S3 Storage MOCK] Simulated temp upload for ${filename}`);
|
logger.warn(`[S3 Storage MOCK] Simulated temp upload for ${filename}`);
|
||||||
return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET };
|
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 {
|
try {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
// 1. Copy object from Temp Bucket to Main Storage Bucket
|
|
||||||
const copyCommand = new CopyObjectCommand({
|
const copyCommand = new CopyObjectCommand({
|
||||||
CopySource: `${config.S3_TEMP_BUCKET}/${tempFilename}`,
|
CopySource: `${config.S3_TEMP_BUCKET}/${tempFilename}`,
|
||||||
Bucket: config.S3_STORAGE_BUCKET,
|
Bucket: targetBucket,
|
||||||
Key: destinationKey
|
Key: targetKey
|
||||||
});
|
});
|
||||||
await client.send(copyCommand);
|
await client.send(copyCommand);
|
||||||
|
|
||||||
// 2. Delete object from Temp Bucket
|
|
||||||
const deleteCommand = new DeleteObjectCommand({
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
Bucket: config.S3_TEMP_BUCKET,
|
Bucket: config.S3_TEMP_BUCKET,
|
||||||
Key: tempFilename
|
Key: tempFilename
|
||||||
});
|
});
|
||||||
await client.send(deleteCommand);
|
await client.send(deleteCommand);
|
||||||
|
|
||||||
logger.info(`[S3 Storage] Committed file from temp: ${tempFilename} to permanent storage: ${destinationKey}`);
|
const fileUrl = buildPublicUrl(targetBucket, targetKey);
|
||||||
return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET };
|
logger.info(`[S3 Storage] Committed ${tempFilename} → ${targetBucket}/${targetKey}`);
|
||||||
|
return { fileKey: targetKey, bucket: targetBucket, fileUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[S3 Storage ERROR] Failed to commit temp file ${tempFilename}: ${error.message}`);
|
logger.error(`[S3 Storage ERROR] Failed to commit temp file ${tempFilename}: ${error.message}`);
|
||||||
if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') {
|
if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') {
|
||||||
logger.warn(`[S3 Storage MOCK] Simulated file commit for ${destinationKey}`);
|
logger.warn(`[S3 Storage MOCK] Simulated file commit for ${targetKey}`);
|
||||||
return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET };
|
return {
|
||||||
|
fileKey: targetKey,
|
||||||
|
bucket: targetBucket,
|
||||||
|
fileUrl: buildPublicUrl(targetBucket, targetKey)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
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 {
|
try {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: bucketName,
|
Bucket: bucket,
|
||||||
Key: filename
|
Key: filename
|
||||||
});
|
});
|
||||||
|
|
||||||
const presignedUrl = await getSignedUrl(client, command, { expiresIn });
|
return await getSignedUrl(client, command, { expiresIn });
|
||||||
return presignedUrl;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[S3 Storage ERROR] Failed to generate presigned URL for ${filename}: ${error.message}`);
|
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}/${bucket}/${filename}?token=mock_presigned_${Date.now()}`;
|
||||||
return `${config.S3_ENDPOINT}/${bucketName}/${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 {
|
try {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
const command = new DeleteObjectCommand({
|
const command = new DeleteObjectCommand({
|
||||||
Bucket: bucketName,
|
Bucket: bucket,
|
||||||
Key: filename
|
Key: filename
|
||||||
});
|
});
|
||||||
await client.send(command);
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[S3 Storage ERROR] Failed to delete file ${filename}: ${error.message}`);
|
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) {
|
if (object.LastModified && new Date(object.LastModified) < cutoffTime) {
|
||||||
await deleteFromBucket(object.Key, config.S3_TEMP_BUCKET);
|
await deleteFromBucket(object.Key, config.S3_TEMP_BUCKET);
|
||||||
deletedCount++;
|
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;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[S3 Temp Cleanup ERROR] Cleanup job failed: ${error.message}`);
|
logger.error(`[S3 Temp Cleanup ERROR] Cleanup job failed: ${error.message}`);
|
||||||
@@ -156,5 +197,9 @@ module.exports = {
|
|||||||
commitTempFile,
|
commitTempFile,
|
||||||
generatePresignedUrl,
|
generatePresignedUrl,
|
||||||
deleteFromBucket,
|
deleteFromBucket,
|
||||||
cleanupTempBucket
|
cleanupTempBucket,
|
||||||
|
resolveBucket,
|
||||||
|
isBucketPublic,
|
||||||
|
buildPublicUrl,
|
||||||
|
BUCKETS
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user