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
+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();