Files
minio-webui/Implementation-Guide.md
paul bb44b143ec 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
2025-07-22 16:29:53 +02:00

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.