bb44b143ec
- 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
20 KiB
20 KiB
MinIO WebUI - Technical Implementation Guide
Quick Start Implementation
1. Backend Service Implementation
MinIO Service Layer
// backend/src/services/minio.service.js
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);
class MinIOService {
constructor(alias = process.env.DEFAULT_MINIO_ALIAS) {
this.alias = alias;
}
// Create bucket with user and policy (based on kopia-user-create.sh)
async createBucketWithUser(bucketName, username, password) {
try {
// Create bucket
await execAsync(`mc mb ${this.alias}/${bucketName}`);
// Create user
await execAsync(`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}/*`
]
}]
};
const policyFile = `/tmp/${policyName}.json`;
require('fs').writeFileSync(policyFile, JSON.stringify(policy));
await execAsync(`mc admin policy create ${this.alias} ${policyName} ${policyFile}`);
await execAsync(`mc admin policy attach ${this.alias} ${policyName} --user ${username}`);
// Cleanup
require('fs').unlinkSync(policyFile);
return { bucketName, username, policyName };
} catch (error) {
throw new Error(`Failed to create bucket with user: ${error.message}`);
}
}
// Get bucket sizes for invoicing (based on speicherauswertung_mail.sh)
async getBucketSizes() {
try {
const { stdout: bucketsOutput } = await execAsync(`mc ls ${this.alias} --json`);
const buckets = bucketsOutput.split('\n').filter(line => line).map(line => JSON.parse(line));
const bucketSizes = await Promise.all(
buckets.map(async (bucket) => {
const { stdout: sizeOutput } = await execAsync(`mc du --json ${this.alias}/${bucket.key}`);
const sizeInfo = JSON.parse(sizeOutput);
// Get last modified
const { stdout: lastModified } = await execAsync(
`mc find ${this.alias}/${bucket.key} --json --print '{time}' | sort | tail -n1`
);
return {
name: bucket.key,
size: sizeInfo.size,
sizeFormatted: this.formatBytes(sizeInfo.size),
lastModified: lastModified.trim() || 'No files',
objectCount: sizeInfo.objects || 0
};
})
);
return bucketSizes;
} catch (error) {
throw new Error(`Failed to get bucket sizes: ${error.message}`);
}
}
formatBytes(bytes) {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
}
}
module.exports = MinIOService;
Authentication Middleware
// backend/src/middleware/auth.middleware.js
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const authMiddleware = async (req, res, next) => {
try {
const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
module.exports = authMiddleware;
IP Filter Middleware
// backend/src/middleware/ipFilter.middleware.js
const ipRangeCheck = require('ip-range-check');
const ipFilterMiddleware = (req, res, next) => {
if (process.env.ENABLE_IP_RESTRICTION !== 'true') {
return next();
}
const clientIp = req.ip || req.connection.remoteAddress;
const allowedIps = process.env.ALLOWED_IPS.split(',').map(ip => ip.trim());
if (ipRangeCheck(clientIp, allowedIps)) {
next();
} else {
console.warn(`Unauthorized access attempt from IP: ${clientIp}`);
res.status(403).json({ error: 'Access denied' });
}
};
module.exports = ipFilterMiddleware;
2. Frontend Implementation
Bucket Creation Component
// frontend/src/components/Buckets/CreateBucketDialog.tsx
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Button,
Stepper,
Step,
StepLabel,
Alert,
CircularProgress
} from '@mui/material';
import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { bucketService } from '../../services/bucket.service';
const schema = yup.object({
bucketName: yup.string()
.required('Bucket name is required')
.matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, 'Invalid bucket name format')
.min(3, 'Minimum 3 characters')
.max(63, 'Maximum 63 characters'),
username: yup.string()
.required('Username is required')
.matches(/^[a-zA-Z0-9_-]+$/, 'Only alphanumeric, hyphen and underscore allowed'),
password: yup.string()
.required('Password is required')
.min(8, 'Minimum 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Must contain uppercase, lowercase and number')
});
export const CreateBucketDialog: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { register, handleSubmit, formState: { errors }, reset } = useForm({
resolver: yupResolver(schema)
});
const onSubmit = async (data: any) => {
try {
setLoading(true);
setError('');
await bucketService.createBucketWithUser(data.bucketName, data.username, data.password);
setActiveStep(2);
setTimeout(() => {
reset();
onClose();
setActiveStep(0);
}, 2000);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to create bucket');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Create New Bucket with User</DialogTitle>
<DialogContent>
<Stepper activeStep={activeStep} sx={{ mb: 3 }}>
<Step><StepLabel>Bucket Details</StepLabel></Step>
<Step><StepLabel>User Credentials</StepLabel></Step>
<Step><StepLabel>Complete</StepLabel></Step>
</Stepper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<form onSubmit={handleSubmit(onSubmit)}>
{activeStep === 0 && (
<>
<TextField
{...register('bucketName')}
label="Bucket Name"
fullWidth
margin="normal"
error={!!errors.bucketName}
helperText={errors.bucketName?.message || 'e.g., alice-bucket'}
/>
<Button
variant="contained"
onClick={() => setActiveStep(1)}
disabled={!!errors.bucketName}
sx={{ mt: 2 }}
>
Next
</Button>
</>
)}
{activeStep === 1 && (
<>
<TextField
{...register('username')}
label="Username"
fullWidth
margin="normal"
error={!!errors.username}
helperText={errors.username?.message}
/>
<TextField
{...register('password')}
label="Password"
type="password"
fullWidth
margin="normal"
error={!!errors.password}
helperText={errors.password?.message}
/>
<Button onClick={() => setActiveStep(0)} sx={{ mt: 2, mr: 1 }}>
Back
</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
sx={{ mt: 2 }}
>
{loading ? <CircularProgress size={24} /> : 'Create'}
</Button>
</>
)}
{activeStep === 2 && (
<Alert severity="success">
Bucket and user created successfully!
</Alert>
)}
</form>
</DialogContent>
</Dialog>
);
};
Storage Dashboard Component
// frontend/src/components/Reports/StorageDashboard.tsx
import React, { useEffect, useState } from 'react';
import {
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Chip,
Box,
LinearProgress
} from '@mui/material';
import { Pie } from 'react-chartjs-2';
import { reportService } from '../../services/report.service';
export const StorageDashboard: React.FC = () => {
const [bucketSizes, setBucketSizes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [totalSize, setTotalSize] = useState(0);
useEffect(() => {
loadBucketSizes();
const interval = setInterval(loadBucketSizes, 60000); // Refresh every minute
return () => clearInterval(interval);
}, []);
const loadBucketSizes = async () => {
try {
const data = await reportService.getBucketSizes();
setBucketSizes(data);
setTotalSize(data.reduce((sum, bucket) => sum + bucket.size, 0));
} catch (error) {
console.error('Failed to load bucket sizes:', error);
} finally {
setLoading(false);
}
};
const chartData = {
labels: bucketSizes.map(b => b.name),
datasets: [{
data: bucketSizes.map(b => b.size),
backgroundColor: [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40'
]
}]
};
return (
<Box>
<Typography variant="h4" gutterBottom>
Storage Overview
</Typography>
{loading ? (
<LinearProgress />
) : (
<>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Card sx={{ flex: 1 }}>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Storage Used
</Typography>
<Typography variant="h4">
{reportService.formatBytes(totalSize)}
</Typography>
</CardContent>
</Card>
<Card sx={{ flex: 1 }}>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Number of Buckets
</Typography>
<Typography variant="h4">
{bucketSizes.length}
</Typography>
</CardContent>
</Card>
</Box>
<Box sx={{ display: 'flex', gap: 3 }}>
<Card sx={{ flex: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Bucket Details
</Typography>
<Table>
<TableHead>
<TableRow>
<TableCell>Bucket Name</TableCell>
<TableCell align="right">Size</TableCell>
<TableCell align="right">Objects</TableCell>
<TableCell>Last Modified</TableCell>
</TableRow>
</TableHead>
<TableBody>
{bucketSizes.map((bucket) => (
<TableRow key={bucket.name}>
<TableCell>{bucket.name}</TableCell>
<TableCell align="right">
<Chip label={bucket.sizeFormatted} size="small" />
</TableCell>
<TableCell align="right">{bucket.objectCount}</TableCell>
<TableCell>{bucket.lastModified}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card sx={{ flex: 1 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Storage Distribution
</Typography>
<Box sx={{ height: 300 }}>
<Pie data={chartData} options={{ maintainAspectRatio: false }} />
</Box>
</CardContent>
</Card>
</Box>
</>
)}
</Box>
);
};
3. Environment Setup
.env.example
# Application
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
# Authentication
ADMIN_PASSWORD_HASH=$2b$12$abcdefghijklmnopqrstuvwxyz123456789
JWT_SECRET=your-super-secret-jwt-key-minimum-64-characters-long-random-string
SESSION_TIMEOUT=1800
# IP Restrictions
ENABLE_IP_RESTRICTION=true
ALLOWED_IPS=192.168.1.0/24,10.0.0.5,172.16.0.0/16
# MinIO Configuration
DEFAULT_MINIO_ALIAS=kopiaminio
MINIO_ENDPOINT=https://minio.example.com
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# Email Configuration (for reports)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
REPORT_RECIPIENT=info@example.com
REPORT_SENDER=kopiabackup@example.com
# Report Schedule (cron format)
REPORT_SCHEDULE=0 0 * * 1 # Every Monday at midnight
4. Docker Deployment
docker-compose.yml
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: ../docker/Dockerfile.backend
environment:
- NODE_ENV=production
env_file:
- .env
ports:
- "3000:3000"
restart: unless-stopped
depends_on:
- redis
volumes:
- ./logs:/app/logs
- ~/.mc:/root/.mc:ro # MinIO client config
frontend:
build:
context: ./frontend
dockerfile: ../docker/Dockerfile.frontend
ports:
- "80:80"
- "443:443"
restart: unless-stopped
depends_on:
- backend
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./ssl:/etc/nginx/ssl
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
redis-data:
Nginx Configuration
# nginx/default.conf
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# IP Restriction (backup for application-level restriction)
allow 192.168.1.0/24;
allow 10.0.0.5;
allow 172.16.0.0/16;
deny all;
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
5. Security Implementation Checklist
- Implement bcrypt password hashing with salt rounds >= 12
- Use secure session cookies (HttpOnly, Secure, SameSite=Strict)
- Implement CSRF protection using double-submit cookies
- Add rate limiting (express-rate-limit)
- Input validation on all API endpoints
- Escape shell commands to prevent injection
- Implement proper error handling without exposing internals
- Add security headers (Helmet.js)
- Regular dependency updates (npm audit)
- Implement audit logging for all operations
- Use HTTPS everywhere
- Implement proper CORS configuration
- Add Content Security Policy
- Implement request size limits
- Add API versioning
6. Automated Report Service
// backend/src/services/report.service.js
const cron = require('node-cron');
const nodemailer = require('nodemailer');
const MinIOService = require('./minio.service');
class ReportService {
constructor() {
this.minioService = new MinIOService();
this.setupSchedule();
}
setupSchedule() {
const schedule = process.env.REPORT_SCHEDULE || '0 0 * * 1';
cron.schedule(schedule, async () => {
console.log('Running scheduled storage report...');
await this.generateAndSendReport();
});
}
async generateAndSendReport() {
try {
const bucketSizes = await this.minioService.getBucketSizes();
const users = await this.minioService.listUsers();
const report = this.formatReport(bucketSizes, users);
await this.sendEmail(report);
console.log('Storage report sent successfully');
} catch (error) {
console.error('Failed to generate report:', error);
}
}
formatReport(bucketSizes, users) {
const date = new Date().toISOString().split('T')[0];
const totalSize = bucketSizes.reduce((sum, b) => sum + b.size, 0);
let report = `MinIO Speicherauswertung\n`;
report += `Datum: ${date}\n`;
report += `----------------------------------------\n\n`;
report += `Alle MinIO-User:\n`;
users.forEach(user => {
report += `- ${user}\n`;
});
report += `\nAlle Buckets:\n`;
bucketSizes.forEach(bucket => {
report += `\nBucket: ${bucket.name}\n`;
report += ` Größe: ${bucket.sizeFormatted}\n`;
report += ` Letzte Dateiänderung: ${bucket.lastModified}\n`;
});
report += `\n----------------------------------------\n`;
report += `Gesamtspeicher: ${this.minioService.formatBytes(totalSize)}\n`;
return report;
}
async sendEmail(content) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
await transporter.sendMail({
from: process.env.REPORT_SENDER,
to: process.env.REPORT_RECIPIENT,
subject: `MinIO Speicherauswertung ${new Date().toISOString().split('T')[0]}`,
text: content
});
}
}
module.exports = ReportService;
7. Quick Setup Script
#!/bin/bash
# scripts/setup.sh
echo "MinIO WebUI Setup"
echo "================="
# Check prerequisites
command -v node >/dev/null 2>&1 || { echo "Node.js is required but not installed."; exit 1; }
command -v mc >/dev/null 2>&1 || { echo "MinIO Client (mc) is required but not installed."; exit 1; }
# Create .env file
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env file. Please configure it before starting."
fi
# Generate secure passwords
echo "Generating secure passwords..."
ADMIN_PASS=$(openssl rand -base64 32)
JWT_SECRET=$(openssl rand -base64 64)
echo "Admin Password: $ADMIN_PASS"
echo "Hashing password..."
ADMIN_HASH=$(node -e "const bcrypt = require('bcrypt'); console.log(bcrypt.hashSync('$ADMIN_PASS', 12));")
# Update .env
sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH=$ADMIN_HASH|" .env
sed -i "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
# Install dependencies
echo "Installing backend dependencies..."
cd backend && npm install
echo "Installing frontend dependencies..."
cd ../frontend && npm install
# Build frontend
echo "Building frontend..."
npm run build
echo "Setup complete! Don't forget to:"
echo "1. Configure MinIO connection in .env"
echo "2. Set allowed IPs in .env"
echo "3. Configure email settings for reports"
echo ""
echo "To start: docker-compose up -d"
This implementation guide provides a solid foundation for building the MinIO WebUI with all the requested features while maintaining security and simplicity for non-technical users.