Initial commit: MinIO WebUI - Complete implementation
- Backend: Express.js API with MinIO CLI integration - Frontend: React with Material-UI for non-technical users - Features: Bucket management, user creation, storage monitoring - Security: JWT auth, IP filtering, encrypted passwords - Docker support for easy deployment - Automated weekly storage reports - Setup and deployment scripts included
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const authService = require('../../services/auth.service');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation middleware
|
||||
const validateLogin = [
|
||||
body('password')
|
||||
.notEmpty().withMessage('Password is required')
|
||||
.isLength({ min: 1 }).withMessage('Password cannot be empty'),
|
||||
];
|
||||
|
||||
// Handle validation errors
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', validateLogin, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const { password } = req.body;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Verify password
|
||||
const isValid = await authService.verifyPassword(password);
|
||||
|
||||
if (!isValid) {
|
||||
logAudit('LOGIN_FAILED', {
|
||||
ip: clientIp,
|
||||
status: 'failed',
|
||||
details: { reason: 'Invalid password' },
|
||||
});
|
||||
|
||||
throw new AppError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = authService.createSession(clientIp);
|
||||
|
||||
// Set cookie
|
||||
res.cookie('token', session.token, authService.getCookieOptions());
|
||||
|
||||
// Log successful login
|
||||
logAudit('LOGIN_SUCCESS', {
|
||||
ip: clientIp,
|
||||
userId: 'admin',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
logger.info(`Successful login from IP: ${clientIp}`);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token: session.token,
|
||||
expiresIn: session.expiresIn,
|
||||
role: session.role,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post('/logout', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Clear cookie
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
logAudit('LOGOUT', {
|
||||
ip: clientIp,
|
||||
userId: req.user?.role || 'unknown',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Logout successful',
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/auth/status
|
||||
router.get('/status', (req, res) => {
|
||||
const token = req.cookies.token || req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = authService.verifyToken(token);
|
||||
res.json({
|
||||
authenticated: true,
|
||||
role: decoded.role,
|
||||
loginTime: decoded.loginTime,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
authenticated: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh
|
||||
router.post('/refresh', (req, res, next) => {
|
||||
try {
|
||||
const token = req.cookies.token || req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new AppError('No token provided', 401);
|
||||
}
|
||||
|
||||
const decoded = authService.verifyToken(token);
|
||||
|
||||
// Create new token with same data
|
||||
const newSession = authService.createSession(decoded.ip || req.ip);
|
||||
|
||||
// Set new cookie
|
||||
res.cookie('token', newSession.token, authService.getCookieOptions());
|
||||
|
||||
res.json({
|
||||
message: 'Token refreshed',
|
||||
token: newSession.token,
|
||||
expiresIn: newSession.expiresIn,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,197 @@
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const MinIOService = require('../../services/minio.service');
|
||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const minioService = new MinIOService();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Validation rules
|
||||
const bucketValidation = {
|
||||
name: body('bucketName')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Bucket name is required')
|
||||
.matches(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/).withMessage('Invalid bucket name format')
|
||||
.isLength({ min: 3, max: 63 }).withMessage('Bucket name must be 3-63 characters'),
|
||||
|
||||
nameParam: param('name')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Bucket name is required')
|
||||
.matches(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/).withMessage('Invalid bucket name format'),
|
||||
|
||||
withUser: [
|
||||
body('username')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Username is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid username format')
|
||||
.isLength({ min: 3, max: 32 }).withMessage('Username must be 3-32 characters'),
|
||||
body('password')
|
||||
.notEmpty().withMessage('Password is required')
|
||||
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).withMessage('Password must contain uppercase, lowercase, and number'),
|
||||
],
|
||||
};
|
||||
|
||||
// Handle validation errors
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// GET /api/buckets
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const buckets = await minioService.listBuckets();
|
||||
|
||||
res.json({
|
||||
buckets,
|
||||
count: buckets.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/buckets/sizes
|
||||
router.get('/sizes', async (req, res, next) => {
|
||||
try {
|
||||
const bucketSizes = await minioService.getBucketSizes();
|
||||
const totalSize = bucketSizes.reduce((sum, bucket) => sum + bucket.size, 0);
|
||||
|
||||
res.json({
|
||||
buckets: bucketSizes,
|
||||
totalSize,
|
||||
totalSizeFormatted: minioService.formatBytes(totalSize),
|
||||
count: bucketSizes.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/buckets
|
||||
router.post('/', bucketValidation.name, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const { bucketName } = req.body;
|
||||
|
||||
const result = await minioService.createBucket(bucketName);
|
||||
|
||||
logAudit('BUCKET_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: bucketName,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
logAudit('BUCKET_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.body.bucketName,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/buckets/with-user
|
||||
router.post('/with-user',
|
||||
[bucketValidation.name, ...bucketValidation.withUser],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { bucketName, username, password } = req.body;
|
||||
|
||||
const result = await minioService.createBucketWithUser(bucketName, username, password);
|
||||
|
||||
logAudit('BUCKET_USER_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: `${bucketName}/${username}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
logAudit('BUCKET_USER_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: `${req.body.bucketName}/${req.body.username}`,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/buckets/:name
|
||||
router.get('/:name', bucketValidation.nameParam, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const bucketInfo = await minioService.getBucketSize(name);
|
||||
|
||||
res.json(bucketInfo);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/buckets/:name
|
||||
router.delete('/:name', bucketValidation.nameParam, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const result = await minioService.deleteBucket(name);
|
||||
|
||||
logAudit('BUCKET_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: name,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logAudit('BUCKET_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.params.name,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/buckets/:name/policy
|
||||
router.get('/:name/policy', bucketValidation.nameParam, handleValidationErrors, async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
// For now, return a placeholder
|
||||
// In a real implementation, you'd fetch the actual bucket policy
|
||||
res.json({
|
||||
bucketName: name,
|
||||
policy: null,
|
||||
message: 'Bucket policy retrieval not yet implemented',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,252 @@
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const MinIOService = require('../../services/minio.service');
|
||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const minioService = new MinIOService();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Validation rules
|
||||
const policyValidation = {
|
||||
name: body('policyName')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Policy name is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid policy name format')
|
||||
.isLength({ min: 1, max: 128 }).withMessage('Policy name must be 1-128 characters'),
|
||||
|
||||
document: body('policyDocument')
|
||||
.notEmpty().withMessage('Policy document is required')
|
||||
.custom((value) => {
|
||||
try {
|
||||
const policy = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (!policy.Version || !policy.Statement) {
|
||||
throw new Error('Policy must have Version and Statement');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid policy document format');
|
||||
}
|
||||
}),
|
||||
|
||||
nameParam: param('name')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Policy name is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid policy name format'),
|
||||
|
||||
attachUser: body('username')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Username is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid username format'),
|
||||
};
|
||||
|
||||
// Handle validation errors
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Policy templates
|
||||
const policyTemplates = {
|
||||
bucketFullAccess: (bucketName) => ({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Effect: 'Allow',
|
||||
Action: ['s3:*'],
|
||||
Resource: [
|
||||
`arn:aws:s3:::${bucketName}`,
|
||||
`arn:aws:s3:::${bucketName}/*`
|
||||
]
|
||||
}]
|
||||
}),
|
||||
|
||||
bucketReadOnly: (bucketName) => ({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Effect: 'Allow',
|
||||
Action: [
|
||||
's3:GetObject',
|
||||
's3:ListBucket'
|
||||
],
|
||||
Resource: [
|
||||
`arn:aws:s3:::${bucketName}`,
|
||||
`arn:aws:s3:::${bucketName}/*`
|
||||
]
|
||||
}]
|
||||
}),
|
||||
|
||||
bucketWriteOnly: (bucketName) => ({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Effect: 'Allow',
|
||||
Action: [
|
||||
's3:PutObject',
|
||||
's3:DeleteObject'
|
||||
],
|
||||
Resource: [`arn:aws:s3:::${bucketName}/*`]
|
||||
}]
|
||||
}),
|
||||
};
|
||||
|
||||
// GET /api/policies
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const policies = await minioService.listPolicies();
|
||||
|
||||
res.json({
|
||||
policies,
|
||||
count: policies.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/policies/templates
|
||||
router.get('/templates', (req, res) => {
|
||||
res.json({
|
||||
templates: Object.keys(policyTemplates),
|
||||
descriptions: {
|
||||
bucketFullAccess: 'Full read/write access to a specific bucket',
|
||||
bucketReadOnly: 'Read-only access to a specific bucket',
|
||||
bucketWriteOnly: 'Write-only access to a specific bucket',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/policies/templates/:templateName
|
||||
router.post('/templates/:templateName',
|
||||
[
|
||||
param('templateName').isIn(Object.keys(policyTemplates)).withMessage('Invalid template name'),
|
||||
body('bucketName').trim().notEmpty().withMessage('Bucket name is required'),
|
||||
body('policyName').trim().notEmpty().withMessage('Policy name is required'),
|
||||
],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { templateName } = req.params;
|
||||
const { bucketName, policyName } = req.body;
|
||||
|
||||
const policyDocument = policyTemplates[templateName](bucketName);
|
||||
const result = await minioService.createPolicy(policyName, policyDocument);
|
||||
|
||||
logAudit('POLICY_CREATE_FROM_TEMPLATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: policyName,
|
||||
status: 'success',
|
||||
details: { template: templateName, bucketName },
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...result,
|
||||
template: templateName,
|
||||
bucketName,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/policies
|
||||
router.post('/',
|
||||
[policyValidation.name, policyValidation.document],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { policyName, policyDocument } = req.body;
|
||||
|
||||
const result = await minioService.createPolicy(policyName, policyDocument);
|
||||
|
||||
logAudit('POLICY_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: policyName,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
logAudit('POLICY_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.body.policyName,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/policies/:name
|
||||
router.delete('/:name',
|
||||
policyValidation.nameParam,
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const result = await minioService.deletePolicy(name);
|
||||
|
||||
logAudit('POLICY_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: name,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logAudit('POLICY_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.params.name,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/policies/:name/attach
|
||||
router.post('/:name/attach',
|
||||
[policyValidation.nameParam, policyValidation.attachUser],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { username } = req.body;
|
||||
|
||||
const result = await minioService.attachPolicy(name, username);
|
||||
|
||||
logAudit('POLICY_ATTACH', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: `${name}/${username}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logAudit('POLICY_ATTACH', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: `${req.params.name}/${req.body.username}`,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,164 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const reportService = require('../../services/report.service');
|
||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Handle validation errors
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// GET /api/reports/storage
|
||||
router.get('/storage', async (req, res, next) => {
|
||||
try {
|
||||
const report = await reportService.generateReport();
|
||||
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reports/generate
|
||||
router.post('/generate',
|
||||
body('recipients')
|
||||
.optional()
|
||||
.isArray().withMessage('Recipients must be an array')
|
||||
.custom((value) => {
|
||||
if (value && value.length > 0) {
|
||||
return value.every(email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
||||
}
|
||||
return true;
|
||||
}).withMessage('Invalid email address in recipients'),
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { recipients } = req.body;
|
||||
|
||||
const report = await reportService.generateAndSendReport(recipients);
|
||||
|
||||
logAudit('REPORT_GENERATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: 'storage_report',
|
||||
status: 'success',
|
||||
details: {
|
||||
recipients: recipients || 'default',
|
||||
date: report.date,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Report generated and sent successfully',
|
||||
report: {
|
||||
date: report.date,
|
||||
summary: report.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logAudit('REPORT_GENERATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: 'storage_report',
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reports/schedule
|
||||
router.get('/schedule', (req, res) => {
|
||||
const scheduleInfo = reportService.getScheduleInfo();
|
||||
|
||||
res.json(scheduleInfo);
|
||||
});
|
||||
|
||||
// PUT /api/reports/schedule
|
||||
router.put('/schedule',
|
||||
body('action').isIn(['start', 'stop']).withMessage('Action must be start or stop'),
|
||||
handleValidationErrors,
|
||||
(req, res, next) => {
|
||||
try {
|
||||
const { action } = req.body;
|
||||
|
||||
if (action === 'start') {
|
||||
reportService.startSchedule();
|
||||
} else {
|
||||
reportService.stopSchedule();
|
||||
}
|
||||
|
||||
const scheduleInfo = reportService.getScheduleInfo();
|
||||
|
||||
logAudit('REPORT_SCHEDULE_CHANGE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: 'report_schedule',
|
||||
status: 'success',
|
||||
details: { action },
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Schedule ${action}ed successfully`,
|
||||
schedule: scheduleInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reports/storage/export
|
||||
router.get('/storage/export', async (req, res, next) => {
|
||||
try {
|
||||
const { format = 'csv' } = req.query;
|
||||
|
||||
const report = await reportService.generateReport();
|
||||
|
||||
if (format === 'csv') {
|
||||
const csv = [
|
||||
'Bucket Name,Size (Bytes),Size (Formatted),Objects,Last Modified',
|
||||
...report.buckets.map(b =>
|
||||
`"${b.name}",${b.size},"${b.sizeFormatted}",${b.objects},"${b.lastModified}"`
|
||||
),
|
||||
'',
|
||||
`Total,${report.summary.totalSize},"${report.summary.totalSizeFormatted}",,`,
|
||||
].join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="minio-storage-report-${report.date}.csv"`);
|
||||
res.send(csv);
|
||||
} else if (format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="minio-storage-report-${report.date}.json"`);
|
||||
res.json(report);
|
||||
} else {
|
||||
throw new AppError('Invalid export format. Use csv or json.', 400);
|
||||
}
|
||||
|
||||
logAudit('REPORT_EXPORT', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: 'storage_report',
|
||||
status: 'success',
|
||||
details: { format },
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,193 @@
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const MinIOService = require('../../services/minio.service');
|
||||
const { authMiddleware } = require('../../middleware/auth.middleware');
|
||||
const { logger, logAudit } = require('../../utils/logger');
|
||||
const { AppError } = require('../../middleware/errorHandler.middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const minioService = new MinIOService();
|
||||
|
||||
// Apply auth middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Validation rules
|
||||
const userValidation = {
|
||||
username: body('username')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Username is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid username format')
|
||||
.isLength({ min: 3, max: 32 }).withMessage('Username must be 3-32 characters'),
|
||||
|
||||
password: body('password')
|
||||
.notEmpty().withMessage('Password is required')
|
||||
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).withMessage('Password must contain uppercase, lowercase, and number'),
|
||||
|
||||
usernameParam: param('username')
|
||||
.trim()
|
||||
.notEmpty().withMessage('Username is required')
|
||||
.matches(/^[a-zA-Z0-9_-]+$/).withMessage('Invalid username format'),
|
||||
};
|
||||
|
||||
// Handle validation errors
|
||||
const handleValidationErrors = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation Error',
|
||||
errors: errors.array(),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// GET /api/users
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const users = await minioService.listUsers();
|
||||
|
||||
res.json({
|
||||
users,
|
||||
count: users.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/users
|
||||
router.post('/',
|
||||
[userValidation.username, userValidation.password],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Create user without bucket (admin can assign policies later)
|
||||
await minioService.executeCommand(
|
||||
`mc admin user add ${minioService.alias} ${username} ${password}`
|
||||
);
|
||||
|
||||
logAudit('USER_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: username,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User created successfully',
|
||||
username,
|
||||
});
|
||||
} catch (error) {
|
||||
logAudit('USER_CREATE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.body.username,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/users/:username
|
||||
router.delete('/:username',
|
||||
userValidation.usernameParam,
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
const result = await minioService.removeUser(username);
|
||||
|
||||
logAudit('USER_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: username,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logAudit('USER_DELETE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.params.username,
|
||||
status: 'failed',
|
||||
details: { error: error.message },
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/users/:username/status
|
||||
router.put('/:username/status',
|
||||
[
|
||||
userValidation.usernameParam,
|
||||
body('status').isIn(['enabled', 'disabled']).withMessage('Status must be enabled or disabled'),
|
||||
],
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
let result;
|
||||
if (status === 'enabled') {
|
||||
result = await minioService.enableUser(username);
|
||||
} else {
|
||||
result = await minioService.disableUser(username);
|
||||
}
|
||||
|
||||
logAudit('USER_STATUS_CHANGE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: username,
|
||||
status: 'success',
|
||||
details: { newStatus: status },
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logAudit('USER_STATUS_CHANGE', {
|
||||
userId: req.user.role,
|
||||
ip: req.ip,
|
||||
resource: req.params.username,
|
||||
status: 'failed',
|
||||
details: {
|
||||
error: error.message,
|
||||
requestedStatus: req.body.status,
|
||||
},
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/users/:username
|
||||
router.get('/:username',
|
||||
userValidation.usernameParam,
|
||||
handleValidationErrors,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Get user info from list
|
||||
const users = await minioService.listUsers();
|
||||
const user = users.find(u => u.accessKey === username);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
res.json({
|
||||
username: user.accessKey,
|
||||
status: user.status,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,152 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const config = require('./config');
|
||||
const { logger } = require('./utils/logger');
|
||||
|
||||
// Import middleware
|
||||
const errorHandler = require('./middleware/errorHandler.middleware');
|
||||
const ipFilter = require('./middleware/ipFilter.middleware');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./api/auth');
|
||||
const bucketRoutes = require('./api/buckets');
|
||||
const userRoutes = require('./api/users');
|
||||
const policyRoutes = require('./api/policies');
|
||||
const reportRoutes = require('./api/reports');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Trust proxy - important for getting real IP addresses
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// CORS configuration
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
// Allow requests with no origin (mobile apps, Postman, etc)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// In production, you might want to whitelist specific origins
|
||||
if (config.app.env === 'production') {
|
||||
const allowedOrigins = ['https://your-domain.com'];
|
||||
if (allowedOrigins.indexOf(origin) === -1) {
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, true);
|
||||
},
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200,
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// IP filtering middleware
|
||||
app.use(ipFilter);
|
||||
|
||||
// Request parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
// Logging
|
||||
if (config.app.env !== 'test') {
|
||||
const morganFormat = config.app.env === 'production' ? 'combined' : 'dev';
|
||||
app.use(morgan(morganFormat, {
|
||||
stream: {
|
||||
write: (message) => logger.info(message.trim())
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Apply rate limiting to all routes
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Stricter rate limiting for auth routes
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
app.use('/api/auth/login', authLimiter);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/buckets', bucketRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/policies', policyRoutes);
|
||||
app.use('/api/reports', reportRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'The requested resource was not found.',
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
if (require.main === module) {
|
||||
const PORT = config.app.port;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`MinIO WebUI Backend running on port ${PORT}`);
|
||||
logger.info(`Environment: ${config.app.env}`);
|
||||
logger.info(`IP Restriction: ${config.security.enableIpRestriction ? 'Enabled' : 'Disabled'}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,79 @@
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: path.join(__dirname, '../../../.env') });
|
||||
|
||||
const config = {
|
||||
app: {
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
},
|
||||
auth: {
|
||||
adminPasswordHash: process.env.ADMIN_PASSWORD_HASH,
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '1800', 10),
|
||||
},
|
||||
security: {
|
||||
enableIpRestriction: process.env.ENABLE_IP_RESTRICTION === 'true',
|
||||
allowedIps: process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',').map(ip => ip.trim()) : [],
|
||||
},
|
||||
minio: {
|
||||
defaultAlias: process.env.DEFAULT_MINIO_ALIAS || 'minio',
|
||||
endpoint: process.env.MINIO_ENDPOINT,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||
secretKey: process.env.MINIO_SECRET_KEY,
|
||||
},
|
||||
email: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
from: process.env.REPORT_SENDER,
|
||||
to: process.env.REPORT_RECIPIENT,
|
||||
},
|
||||
reports: {
|
||||
schedule: process.env.REPORT_SCHEDULE || '0 0 * * 1',
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate required configuration
|
||||
const validateConfig = () => {
|
||||
const required = [
|
||||
'auth.adminPasswordHash',
|
||||
'auth.jwtSecret',
|
||||
'minio.defaultAlias',
|
||||
];
|
||||
|
||||
const missing = [];
|
||||
required.forEach(key => {
|
||||
const keys = key.split('.');
|
||||
let value = config;
|
||||
keys.forEach(k => {
|
||||
value = value[k];
|
||||
});
|
||||
if (!value) {
|
||||
missing.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Only validate in production
|
||||
if (config.app.env === 'production') {
|
||||
validateConfig();
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,70 @@
|
||||
const authService = require('../services/auth.service');
|
||||
const { logger, logAudit } = require('../utils/logger');
|
||||
const { AppError } = require('./errorHandler.middleware');
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
// Get token from cookie or Authorization header
|
||||
const token = req.cookies.token ||
|
||||
req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
throw new AppError('Authentication required', 401);
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = authService.verifyToken(token);
|
||||
|
||||
// Check if IP matches (optional additional security)
|
||||
if (decoded.ip && decoded.ip !== req.ip) {
|
||||
logger.warn(`IP mismatch for token. Token IP: ${decoded.ip}, Request IP: ${req.ip}`);
|
||||
logAudit('SUSPICIOUS_TOKEN_USE', {
|
||||
ip: req.ip,
|
||||
tokenIp: decoded.ip,
|
||||
resource: req.originalUrl,
|
||||
status: 'blocked',
|
||||
});
|
||||
throw new AppError('Invalid session', 401);
|
||||
}
|
||||
|
||||
// Attach user info to request
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return res.status(error.statusCode).json({
|
||||
error: 'Authentication Failed',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Auth middleware error:', error);
|
||||
return res.status(401).json({
|
||||
error: 'Authentication Failed',
|
||||
message: 'Invalid or expired token',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Optional middleware for routes that can work with or without auth
|
||||
const optionalAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.cookies.token ||
|
||||
req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
const decoded = authService.verifyToken(token);
|
||||
req.user = decoded;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors for optional auth
|
||||
logger.debug('Optional auth failed:', error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
optionalAuthMiddleware,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
const { logger } = require('../utils/logger');
|
||||
const config = require('../config');
|
||||
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode, isOperational = true) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = isOperational;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
|
||||
// Log error
|
||||
logger.error({
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// Mongoose bad ObjectId
|
||||
if (err.name === 'CastError') {
|
||||
const message = 'Resource not found';
|
||||
error = new AppError(message, 404);
|
||||
}
|
||||
|
||||
// Mongoose duplicate key
|
||||
if (err.code === 11000) {
|
||||
const message = 'Duplicate field value entered';
|
||||
error = new AppError(message, 400);
|
||||
}
|
||||
|
||||
// Mongoose validation error
|
||||
if (err.name === 'ValidationError') {
|
||||
const message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||
error = new AppError(message, 400);
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
const message = 'Invalid token';
|
||||
error = new AppError(message, 401);
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
const message = 'Token expired';
|
||||
error = new AppError(message, 401);
|
||||
}
|
||||
|
||||
// Default error response
|
||||
const statusCode = error.statusCode || 500;
|
||||
const message = error.message || 'Internal Server Error';
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: true,
|
||||
message,
|
||||
...(config.app.env === 'development' && { stack: err.stack }),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
module.exports.AppError = AppError;
|
||||
@@ -0,0 +1,59 @@
|
||||
const ipRangeCheck = require('ip-range-check');
|
||||
const config = require('../config');
|
||||
const { logger, logAudit } = require('../utils/logger');
|
||||
|
||||
const ipFilterMiddleware = (req, res, next) => {
|
||||
// Skip IP filtering if disabled
|
||||
if (!config.security.enableIpRestriction) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip for health check endpoint
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
const clientIp = req.ip ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
req.headers['x-forwarded-for']?.split(',')[0];
|
||||
|
||||
// Normalize IPv6 localhost to IPv4
|
||||
const normalizedIp = clientIp === '::1' ? '127.0.0.1' : clientIp;
|
||||
|
||||
try {
|
||||
// Check if IP is in allowed list
|
||||
const isAllowed = ipRangeCheck(normalizedIp, config.security.allowedIps);
|
||||
|
||||
if (isAllowed) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Log unauthorized access attempt
|
||||
logger.warn(`Unauthorized access attempt from IP: ${normalizedIp}`);
|
||||
logAudit('UNAUTHORIZED_ACCESS', {
|
||||
ip: normalizedIp,
|
||||
resource: req.originalUrl,
|
||||
status: 'blocked',
|
||||
details: {
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Access Denied',
|
||||
message: 'Your IP address is not authorized to access this resource.',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in IP filter middleware:', error);
|
||||
// In case of error, fail securely by denying access
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Unable to verify access permissions.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ipFilterMiddleware;
|
||||
@@ -0,0 +1,69 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
const { logger } = require('../utils/logger');
|
||||
const { AppError } = require('../middleware/errorHandler.middleware');
|
||||
|
||||
class AuthService {
|
||||
generateToken(payload) {
|
||||
return jwt.sign(
|
||||
payload,
|
||||
config.auth.jwtSecret,
|
||||
{ expiresIn: config.auth.sessionTimeout }
|
||||
);
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, config.auth.jwtSecret);
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new AppError('Session expired', 401);
|
||||
}
|
||||
throw new AppError('Invalid token', 401);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(password) {
|
||||
try {
|
||||
const isValid = await bcrypt.compare(password, config.auth.adminPasswordHash);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
logger.error('Password verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async hashPassword(password) {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
createSession(ip) {
|
||||
const sessionData = {
|
||||
role: 'admin',
|
||||
ip,
|
||||
loginTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const token = this.generateToken(sessionData);
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresIn: config.auth.sessionTimeout,
|
||||
role: sessionData.role,
|
||||
};
|
||||
}
|
||||
|
||||
getCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: config.app.env === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: config.auth.sessionTimeout * 1000, // Convert to milliseconds
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
@@ -0,0 +1,414 @@
|
||||
const { exec, spawn } = require('child_process');
|
||||
const util = require('util');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
const { logger } = require('../utils/logger');
|
||||
const { AppError } = require('../middleware/errorHandler.middleware');
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
class MinIOService {
|
||||
constructor(alias = config.minio.defaultAlias) {
|
||||
this.alias = alias;
|
||||
this.tempDir = path.join(__dirname, '../../../temp');
|
||||
this.ensureTempDir();
|
||||
}
|
||||
|
||||
async ensureTempDir() {
|
||||
try {
|
||||
await fs.mkdir(this.tempDir, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create temp directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute MinIO CLI command with timeout and error handling
|
||||
async executeCommand(command, options = {}) {
|
||||
const { timeout = 30000, parseJson = false } = options;
|
||||
|
||||
try {
|
||||
logger.debug(`Executing command: ${command}`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||
env: { ...process.env, MC_NO_COLOR: '1' }, // Disable color output
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('Configuration written to')) {
|
||||
logger.warn(`Command stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
if (parseJson && stdout) {
|
||||
// Handle multiple JSON objects (one per line)
|
||||
const lines = stdout.trim().split('\n').filter(line => line);
|
||||
if (lines.length === 1) {
|
||||
return JSON.parse(lines[0]);
|
||||
}
|
||||
return lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
logger.error(`Command failed: ${command}`, error);
|
||||
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
throw new AppError('Command timed out', 504);
|
||||
}
|
||||
|
||||
if (error.stderr) {
|
||||
if (error.stderr.includes('Unable to initialize new alias')) {
|
||||
throw new AppError('Invalid MinIO connection settings', 400);
|
||||
}
|
||||
if (error.stderr.includes('The specified bucket does not exist')) {
|
||||
throw new AppError('Bucket not found', 404);
|
||||
}
|
||||
if (error.stderr.includes('The specified key does not exist')) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
if (error.stderr.includes('Access Denied')) {
|
||||
throw new AppError('Access denied', 403);
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(error.message || 'Command execution failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket Management Methods
|
||||
async listBuckets() {
|
||||
const output = await this.executeCommand(
|
||||
`mc ls ${this.alias} --json`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
return Array.isArray(output) ? output : [output];
|
||||
}
|
||||
|
||||
async createBucket(bucketName) {
|
||||
// Validate bucket name
|
||||
if (!this.isValidBucketName(bucketName)) {
|
||||
throw new AppError('Invalid bucket name. Must be 3-63 characters, lowercase, no spaces.', 400);
|
||||
}
|
||||
|
||||
await this.executeCommand(`mc mb ${this.alias}/${bucketName}`);
|
||||
return { message: 'Bucket created successfully', bucketName };
|
||||
}
|
||||
|
||||
async deleteBucket(bucketName) {
|
||||
// Check if bucket is empty first
|
||||
const objects = await this.executeCommand(
|
||||
`mc ls ${this.alias}/${bucketName} --json`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
if (objects && objects.length > 0) {
|
||||
throw new AppError('Cannot delete non-empty bucket', 400);
|
||||
}
|
||||
|
||||
await this.executeCommand(`mc rb ${this.alias}/${bucketName}`);
|
||||
return { message: 'Bucket deleted successfully', bucketName };
|
||||
}
|
||||
|
||||
async getBucketSize(bucketName) {
|
||||
const output = await this.executeCommand(
|
||||
`mc du --json ${this.alias}/${bucketName}`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
return {
|
||||
bucketName,
|
||||
size: output.size || 0,
|
||||
sizeFormatted: this.formatBytes(output.size || 0),
|
||||
objects: output.objects || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getBucketSizes() {
|
||||
const buckets = await this.listBuckets();
|
||||
|
||||
const bucketSizes = await Promise.all(
|
||||
buckets.map(async (bucket) => {
|
||||
try {
|
||||
const sizeInfo = await this.getBucketSize(bucket.key);
|
||||
|
||||
// Get last modified time
|
||||
const findOutput = await this.executeCommand(
|
||||
`mc find ${this.alias}/${bucket.key} --maxdepth 1 --json | head -1`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
return {
|
||||
name: bucket.key,
|
||||
size: sizeInfo.size,
|
||||
sizeFormatted: sizeInfo.sizeFormatted,
|
||||
objects: sizeInfo.objects,
|
||||
lastModified: findOutput?.lastModified || bucket.lastModified || 'No files',
|
||||
created: bucket.lastModified,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get size for bucket ${bucket.key}:`, error);
|
||||
return {
|
||||
name: bucket.key,
|
||||
size: 0,
|
||||
sizeFormatted: '0 B',
|
||||
objects: 0,
|
||||
lastModified: 'Error',
|
||||
created: bucket.lastModified,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return bucketSizes;
|
||||
}
|
||||
|
||||
// User Management Methods
|
||||
async createBucketWithUser(bucketName, username, password) {
|
||||
// Validate inputs
|
||||
if (!this.isValidBucketName(bucketName)) {
|
||||
throw new AppError('Invalid bucket name', 400);
|
||||
}
|
||||
if (!this.isValidUsername(username)) {
|
||||
throw new AppError('Invalid username. Use only letters, numbers, hyphens, and underscores.', 400);
|
||||
}
|
||||
if (!this.isValidPassword(password)) {
|
||||
throw new AppError('Password must be at least 8 characters with uppercase, lowercase, and number.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create bucket
|
||||
await this.createBucket(bucketName);
|
||||
|
||||
// Create user
|
||||
await this.executeCommand(
|
||||
`mc admin user add ${this.alias} ${username} ${password}`
|
||||
);
|
||||
|
||||
// Create policy
|
||||
const policyName = `${username}-policy`;
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Effect: 'Allow',
|
||||
Action: ['s3:*'],
|
||||
Resource: [
|
||||
`arn:aws:s3:::${bucketName}`,
|
||||
`arn:aws:s3:::${bucketName}/*`
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
// Write policy to temp file
|
||||
const policyFile = path.join(this.tempDir, `${policyName}-${Date.now()}.json`);
|
||||
await fs.writeFile(policyFile, JSON.stringify(policy, null, 2));
|
||||
|
||||
try {
|
||||
// Create and attach policy
|
||||
await this.executeCommand(
|
||||
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
|
||||
);
|
||||
await this.executeCommand(
|
||||
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
|
||||
);
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
await fs.unlink(policyFile).catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
bucketName,
|
||||
username,
|
||||
policyName,
|
||||
message: 'Bucket and user created successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
logger.error('Failed to create bucket with user, attempting rollback:', error);
|
||||
|
||||
// Try to clean up
|
||||
await this.executeCommand(`mc rb ${this.alias}/${bucketName} --force`).catch(() => {});
|
||||
await this.executeCommand(`mc admin user remove ${this.alias} ${username}`).catch(() => {});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const output = await this.executeCommand(
|
||||
`mc admin user list ${this.alias} --json`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
const users = Array.isArray(output) ? output : [output];
|
||||
return users.map(user => ({
|
||||
accessKey: user.accessKey,
|
||||
status: user.userStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
async removeUser(username) {
|
||||
await this.executeCommand(`mc admin user remove ${this.alias} ${username}`);
|
||||
return { message: 'User removed successfully', username };
|
||||
}
|
||||
|
||||
async enableUser(username) {
|
||||
await this.executeCommand(`mc admin user enable ${this.alias} ${username}`);
|
||||
return { message: 'User enabled successfully', username };
|
||||
}
|
||||
|
||||
async disableUser(username) {
|
||||
await this.executeCommand(`mc admin user disable ${this.alias} ${username}`);
|
||||
return { message: 'User disabled successfully', username };
|
||||
}
|
||||
|
||||
// Policy Management Methods
|
||||
async listPolicies() {
|
||||
const output = await this.executeCommand(
|
||||
`mc admin policy list ${this.alias} --json`,
|
||||
{ parseJson: true }
|
||||
);
|
||||
|
||||
const policies = Array.isArray(output) ? output : [output];
|
||||
return policies.map(policy => ({
|
||||
name: policy.policy,
|
||||
type: policy.policyInfo?.PolicyType || 'custom',
|
||||
}));
|
||||
}
|
||||
|
||||
async createPolicy(policyName, policyDocument) {
|
||||
if (!this.isValidPolicyName(policyName)) {
|
||||
throw new AppError('Invalid policy name. Use only letters, numbers, hyphens, and underscores.', 400);
|
||||
}
|
||||
|
||||
// Validate policy document
|
||||
try {
|
||||
const policy = typeof policyDocument === 'string'
|
||||
? JSON.parse(policyDocument)
|
||||
: policyDocument;
|
||||
|
||||
if (!policy.Version || !policy.Statement) {
|
||||
throw new Error('Invalid policy structure');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new AppError('Invalid policy document', 400);
|
||||
}
|
||||
|
||||
const policyFile = path.join(this.tempDir, `${policyName}-${Date.now()}.json`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(policyFile,
|
||||
typeof policyDocument === 'string' ? policyDocument : JSON.stringify(policyDocument, null, 2)
|
||||
);
|
||||
|
||||
await this.executeCommand(
|
||||
`mc admin policy create ${this.alias} ${policyName} ${policyFile}`
|
||||
);
|
||||
|
||||
return { message: 'Policy created successfully', policyName };
|
||||
} finally {
|
||||
await fs.unlink(policyFile).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async deletePolicy(policyName) {
|
||||
await this.executeCommand(`mc admin policy remove ${this.alias} ${policyName}`);
|
||||
return { message: 'Policy deleted successfully', policyName };
|
||||
}
|
||||
|
||||
async attachPolicy(policyName, username) {
|
||||
await this.executeCommand(
|
||||
`mc admin policy attach ${this.alias} ${policyName} --user ${username}`
|
||||
);
|
||||
return { message: 'Policy attached successfully', policyName, username };
|
||||
}
|
||||
|
||||
// Alias Management Methods
|
||||
async testConnection(alias = this.alias) {
|
||||
try {
|
||||
await this.executeCommand(`mc admin info ${alias}`, { timeout: 10000 });
|
||||
return { status: 'connected', alias };
|
||||
} catch (error) {
|
||||
return { status: 'failed', alias, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async addAlias(aliasName, endpoint, accessKey, secretKey) {
|
||||
if (!this.isValidAliasName(aliasName)) {
|
||||
throw new AppError('Invalid alias name', 400);
|
||||
}
|
||||
|
||||
await this.executeCommand(
|
||||
`mc alias set ${aliasName} ${endpoint} ${accessKey} ${secretKey}`
|
||||
);
|
||||
|
||||
// Test the connection
|
||||
const testResult = await this.testConnection(aliasName);
|
||||
if (testResult.status !== 'connected') {
|
||||
// Remove the alias if connection fails
|
||||
await this.executeCommand(`mc alias remove ${aliasName}`).catch(() => {});
|
||||
throw new AppError('Failed to connect to MinIO server', 400);
|
||||
}
|
||||
|
||||
return { message: 'Alias added successfully', aliasName };
|
||||
}
|
||||
|
||||
async listAliases() {
|
||||
const output = await this.executeCommand(`mc alias list --json`, { parseJson: true });
|
||||
const aliases = Array.isArray(output) ? output : [output];
|
||||
|
||||
return aliases.map(alias => ({
|
||||
alias: alias.alias,
|
||||
URL: alias.URL,
|
||||
accessKey: alias.accessKey ? alias.accessKey.substring(0, 8) + '...' : '',
|
||||
}));
|
||||
}
|
||||
|
||||
// Validation Methods
|
||||
isValidBucketName(name) {
|
||||
const regex = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/;
|
||||
return name && name.length >= 3 && name.length <= 63 && regex.test(name);
|
||||
}
|
||||
|
||||
isValidUsername(username) {
|
||||
const regex = /^[a-zA-Z0-9_-]+$/;
|
||||
return username && username.length >= 3 && username.length <= 32 && regex.test(username);
|
||||
}
|
||||
|
||||
isValidPassword(password) {
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
return password && password.length >= 8 && hasUpperCase && hasLowerCase && hasNumber;
|
||||
}
|
||||
|
||||
isValidPolicyName(name) {
|
||||
const regex = /^[a-zA-Z0-9_-]+$/;
|
||||
return name && name.length >= 1 && name.length <= 128 && regex.test(name);
|
||||
}
|
||||
|
||||
isValidAliasName(name) {
|
||||
const regex = /^[a-zA-Z0-9_-]+$/;
|
||||
return name && name.length >= 1 && name.length <= 32 && regex.test(name);
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MinIOService;
|
||||
@@ -0,0 +1,233 @@
|
||||
const cron = require('node-cron');
|
||||
const nodemailer = require('nodemailer');
|
||||
const MinIOService = require('./minio.service');
|
||||
const config = require('../config');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
class ReportService {
|
||||
constructor() {
|
||||
this.minioService = new MinIOService();
|
||||
this.transporter = null;
|
||||
this.scheduledTask = null;
|
||||
|
||||
if (config.email.host && config.email.auth.user) {
|
||||
this.initializeMailer();
|
||||
this.setupSchedule();
|
||||
} else {
|
||||
logger.warn('Email configuration missing. Report scheduling disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
initializeMailer() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
secure: config.email.secure,
|
||||
auth: {
|
||||
user: config.email.auth.user,
|
||||
pass: config.email.auth.pass,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify connection
|
||||
this.transporter.verify((error) => {
|
||||
if (error) {
|
||||
logger.error('Email transporter verification failed:', error);
|
||||
} else {
|
||||
logger.info('Email transporter ready');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupSchedule() {
|
||||
if (!cron.validate(config.reports.schedule)) {
|
||||
logger.error(`Invalid cron expression: ${config.reports.schedule}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduledTask = cron.schedule(config.reports.schedule, async () => {
|
||||
logger.info('Running scheduled storage report...');
|
||||
try {
|
||||
await this.generateAndSendReport();
|
||||
} catch (error) {
|
||||
logger.error('Scheduled report failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Report scheduled with cron: ${config.reports.schedule}`);
|
||||
}
|
||||
|
||||
async generateReport() {
|
||||
const [bucketSizes, users] = await Promise.all([
|
||||
this.minioService.getBucketSizes(),
|
||||
this.minioService.listUsers(),
|
||||
]);
|
||||
|
||||
const totalSize = bucketSizes.reduce((sum, b) => sum + b.size, 0);
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
const report = {
|
||||
date,
|
||||
summary: {
|
||||
totalBuckets: bucketSizes.length,
|
||||
totalUsers: users.length,
|
||||
totalSize,
|
||||
totalSizeFormatted: this.minioService.formatBytes(totalSize),
|
||||
},
|
||||
buckets: bucketSizes,
|
||||
users: users.map(u => u.accessKey),
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
formatReportText(report) {
|
||||
let text = `MinIO Speicherauswertung\n`;
|
||||
text += `Datum: ${report.date}\n`;
|
||||
text += `----------------------------------------\n\n`;
|
||||
|
||||
text += `Zusammenfassung:\n`;
|
||||
text += `- Buckets: ${report.summary.totalBuckets}\n`;
|
||||
text += `- Benutzer: ${report.summary.totalUsers}\n`;
|
||||
text += `- Gesamtspeicher: ${report.summary.totalSizeFormatted}\n\n`;
|
||||
|
||||
text += `Alle MinIO-User:\n`;
|
||||
report.users.forEach(user => {
|
||||
text += `- ${user}\n`;
|
||||
});
|
||||
|
||||
text += `\nAlle Buckets:\n`;
|
||||
report.buckets.forEach(bucket => {
|
||||
text += `\nBucket: ${bucket.name}\n`;
|
||||
text += ` Größe: ${bucket.sizeFormatted}\n`;
|
||||
text += ` Objekte: ${bucket.objects}\n`;
|
||||
text += ` Letzte Änderung: ${bucket.lastModified}\n`;
|
||||
});
|
||||
|
||||
text += `\n----------------------------------------\n`;
|
||||
text += `Gesamtspeicher: ${report.summary.totalSizeFormatted}\n`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
formatReportHTML(report) {
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
h1 { color: #2c3e50; }
|
||||
.summary { background: #f4f4f4; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #2c3e50; color: white; }
|
||||
tr:nth-child(even) { background-color: #f2f2f2; }
|
||||
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 0.9em; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MinIO Speicherauswertung</h1>
|
||||
<p>Datum: ${report.date}</p>
|
||||
|
||||
<div class="summary">
|
||||
<h2>Zusammenfassung</h2>
|
||||
<ul>
|
||||
<li>Anzahl Buckets: ${report.summary.totalBuckets}</li>
|
||||
<li>Anzahl Benutzer: ${report.summary.totalUsers}</li>
|
||||
<li>Gesamtspeicher: <strong>${report.summary.totalSizeFormatted}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Bucket-Details</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket Name</th>
|
||||
<th>Größe</th>
|
||||
<th>Objekte</th>
|
||||
<th>Letzte Änderung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${report.buckets.map(bucket => `
|
||||
<tr>
|
||||
<td>${bucket.name}</td>
|
||||
<td>${bucket.sizeFormatted}</td>
|
||||
<td>${bucket.objects}</td>
|
||||
<td>${bucket.lastModified}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Benutzer</h2>
|
||||
<ul>
|
||||
${report.users.map(user => `<li>${user}</li>`).join('')}
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
<p>Dieser Bericht wurde automatisch von MinIO WebUI generiert.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async sendEmail(report, recipients = null) {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
const to = recipients || config.email.to;
|
||||
if (!to) {
|
||||
throw new Error('No recipients configured');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: config.email.from,
|
||||
to: Array.isArray(to) ? to.join(', ') : to,
|
||||
subject: `MinIO Speicherauswertung ${report.date}`,
|
||||
text: this.formatReportText(report),
|
||||
html: this.formatReportHTML(report),
|
||||
};
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
logger.info(`Report email sent: ${info.messageId}`);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async generateAndSendReport(recipients = null) {
|
||||
const report = await this.generateReport();
|
||||
await this.sendEmail(report, recipients);
|
||||
return report;
|
||||
}
|
||||
|
||||
getScheduleInfo() {
|
||||
return {
|
||||
enabled: !!this.scheduledTask,
|
||||
schedule: config.reports.schedule,
|
||||
nextRun: this.scheduledTask ? cron.getTasks()[0]?.nextDates(1)[0] : null,
|
||||
recipients: config.email.to,
|
||||
};
|
||||
}
|
||||
|
||||
stopSchedule() {
|
||||
if (this.scheduledTask) {
|
||||
this.scheduledTask.stop();
|
||||
this.scheduledTask = null;
|
||||
logger.info('Report schedule stopped');
|
||||
}
|
||||
}
|
||||
|
||||
startSchedule() {
|
||||
if (!this.scheduledTask && this.transporter) {
|
||||
this.setupSchedule();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ReportService();
|
||||
@@ -0,0 +1,104 @@
|
||||
const winston = require('winston');
|
||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
|
||||
const logDir = path.join(__dirname, '../../../logs');
|
||||
|
||||
// Define log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
// Console format for development
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, ...metadata }) => {
|
||||
let msg = `${timestamp} [${level}]: ${message}`;
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
msg += ` ${JSON.stringify(metadata)}`;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
// Create transports
|
||||
const transports = [];
|
||||
|
||||
// Console transport
|
||||
if (config.app.env !== 'test') {
|
||||
transports.push(
|
||||
new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// File transports for production
|
||||
if (config.app.env === 'production') {
|
||||
// General log file
|
||||
transports.push(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'app-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
format: logFormat,
|
||||
})
|
||||
);
|
||||
|
||||
// Error log file
|
||||
transports.push(
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
format: logFormat,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: config.app.logLevel,
|
||||
format: logFormat,
|
||||
transports,
|
||||
exitOnError: false,
|
||||
});
|
||||
|
||||
// Create audit logger for security events
|
||||
const auditLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'audit-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '90d',
|
||||
})
|
||||
],
|
||||
});
|
||||
|
||||
// Helper function for audit logging
|
||||
const logAudit = (action, { userId, ip, resource, status, details = {} }) => {
|
||||
auditLogger.info({
|
||||
action,
|
||||
userId,
|
||||
ip,
|
||||
resource,
|
||||
status,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logger,
|
||||
logAudit,
|
||||
};
|
||||
Reference in New Issue
Block a user