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:
2025-07-22 16:29:53 +02:00
commit bb44b143ec
43 changed files with 6631 additions and 0 deletions
+147
View File
@@ -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;
+197
View File
@@ -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;
+252
View File
@@ -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;
+164
View File
@@ -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;
+193
View File
@@ -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;
+152
View File
@@ -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;
+79
View File
@@ -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;
+70
View File
@@ -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;
+69
View File
@@ -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();
+414
View File
@@ -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;
+233
View File
@@ -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();
+104
View File
@@ -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,
};