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
+37
View File
@@ -0,0 +1,37 @@
# Application
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
# Authentication
ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_ACTUAL_HASH
JWT_SECRET=REPLACE_WITH_RANDOM_64_CHAR_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)
# Every Monday at midnight
REPORT_SCHEDULE=0 0 * * 1
# Redis Configuration (optional)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
+98
View File
@@ -0,0 +1,98 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Production builds
backend/dist/
frontend/build/
frontend/dist/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS files
.DS_Store
Thumbs.db
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
# Test coverage
coverage/
.nyc_output/
# Temporary files
temp/
tmp/
*.tmp
# MinIO configuration
.mc/
# SSL certificates (for local development)
*.pem
*.key
*.crt
# Docker volumes
docker/volumes/
# PM2
.pm2/
# Backup files
*.backup
*.bak
# Cache directories
.cache/
.parcel-cache/
# Generated files
*.pid
*.seed
*.pid.lock
# TypeScript
*.tsbuildinfo
# Next.js (if used later)
.next/
out/
# Gatsby (if used later)
.cache/
public/
# Mac specific
.AppleDouble
.LSOverride
# Linux specific
*~
# Windows specific
Thumbs.db
ehthumbs.db
Desktop.ini
# Redis dump
dump.rdb
+718
View File
@@ -0,0 +1,718 @@
# MinIO WebUI - Technical Implementation Guide
## Quick Start Implementation
### 1. Backend Service Implementation
#### MinIO Service Layer
```javascript
// 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
```javascript
// 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
```javascript
// 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
```tsx
// 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
```tsx
// 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
```bash
# 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
```yaml
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
# 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
```javascript
// 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
```bash
#!/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.
+589
View File
@@ -0,0 +1,589 @@
# Product Requirements Document (PRD)
## MinIO WebUI Management Portal
**Version:** 1.0
**Date:** January 22, 2025
**Status:** Draft
**Target Audience:** Non-Linux administrators
---
## 1. Executive Summary
### 1.1 Purpose
This document outlines the requirements for a web-based user interface that simplifies MinIO administration tasks for non-technical users. The WebUI will provide an intuitive interface for common MinIO operations currently performed via command-line tools.
### 1.2 Business Objectives
- Reduce the technical barrier for MinIO administration
- Streamline bucket and user management processes
- Provide real-time storage monitoring for billing purposes
- Ensure secure access with IP-based restrictions
- Minimize training requirements for non-Linux administrators
### 1.3 Key Features
- Bucket creation and management
- User and policy administration
- Storage size monitoring and reporting
- Alias management for MinIO connections
- Secure authentication with encrypted credentials
- IP-based access control
---
## 2. Product Overview
### 2.1 Problem Statement
Currently, MinIO administration requires Linux command-line expertise and knowledge of shell scripting. Non-technical staff cannot easily:
- Create new buckets and users
- Monitor storage usage for billing
- Manage access policies
- Check system status
### 2.2 Solution Overview
A web-based interface that abstracts complex MinIO CLI commands into simple, form-based operations with visual feedback and comprehensive error handling.
### 2.3 Success Metrics
- 90% reduction in support tickets for MinIO operations
- Sub-5-minute task completion for common operations
- Zero security incidents related to the WebUI
- Weekly automated storage reports generation
---
## 3. Functional Requirements
### 3.1 Core Features
#### 3.1.1 Bucket Management
**Priority:** High
**Capabilities:**
- Create new buckets with validation
- List all existing buckets
- Display bucket metadata (creation date, size, object count)
- Delete empty buckets
- View bucket policies
- Monitor bucket storage size in real-time
**Acceptance Criteria:**
- Bucket names follow S3 naming conventions
- Real-time validation prevents invalid names
- Confirmation dialogs for destructive operations
- Storage size displayed in human-readable format (GB, TB)
#### 3.1.2 User Management
**Priority:** High
**Capabilities:**
- Create new users with secure password generation
- List all MinIO users
- Associate users with buckets
- Manage user credentials
- Enable/disable user accounts
**Acceptance Criteria:**
- Password strength requirements enforced
- User creation follows the pattern from `kopia-user-create.sh`
- Automatic policy generation for bucket access
- Credential display with copy-to-clipboard functionality
#### 3.1.3 Policy Management
**Priority:** High
**Capabilities:**
- Create custom IAM-compatible policies
- Apply built-in policies (readonly, readwrite, writeonly)
- Attach policies to users
- View existing policies
- Policy templates for common scenarios
**Acceptance Criteria:**
- JSON policy validation
- Visual policy builder for non-technical users
- Policy preview before application
- Rollback capability for policy changes
#### 3.1.4 Alias Management
**Priority:** Medium
**Capabilities:**
- Add new MinIO server aliases
- Test connection to MinIO servers
- Switch between different MinIO deployments
- Secure credential storage for aliases
**Acceptance Criteria:**
- Connection testing with meaningful error messages
- Encrypted storage of alias credentials
- Default alias configuration
#### 3.1.5 Storage Monitoring
**Priority:** High
**Capabilities:**
- Real-time bucket size monitoring
- Historical storage usage graphs
- Weekly storage reports (automated)
- Export data for billing purposes
- Last modified timestamp for buckets
**Acceptance Criteria:**
- Matches functionality of `speicherauswertung_mail.sh`
- Configurable report scheduling
- CSV/PDF export options
- Email notification support
### 3.2 Authentication & Security
#### 3.2.1 Authentication System
**Priority:** Critical
**Requirements:**
- Single admin password stored in `.env` file
- Password encryption using industry-standard algorithms
- Session management with configurable timeout
- Secure session tokens
**Implementation:**
- bcrypt for password hashing
- JWT tokens for session management
- 30-minute default session timeout
- Secure cookie settings (HttpOnly, Secure, SameSite)
#### 3.2.2 IP-Based Access Control
**Priority:** Critical
**Requirements:**
- Configurable IP whitelist in `.env` file
- Support for CIDR notation
- Logging of access attempts
- Graceful handling of unauthorized access
**Implementation:**
```env
ALLOWED_IPS=192.168.1.0/24,10.0.0.5,172.16.0.0/16
```
### 3.3 Non-Functional Requirements
#### 3.3.1 Performance
- Page load time < 2 seconds
- API response time < 500ms for read operations
- Support 10 concurrent users
- Handle buckets with up to 1 million objects
#### 3.3.2 Usability
- Mobile-responsive design
- Intuitive navigation without documentation
- Contextual help tooltips
- Progress indicators for long operations
- Clear error messages with resolution steps
#### 3.3.3 Reliability
- 99.9% uptime (excluding planned maintenance)
- Graceful error handling
- Automatic reconnection to MinIO
- Transaction rollback on failures
#### 3.3.4 Security
- HTTPS-only communication
- CSRF protection
- XSS prevention
- SQL injection prevention (if applicable)
- Regular security header implementation
- Audit logging for all operations
---
## 4. Technical Architecture
### 4.1 Technology Stack
#### Backend
- **Runtime:** Node.js 20 LTS
- **Framework:** Express.js or Fastify
- **Process Manager:** PM2 for production
- **MinIO Integration:** Child process execution of `mc` CLI
- **Authentication:** Passport.js with local strategy
- **Session Store:** Redis or in-memory (configurable)
- **Task Scheduler:** node-cron for reports
#### Frontend
- **Framework:** React 18 with TypeScript
- **UI Library:** Material-UI (MUI) v5
- **State Management:** Zustand or Redux Toolkit
- **API Client:** Axios with interceptors
- **Charts:** Chart.js for storage graphs
- **Forms:** React Hook Form with Yup validation
#### Infrastructure
- **Web Server:** Nginx reverse proxy
- **SSL:** Let's Encrypt with auto-renewal
- **Monitoring:** Prometheus + Grafana (optional)
- **Logging:** Winston with daily rotation
### 4.2 System Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Web Browser │────▶│ Nginx Proxy │────▶│ Node.js App │
│ (React SPA) │ │ (SSL, IP Filter)│ │ (Express API) │
│ │ │ │ │ │
└─────────────────┘ └──────────────────┘ └────────┬────────┘
┌─────────────────┐
│ │
│ MinIO CLI (mc) │
│ Child Process │
│ │
└────────┬────────┘
┌─────────────────┐
│ │
│ MinIO Server │
│ (S3 API) │
│ │
└─────────────────┘
```
### 4.3 API Design
#### RESTful Endpoints
```
Authentication:
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/status
Buckets:
GET /api/buckets
POST /api/buckets
DELETE /api/buckets/:name
GET /api/buckets/:name/size
GET /api/buckets/:name/policy
Users:
GET /api/users
POST /api/users
DELETE /api/users/:username
PUT /api/users/:username/status
Policies:
GET /api/policies
POST /api/policies
DELETE /api/policies/:name
POST /api/policies/:name/attach
Aliases:
GET /api/aliases
POST /api/aliases
DELETE /api/aliases/:name
POST /api/aliases/:name/test
Reports:
GET /api/reports/storage
POST /api/reports/generate
GET /api/reports/schedule
```
### 4.4 Security Implementation
#### 4.4.1 Environment Configuration
```env
# Authentication
ADMIN_PASSWORD_HASH=$2b$12$... # bcrypt hash
JWT_SECRET=<random-64-char-string>
SESSION_TIMEOUT=1800 # 30 minutes
# IP Restrictions
ALLOWED_IPS=192.168.1.0/24,10.0.0.5
ENABLE_IP_RESTRICTION=true
# MinIO Configuration
DEFAULT_MINIO_ALIAS=kopiaminio
MINIO_ENDPOINT=https://minio.example.com
MINIO_ACCESS_KEY=<encrypted>
MINIO_SECRET_KEY=<encrypted>
# Email Configuration (for reports)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=kopiabackup@example.com
SMTP_PASS=<encrypted>
REPORT_RECIPIENT=info@example.com
# Application
PORT=3000
NODE_ENV=production
LOG_LEVEL=info
```
#### 4.4.2 Security Headers
```javascript
// Helmet.js configuration
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
```
### 4.5 Database Schema (Optional)
If persistent storage is needed beyond MinIO:
```sql
-- Audit Log
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_ip VARCHAR(45),
action VARCHAR(100),
resource_type VARCHAR(50),
resource_name VARCHAR(255),
status VARCHAR(20),
details JSONB
);
-- Report Schedule
CREATE TABLE report_schedule (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
cron_expression VARCHAR(100),
recipients TEXT[],
enabled BOOLEAN DEFAULT true,
last_run TIMESTAMP,
next_run TIMESTAMP
);
```
---
## 5. Implementation Plan
### 5.1 Project Structure
```
minio-webui/
├── backend/
│ ├── src/
│ │ ├── api/
│ │ │ ├── auth/
│ │ │ ├── buckets/
│ │ │ ├── users/
│ │ │ ├── policies/
│ │ │ └── reports/
│ │ ├── middleware/
│ │ │ ├── auth.middleware.js
│ │ │ ├── ipFilter.middleware.js
│ │ │ └── errorHandler.middleware.js
│ │ ├── services/
│ │ │ ├── minio.service.js
│ │ │ ├── auth.service.js
│ │ │ └── report.service.js
│ │ ├── utils/
│ │ │ ├── encryption.js
│ │ │ ├── validation.js
│ │ │ └── logger.js
│ │ ├── config/
│ │ │ └── index.js
│ │ └── app.js
│ ├── tests/
│ ├── package.json
│ └── .env.example
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Layout/
│ │ │ ├── Buckets/
│ │ │ ├── Users/
│ │ │ ├── Policies/
│ │ │ └── Reports/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── store/
│ │ ├── utils/
│ │ └── App.tsx
│ ├── public/
│ ├── package.json
│ └── tsconfig.json
├── docker/
│ ├── Dockerfile.backend
│ ├── Dockerfile.frontend
│ └── docker-compose.yml
├── nginx/
│ └── default.conf
├── scripts/
│ ├── setup.sh
│ └── deploy.sh
└── README.md
```
### 5.2 Development Phases
#### Phase 1: Foundation (Week 1-2)
- Project setup and configuration
- Authentication system
- IP restriction middleware
- Basic API structure
- Frontend scaffolding
#### Phase 2: Core Features (Week 3-4)
- Bucket management API and UI
- User creation workflow
- Policy management system
- MinIO service integration
#### Phase 3: Advanced Features (Week 5-6)
- Storage monitoring dashboard
- Report generation system
- Email integration
- Alias management
#### Phase 4: Polish & Security (Week 7-8)
- Security audit and penetration testing
- Performance optimization
- Error handling improvements
- Documentation completion
- Deployment automation
### 5.3 Testing Strategy
#### Unit Tests
- Service layer: 90% coverage
- API endpoints: 85% coverage
- Utility functions: 100% coverage
#### Integration Tests
- MinIO CLI command execution
- Authentication flow
- Report generation
#### E2E Tests
- Critical user journeys
- Bucket creation workflow
- User management flow
- Report scheduling
#### Security Tests
- OWASP Top 10 compliance
- Penetration testing
- Dependency scanning
- Static code analysis
---
## 6. Deployment & Operations
### 6.1 Deployment Strategy
#### Production Deployment
1. Use Docker containers for consistency
2. Nginx reverse proxy with SSL
3. PM2 for Node.js process management
4. Automated backup of configuration
5. Blue-green deployment for zero downtime
#### Monitoring
- Application metrics (Prometheus)
- Error tracking (Sentry)
- Uptime monitoring (UptimeRobot)
- Log aggregation (ELK stack optional)
### 6.2 Maintenance
#### Regular Tasks
- Weekly dependency updates
- Monthly security patches
- Quarterly feature reviews
- Annual penetration testing
#### Backup Strategy
- Daily configuration backup
- Weekly audit log export
- Monthly full system backup
---
## 7. Risk Analysis
### 7.1 Technical Risks
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| MinIO CLI changes | High | Low | Version pinning, compatibility tests |
| Performance degradation | Medium | Medium | Caching, pagination, monitoring |
| Security breach | High | Low | Regular audits, minimal attack surface |
### 7.2 Business Risks
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| User adoption | Medium | Low | Intuitive UI, training materials |
| Feature creep | Medium | High | Strict scope management |
| Maintenance burden | Low | Medium | Good documentation, automation |
---
## 8. Success Criteria
### 8.1 Launch Criteria
- [ ] All core features implemented
- [ ] Security audit passed
- [ ] Performance benchmarks met
- [ ] Documentation complete
- [ ] User training conducted
### 8.2 Post-Launch Metrics
- User satisfaction score > 4.5/5
- Support ticket reduction > 80%
- System uptime > 99.9%
- Average task completion < 5 minutes
---
## 9. Appendices
### 9.1 MinIO CLI Reference
Key commands used by the WebUI:
```bash
mc mb ALIAS/BUCKET
mc admin user add ALIAS USERNAME PASSWORD
mc admin policy create ALIAS POLICYNAME policy.json
mc admin policy attach ALIAS POLICYNAME --user USERNAME
mc du --json ALIAS/BUCKET
mc ls ALIAS
mc admin user list ALIAS
```
### 9.2 Security Compliance
- GDPR compliance for audit logs
- SOC 2 Type II considerations
- ISO 27001 alignment
### 9.3 Future Enhancements
- Multi-tenant support
- LDAP/AD integration
- Advanced reporting dashboard
- Mobile application
- Kubernetes operator integration
---
**Document Control:**
- **Author:** System Architect
- **Reviewers:** Security Team, DevOps Team, Product Management
- **Approval:** CTO
- **Next Review:** Q2 2025
+301
View File
@@ -0,0 +1,301 @@
# MinIO WebUI
A secure, user-friendly web interface for managing MinIO storage infrastructure. Designed specifically for non-Linux administrators to easily manage buckets, users, and monitor storage usage.
## Features
- **🪣 Bucket Management**
- Create buckets with automatic user creation
- List and monitor bucket sizes
- Delete empty buckets
- Real-time storage statistics
- **👥 User Management**
- Create users with bucket access
- Automatic policy generation
- User credential management
- Enable/disable user accounts
- **📊 Storage Monitoring**
- Real-time storage dashboard
- Visual storage distribution charts
- Weekly automated reports via email
- Export reports in CSV/JSON formats
- **🔒 Security**
- Encrypted password storage
- JWT-based authentication
- IP-based access restrictions
- Audit logging for all operations
- HTTPS support with SSL
- **🎯 Simple Interface**
- Wizard-based workflows
- Clear error messages
- Mobile-responsive design
- No Linux knowledge required
## Prerequisites
- Node.js 18+ and npm
- Docker and Docker Compose (for containerized deployment)
- MinIO Client (`mc`) installed
- Access to a MinIO server
## Quick Start
### 1. Clone the Repository
```bash
git clone <repository-url>
cd minio-webui
```
### 2. Run Setup Script
```bash
./scripts/setup.sh
```
The setup script will:
- Create `.env` configuration file
- Generate secure admin password
- Configure MinIO connection
- Install dependencies
- Build the frontend
- Optionally generate SSL certificates
**Important**: Save the generated admin password securely!
### 3. Start the Application
#### Development Mode
```bash
# Terminal 1 - Backend
cd backend
npm run dev
# Terminal 2 - Frontend
cd frontend
npm start
```
Access at: http://localhost:3000
#### Production Mode (Docker)
```bash
./scripts/deploy.sh
# Select option 1 for quick deployment
```
Access at: http://localhost
## Configuration
All configuration is managed through the `.env` file:
### Essential Settings
```env
# Admin password (bcrypt hash)
ADMIN_PASSWORD_HASH=$2b$12$...
# JWT secret for sessions
JWT_SECRET=your-secret-key
# MinIO connection
DEFAULT_MINIO_ALIAS=kopiaminio
MINIO_ENDPOINT=https://minio.example.com
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
```
### Security Settings
```env
# IP restrictions
ENABLE_IP_RESTRICTION=true
ALLOWED_IPS=192.168.1.0/24,10.0.0.5
# Session timeout (seconds)
SESSION_TIMEOUT=1800
```
### Email Settings (for reports)
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
REPORT_RECIPIENT=info@example.com
```
## Usage Guide
### Creating a Bucket with User
1. Navigate to **Buckets** page
2. Click **Create Bucket**
3. Enter bucket name (e.g., `alice-bucket`)
4. Choose to create a user (enabled by default)
5. Enter username and password
6. Click **Create**
7. Save the displayed credentials securely
This creates:
- A new bucket
- A new MinIO user
- A policy granting full access to the bucket
- Automatic policy attachment
### Monitoring Storage
1. Navigate to **Reports** page
2. View real-time storage statistics
3. See visual distribution chart
4. Click **Send Report** to email current report
5. Click **CSV** or **JSON** to export data
### Weekly Automated Reports
Reports are automatically sent every Monday at midnight (configurable via `REPORT_SCHEDULE` in `.env`).
## Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ Nginx │────▶│ Express │
│ (React) │ │ (SSL/Proxy) │ │ (API) │
└─────────────┘ └──────────────┘ └──────┬──────┘
┌─────────────┐
│ MinIO CLI │
│ (mc) │
└──────┬──────┘
┌─────────────┐
│ MinIO │
│ Server │
└─────────────┘
```
## Security Considerations
1. **Authentication**: Single admin user with bcrypt-hashed password
2. **Session Management**: JWT tokens with configurable timeout
3. **IP Restrictions**: Whitelist specific IPs or CIDR ranges
4. **HTTPS**: SSL/TLS encryption for production
5. **Audit Logging**: All operations are logged with timestamp and IP
6. **Input Validation**: Comprehensive validation on all inputs
7. **CSRF Protection**: Secure cookies and token validation
## Deployment
### Docker Deployment (Recommended)
```bash
# Quick deployment
docker-compose up -d
# Production with SSL
docker-compose --profile proxy up -d
```
### Manual Deployment
1. Build frontend: `cd frontend && npm run build`
2. Start backend: `cd backend && npm start`
3. Configure Nginx as reverse proxy
4. Set up SSL certificates
5. Configure firewall rules
### PM2 Deployment
```bash
# Install PM2
npm install -g pm2
# Start backend
cd backend
pm2 start src/app.js --name minio-webui
# Save PM2 configuration
pm2 save
pm2 startup
```
## Troubleshooting
### Common Issues
1. **"mc: command not found"**
- Install MinIO client: https://min.io/docs/minio/linux/reference/minio-mc.html
2. **"Access Denied" error**
- Check IP restrictions in `.env`
- Verify your IP is whitelisted
3. **Cannot connect to MinIO**
- Verify MinIO credentials in `.env`
- Check MinIO server is accessible
- Test with: `mc admin info YOUR_ALIAS`
4. **Email reports not sending**
- Verify SMTP settings in `.env`
- Check firewall allows SMTP port
- Enable "less secure apps" for Gmail
### Logs
- Application logs: `logs/` directory
- Docker logs: `docker-compose logs -f`
- Audit logs: `logs/audit-*.log`
## Development
### Project Structure
```
minio-webui/
├── backend/ # Express.js API
├── frontend/ # React application
├── docker/ # Docker configurations
├── nginx/ # Nginx configurations
├── scripts/ # Setup and deployment scripts
├── logs/ # Application logs
└── ssl/ # SSL certificates
```
### API Endpoints
- `POST /api/auth/login` - Admin login
- `GET /api/buckets` - List buckets
- `POST /api/buckets/with-user` - Create bucket with user
- `GET /api/reports/storage` - Get storage report
- `POST /api/reports/generate` - Send email report
### Adding Features
1. Create new API endpoint in `backend/src/api/`
2. Add service logic in `backend/src/services/`
3. Create React component in `frontend/src/components/`
4. Update routing in `frontend/src/App.tsx`
## License
MIT License - see LICENSE file for details
## Support
For issues and feature requests, please create an issue in the repository.
## Acknowledgments
- Built with React, Node.js, and Material-UI
- Uses MinIO Client (mc) for storage operations
- Inspired by the need for simple MinIO management
+43
View File
@@ -0,0 +1,43 @@
{
"name": "minio-webui-backend",
"version": "1.0.0",
"description": "MinIO WebUI Backend API",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix"
},
"keywords": ["minio", "api", "backend"],
"author": "",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"ip-range-check": "^0.2.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.8",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1"
},
"devDependencies": {
"eslint": "^8.56.0",
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^6.3.3"
},
"engines": {
"node": ">=18.0.0"
}
}
+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,
};
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Development startup script
echo "Starting MinIO WebUI in development mode..."
# Check if .env exists
if [ ! -f .env ]; then
echo "Error: .env file not found. Please run ./scripts/setup.sh first."
exit 1
fi
# Start backend in background
echo "Starting backend..."
cd backend
npm run dev &
BACKEND_PID=$!
cd ..
# Wait a moment for backend to start
sleep 2
# Start frontend
echo "Starting frontend..."
cd frontend
npm start &
FRONTEND_PID=$!
cd ..
echo ""
echo "MinIO WebUI is starting..."
echo "Backend PID: $BACKEND_PID"
echo "Frontend PID: $FRONTEND_PID"
echo ""
echo "Access the application at: http://localhost:3000"
echo "Press Ctrl+C to stop all services"
# Function to cleanup on exit
cleanup() {
echo ""
echo "Stopping services..."
kill $BACKEND_PID 2>/dev/null
kill $FRONTEND_PID 2>/dev/null
exit
}
# Set up trap to cleanup on Ctrl+C
trap cleanup INT
# Wait for processes
wait
+86
View File
@@ -0,0 +1,86 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: ../docker/Dockerfile.backend
container_name: minio-webui-backend
restart: unless-stopped
environment:
NODE_ENV: production
env_file:
- .env
ports:
- "3000:3000"
volumes:
- ./logs:/app/logs
- ~/.mc:/home/nodejs/.mc:ro
depends_on:
- redis
networks:
- minio-webui-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: ./frontend
dockerfile: ../docker/Dockerfile.frontend
container_name: minio-webui-frontend
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- backend
networks:
- minio-webui-network
redis:
image: redis:7-alpine
container_name: minio-webui-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- minio-webui-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Add nginx as reverse proxy with SSL termination
nginx-proxy:
image: nginx:alpine
container_name: minio-webui-proxy
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/proxy.conf:/etc/nginx/conf.d/default.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- frontend
networks:
- minio-webui-network
profiles:
- proxy
volumes:
redis-data:
driver: local
networks:
minio-webui-network:
driver: bridge
+37
View File
@@ -0,0 +1,37 @@
FROM node:20-alpine
# Install MinIO client
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc && \
chmod +x mc && \
mv mc /usr/local/bin/
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Create logs directory
RUN mkdir -p logs
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Change ownership
RUN chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "src/app.js"]
+39
View File
@@ -0,0 +1,39 @@
# Build stage
FROM node:20-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source files
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy custom nginx config
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
# Copy built application
COPY --from=builder /app/build /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 1001 -S nginx-user && \
adduser -S nginx-user -u 1001
# Update nginx to run as non-root
RUN touch /var/run/nginx.pid && \
chown -R nginx-user:nginx-user /var/run/nginx.pid /var/cache/nginx /var/log/nginx /etc/nginx/conf.d
# Expose ports
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+66
View File
@@ -0,0 +1,66 @@
{
"name": "minio-webui-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.15.3",
"@mui/material": "^5.15.3",
"@mui/x-data-grid": "^6.18.7",
"@types/node": "^20.10.7",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"axios": "^1.6.5",
"chart.js": "^4.4.1",
"date-fns": "^3.2.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.21.1",
"react-scripts": "5.0.1",
"typescript": "^5.3.3",
"web-vitals": "^3.5.1",
"yup": "^1.3.3",
"zustand": "^4.4.7"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.1.1"
},
"proxy": "http://localhost:3000"
}
+44
View File
@@ -0,0 +1,44 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box } from '@mui/material';
import useAuthStore from './store/authStore';
import ProtectedRoute from './components/Auth/ProtectedRoute';
import Layout from './components/Layout/Layout';
import Login from './components/Auth/Login';
import Dashboard from './components/Dashboard/Dashboard';
import Buckets from './components/Buckets/Buckets';
import Users from './components/Users/Users';
import Policies from './components/Policies/Policies';
import Reports from './components/Reports/Reports';
function App() {
const { checkAuth, isAuthenticated } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Routes>
<Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <Login />
} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/buckets" element={<Buckets />} />
<Route path="/users" element={<Users />} />
<Route path="/policies" element={<Policies />} />
<Route path="/reports" element={<Reports />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Box>
);
}
export default App;
+170
View File
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Paper,
TextField,
Button,
Typography,
Alert,
CircularProgress,
Container,
InputAdornment,
IconButton,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
LockOutlined,
} from '@mui/icons-material';
import { useForm } from 'react-hook-form';
import useAuthStore from '../../store/authStore';
import { handleApiError } from '../../services/api';
interface LoginForm {
password: string;
}
const Login: React.FC = () => {
const navigate = useNavigate();
const { login, loading } = useAuthStore();
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>();
const onSubmit = async (data: LoginForm) => {
try {
setError('');
await login(data.password);
navigate('/', { replace: true });
} catch (err) {
setError(handleApiError(err));
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Paper
elevation={3}
sx={{
padding: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<Box
sx={{
mb: 3,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
backgroundColor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 2,
}}
>
<LockOutlined sx={{ color: 'white', fontSize: 30 }} />
</Box>
<Typography component="h1" variant="h5">
MinIO WebUI
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Sign in to continue
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
{error}
</Alert>
)}
<Box
component="form"
onSubmit={handleSubmit(onSubmit)}
sx={{ width: '100%' }}
>
<TextField
{...register('password', {
required: 'Password is required',
})}
margin="normal"
required
fullWidth
name="password"
label="Admin Password"
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
autoFocus
error={!!errors.password}
helperText={errors.password?.message}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Sign In'
)}
</Button>
</Box>
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mt: 2 }}
>
Secure access for administrators only
</Typography>
</Paper>
</Box>
</Container>
);
};
export default Login;
@@ -0,0 +1,11 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import useAuthStore from '../../store/authStore';
const ProtectedRoute: React.FC = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
};
export default ProtectedRoute;
+219
View File
@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
IconButton,
Tooltip,
LinearProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
Storage as StorageIcon,
} from '@mui/icons-material';
import { format } from 'date-fns';
import api, { handleApiError } from '../../services/api';
import CreateBucketDialog from './CreateBucketDialog';
interface Bucket {
key: string;
lastModified?: string;
size?: number;
}
interface BucketSize {
name: string;
size: number;
sizeFormatted: string;
objects: number;
lastModified: string;
}
const Buckets: React.FC = () => {
const [buckets, setBuckets] = useState<BucketSize[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
useEffect(() => {
loadBuckets();
}, []);
const loadBuckets = async () => {
try {
setLoading(true);
setError('');
const response = await api.get('/buckets/sizes');
setBuckets(response.data.buckets);
} catch (err) {
setError(handleApiError(err));
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!selectedBucket) return;
try {
await api.delete(`/buckets/${selectedBucket}`);
setDeleteDialogOpen(false);
setSelectedBucket(null);
loadBuckets();
} catch (err) {
setError(handleApiError(err));
setDeleteDialogOpen(false);
}
};
const openDeleteDialog = (bucketName: string) => {
setSelectedBucket(bucketName);
setDeleteDialogOpen(true);
};
const formatDate = (dateString: string) => {
if (dateString === 'No files') return dateString;
try {
return format(new Date(dateString), 'MMM dd, yyyy HH:mm');
} catch {
return dateString;
}
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Buckets</Typography>
<Box>
<Tooltip title="Refresh">
<IconButton onClick={loadBuckets} sx={{ mr: 1 }}>
<RefreshIcon />
</IconButton>
</Tooltip>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateDialogOpen(true)}
>
Create Bucket
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
{loading ? (
<LinearProgress />
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Bucket Name</TableCell>
<TableCell align="right">Size</TableCell>
<TableCell align="right">Objects</TableCell>
<TableCell>Last Modified</TableCell>
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{buckets.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Box sx={{ py: 3 }}>
<StorageIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
<Typography variant="body1" color="text.secondary">
No buckets found. Create your first bucket to get started.
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
buckets.map((bucket) => (
<TableRow key={bucket.name}>
<TableCell>
<Typography variant="body1">{bucket.name}</Typography>
</TableCell>
<TableCell align="right">
<Chip
label={bucket.sizeFormatted}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
<TableCell align="right">{bucket.objects}</TableCell>
<TableCell>{formatDate(bucket.lastModified)}</TableCell>
<TableCell align="center">
<Tooltip title="Delete bucket">
<IconButton
size="small"
onClick={() => openDeleteDialog(bucket.name)}
color="error"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
<CreateBucketDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onSuccess={() => {
setCreateDialogOpen(false);
loadBuckets();
}}
/>
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete the bucket "{selectedBucket}"?
This action cannot be undone. The bucket must be empty to be deleted.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default Buckets;
@@ -0,0 +1,348 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Button,
Stepper,
Step,
StepLabel,
Alert,
CircularProgress,
Box,
Typography,
FormControlLabel,
Checkbox,
InputAdornment,
IconButton,
} from '@mui/material';
import {
Visibility,
VisibilityOff,
ContentCopy,
} from '@mui/icons-material';
import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import api, { handleApiError } from '../../services/api';
interface CreateBucketDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface FormData {
bucketName: string;
createUser: boolean;
username: string;
password: string;
}
const schema = yup.object({
bucketName: yup
.string()
.required('Bucket name is required')
.matches(
/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/,
'Bucket name must be lowercase, 3-63 characters, start and end with letter/number'
)
.min(3, 'Minimum 3 characters')
.max(63, 'Maximum 63 characters'),
createUser: yup.boolean(),
username: yup
.string()
.when('createUser', {
is: true,
then: (schema) =>
schema
.required('Username is required')
.matches(
/^[a-zA-Z0-9_-]+$/,
'Username can only contain letters, numbers, hyphens, and underscores'
)
.min(3, 'Minimum 3 characters')
.max(32, 'Maximum 32 characters'),
}),
password: yup
.string()
.when('createUser', {
is: true,
then: (schema) =>
schema
.required('Password is required')
.min(8, 'Minimum 8 characters')
.matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must contain uppercase, lowercase, and number'
),
}),
});
const CreateBucketDialog: React.FC<CreateBucketDialogProps> = ({
open,
onClose,
onSuccess,
}) => {
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [success, setSuccess] = useState(false);
const [credentials, setCredentials] = useState<{
username: string;
password: string;
} | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
reset,
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
createUser: true,
},
});
const createUser = watch('createUser');
const onSubmit = async (data: FormData) => {
try {
setLoading(true);
setError('');
if (data.createUser) {
// Create bucket with user (like the script)
await api.post('/buckets/with-user', {
bucketName: data.bucketName,
username: data.username,
password: data.password,
});
setCredentials({
username: data.username,
password: data.password,
});
} else {
// Create bucket only
await api.post('/buckets', {
bucketName: data.bucketName,
});
}
setSuccess(true);
setActiveStep(2);
} catch (err) {
setError(handleApiError(err));
} finally {
setLoading(false);
}
};
const handleClose = () => {
setActiveStep(0);
setError('');
setSuccess(false);
setCredentials(null);
reset();
onClose();
};
const handleSuccess = () => {
handleClose();
onSuccess();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const steps = createUser
? ['Bucket Details', 'User Credentials', 'Complete']
: ['Bucket Details', 'Complete'];
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Stepper activeStep={activeStep} sx={{ mb: 3 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</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'
}
autoFocus
/>
<FormControlLabel
control={
<Checkbox {...register('createUser')} defaultChecked />
}
label="Create user with full access to this bucket"
sx={{ mt: 2, mb: 2 }}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Button onClick={handleClose} sx={{ mr: 1 }}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => setActiveStep(1)}
disabled={!!errors.bucketName}
>
Next
</Button>
</Box>
</>
)}
{activeStep === 1 && createUser && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create a user with full access to the bucket. The user will be
able to read, write, and delete objects in this bucket.
</Typography>
<TextField
{...register('username')}
label="Username"
fullWidth
margin="normal"
error={!!errors.username}
helperText={errors.username?.message}
autoFocus
/>
<TextField
{...register('password')}
label="Password"
type={showPassword ? 'text' : 'password'}
fullWidth
margin="normal"
error={!!errors.password}
helperText={
errors.password?.message ||
'Min 8 chars with uppercase, lowercase, and number'
}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button onClick={() => setActiveStep(0)}>Back</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Create'}
</Button>
</Box>
</>
)}
{activeStep === 1 && !createUser && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button onClick={() => setActiveStep(0)}>Back</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Create Bucket'}
</Button>
</Box>
)}
{activeStep === 2 && success && (
<>
<Alert severity="success" sx={{ mb: 2 }}>
Bucket created successfully!
</Alert>
{credentials && (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
User Credentials
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Save these credentials securely. They won't be shown again.
</Typography>
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">
<strong>Username:</strong> {credentials.username}
</Typography>
<IconButton
size="small"
onClick={() => copyToClipboard(credentials.username)}
>
<ContentCopy fontSize="small" />
</IconButton>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">
<strong>Password:</strong> {credentials.password}
</Typography>
<IconButton
size="small"
onClick={() => copyToClipboard(credentials.password)}
>
<ContentCopy fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Button variant="contained" onClick={handleSuccess}>
Done
</Button>
</Box>
</>
)}
</form>
</Box>
</DialogContent>
</Dialog>
);
};
export default CreateBucketDialog;
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Grid,
Card,
CardContent,
Typography,
LinearProgress,
Alert,
Paper,
} from '@mui/material';
import {
Storage as StorageIcon,
People as PeopleIcon,
Folder as FolderIcon,
Speed as SpeedIcon,
} from '@mui/icons-material';
import api, { handleApiError } from '../../services/api';
interface DashboardStats {
totalBuckets: number;
totalUsers: number;
totalSize: number;
totalSizeFormatted: string;
}
interface StatCardProps {
title: string;
value: string | number;
icon: React.ReactElement;
color: string;
}
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: '50%',
backgroundColor: `${color}.light`,
color: `${color}.main`,
mr: 2,
}}
>
{icon}
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography color="textSecondary" variant="body2">
{title}
</Typography>
<Typography variant="h4">{value}</Typography>
</Box>
</Box>
</CardContent>
</Card>
);
const Dashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
setError('');
const [bucketsRes, usersRes, sizesRes] = await Promise.all([
api.get('/buckets'),
api.get('/users'),
api.get('/buckets/sizes'),
]);
setStats({
totalBuckets: bucketsRes.data.count,
totalUsers: usersRes.data.count,
totalSize: sizesRes.data.totalSize,
totalSizeFormatted: sizesRes.data.totalSizeFormatted,
});
} catch (err) {
setError(handleApiError(err));
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Box sx={{ width: '100%', mt: 4 }}>
<LinearProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Welcome to MinIO WebUI. Monitor your storage infrastructure at a glance.
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="Total Buckets"
value={stats?.totalBuckets || 0}
icon={<FolderIcon />}
color="primary"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="Total Users"
value={stats?.totalUsers || 0}
icon={<PeopleIcon />}
color="secondary"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="Total Storage"
value={stats?.totalSizeFormatted || '0 B'}
icon={<StorageIcon />}
color="success"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="System Status"
value="Online"
icon={<SpeedIcon />}
color="info"
/>
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<Typography variant="h5" gutterBottom>
Quick Actions
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Getting Started
</Typography>
<Typography variant="body2" color="text.secondary">
1. Create a new bucket from the Buckets page
<br />
2. Add users and assign them to buckets
<br />
3. Configure policies for fine-grained access control
<br />
4. Monitor storage usage with weekly reports
</Typography>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
System Information
</Typography>
<Typography variant="body2" color="text.secondary">
MinIO WebUI provides a simple interface for managing your MinIO
storage infrastructure. All operations are performed securely
through the MinIO CLI.
</Typography>
</Paper>
</Grid>
</Grid>
</Box>
</Box>
);
};
export default Dashboard;
+221
View File
@@ -0,0 +1,221 @@
import React, { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
Typography,
Divider,
IconButton,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Avatar,
Menu,
MenuItem,
Tooltip,
} from '@mui/material';
import {
Menu as MenuIcon,
ChevronLeft as ChevronLeftIcon,
Dashboard as DashboardIcon,
Storage as StorageIcon,
People as PeopleIcon,
Policy as PolicyIcon,
Assessment as AssessmentIcon,
Logout as LogoutIcon,
AccountCircle as AccountCircleIcon,
} from '@mui/icons-material';
import useAuthStore from '../../store/authStore';
const drawerWidth = 240;
interface NavItem {
text: string;
icon: React.ReactElement;
path: string;
}
const navItems: NavItem[] = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies' },
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports' },
];
const Layout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const [open, setOpen] = useState(true);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleDrawerToggle = () => {
setOpen(!open);
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<Box sx={{ display: 'flex', width: '100%' }}>
<AppBar
position="fixed"
sx={{
width: `calc(100% - ${open ? drawerWidth : 0}px)`,
ml: `${open ? drawerWidth : 0}px`,
transition: (theme) =>
theme.transitions.create(['margin', 'width'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="toggle drawer"
onClick={handleDrawerToggle}
edge="start"
sx={{ mr: 2 }}
>
{open ? <ChevronLeftIcon /> : <MenuIcon />}
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
MinIO WebUI
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title="Account">
<IconButton
onClick={handleMenuOpen}
size="small"
sx={{ ml: 2 }}
aria-controls={Boolean(anchorEl) ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={Boolean(anchorEl) ? 'true' : undefined}
>
<Avatar sx={{ width: 32, height: 32 }}>
<AccountCircleIcon />
</Avatar>
</IconButton>
</Tooltip>
</Box>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={Boolean(anchorEl)}
onClose={handleMenuClose}
onClick={handleMenuClose}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem disabled>
<Typography variant="body2">
Logged in as {user?.role || 'Admin'}
</Typography>
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon fontSize="small" />
</ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
variant="persistent"
anchor="left"
open={open}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
padding: (theme) => theme.spacing(0, 1),
...((theme) => theme.mixins.toolbar),
justifyContent: 'center',
}}
>
<Typography variant="h6" noWrap component="div">
MinIO Manager
</Typography>
</Box>
<Divider />
<List>
{navItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
padding: 3,
transition: (theme) =>
theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: open ? 0 : `-${drawerWidth}px`,
mt: 8,
}}
>
<Outlet />
</Box>
</Box>
);
};
export default Layout;
@@ -0,0 +1,17 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
const Policies: React.FC = () => {
return (
<Box>
<Typography variant="h4" gutterBottom>
Policies
</Typography>
<Typography variant="body1" color="text.secondary">
Policy management functionality coming soon...
</Typography>
</Box>
);
};
export default Policies;
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Button,
Grid,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
LinearProgress,
Alert,
Chip,
IconButton,
Tooltip,
Snackbar,
} from '@mui/material';
import {
Email as EmailIcon,
Download as DownloadIcon,
Refresh as RefreshIcon,
Schedule as ScheduleIcon,
} from '@mui/icons-material';
import { Pie } from 'react-chartjs-2';
import {
Chart as ChartJS,
ArcElement,
Tooltip as ChartTooltip,
Legend,
} from 'chart.js';
import api, { handleApiError } from '../../services/api';
ChartJS.register(ArcElement, ChartTooltip, Legend);
interface StorageReport {
date: string;
summary: {
totalBuckets: number;
totalUsers: number;
totalSize: number;
totalSizeFormatted: string;
};
buckets: Array<{
name: string;
size: number;
sizeFormatted: string;
objects: number;
lastModified: string;
}>;
users: string[];
}
interface ScheduleInfo {
enabled: boolean;
schedule: string;
nextRun: string | null;
recipients: string | string[];
}
const Reports: React.FC = () => {
const [report, setReport] = useState<StorageReport | null>(null);
const [scheduleInfo, setScheduleInfo] = useState<ScheduleInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setError('');
const [reportRes, scheduleRes] = await Promise.all([
api.get('/reports/storage'),
api.get('/reports/schedule'),
]);
setReport(reportRes.data);
setScheduleInfo(scheduleRes.data);
} catch (err) {
setError(handleApiError(err));
} finally {
setLoading(false);
}
};
const sendReport = async () => {
try {
await api.post('/reports/generate');
setSuccessMessage('Report sent successfully!');
} catch (err) {
setError(handleApiError(err));
}
};
const downloadReport = async (format: 'csv' | 'json') => {
try {
const response = await api.get(`/reports/storage/export?format=${format}`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`minio-storage-report-${report?.date}.${format}`
);
document.body.appendChild(link);
link.click();
link.remove();
} catch (err) {
setError(handleApiError(err));
}
};
if (loading) {
return <LinearProgress />;
}
if (error) {
return (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
);
}
if (!report) {
return null;
}
const chartData = {
labels: report.buckets.map((b) => b.name),
datasets: [
{
data: report.buckets.map((b) => b.size),
backgroundColor: [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40',
'#FF6384',
'#C9CBCF',
],
borderWidth: 1,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right' as const,
},
tooltip: {
callbacks: {
label: (context: any) => {
const bucket = report.buckets[context.dataIndex];
return `${bucket.name}: ${bucket.sizeFormatted}`;
},
},
},
},
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Storage Reports</Typography>
<Box>
<Tooltip title="Refresh">
<IconButton onClick={loadData} sx={{ mr: 1 }}>
<RefreshIcon />
</IconButton>
</Tooltip>
<Button
variant="outlined"
startIcon={<EmailIcon />}
onClick={sendReport}
sx={{ mr: 1 }}
>
Send Report
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => downloadReport('csv')}
sx={{ mr: 1 }}
>
CSV
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => downloadReport('json')}
>
JSON
</Button>
</Box>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Grid container spacing={3}>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Storage
</Typography>
<Typography variant="h5">
{report.summary.totalSizeFormatted}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Buckets
</Typography>
<Typography variant="h5">
{report.summary.totalBuckets}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Users
</Typography>
<Typography variant="h5">{report.summary.totalUsers}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ mt: 3 }}>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Bucket Name</TableCell>
<TableCell align="right">Size</TableCell>
<TableCell align="right">Objects</TableCell>
<TableCell>Last Modified</TableCell>
</TableRow>
</TableHead>
<TableBody>
{report.buckets.map((bucket) => (
<TableRow key={bucket.name}>
<TableCell>{bucket.name}</TableCell>
<TableCell align="right">
<Chip
label={bucket.sizeFormatted}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell align="right">{bucket.objects}</TableCell>
<TableCell>{bucket.lastModified}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Storage Distribution
</Typography>
<Box sx={{ height: 300 }}>
{report.buckets.length > 0 ? (
<Pie data={chartData} options={chartOptions} />
) : (
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mt: 10 }}
>
No data to display
</Typography>
)}
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<ScheduleIcon sx={{ mr: 1 }} />
<Typography variant="h6">Report Schedule</Typography>
</Box>
{scheduleInfo && (
<>
<Typography variant="body2" paragraph>
<strong>Status:</strong>{' '}
<Chip
label={scheduleInfo.enabled ? 'Enabled' : 'Disabled'}
size="small"
color={scheduleInfo.enabled ? 'success' : 'default'}
/>
</Typography>
<Typography variant="body2" paragraph>
<strong>Schedule:</strong> {scheduleInfo.schedule}
</Typography>
<Typography variant="body2" paragraph>
<strong>Recipients:</strong>{' '}
{Array.isArray(scheduleInfo.recipients)
? scheduleInfo.recipients.join(', ')
: scheduleInfo.recipients}
</Typography>
{scheduleInfo.nextRun && (
<Typography variant="body2">
<strong>Next Run:</strong>{' '}
{new Date(scheduleInfo.nextRun).toLocaleString()}
</Typography>
)}
</>
)}
</Paper>
</Grid>
</Grid>
<Snackbar
open={!!successMessage}
autoHideDuration={6000}
onClose={() => setSuccessMessage('')}
message={successMessage}
/>
</Box>
);
};
export default Reports;
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
const Users: React.FC = () => {
return (
<Box>
<Typography variant="h4" gutterBottom>
Users
</Typography>
<Typography variant="body1" color="text.secondary">
User management functionality coming soon...
</Typography>
</Box>
);
};
export default Users;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import App from './App';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: 'Roboto, Arial, sans-serif',
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
},
},
},
},
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);
+59
View File
@@ -0,0 +1,59 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
// Create axios instance
const api: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_URL || '/api',
timeout: 30000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
api.interceptors.request.use(
(config) => {
// You can add auth token here if needed
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError) => {
if (error.response?.status === 401) {
// Redirect to login on 401
window.location.href = '/login';
localStorage.removeItem('token');
} else if (error.response?.status === 403) {
// Handle forbidden access
console.error('Access forbidden:', error.response.data);
}
return Promise.reject(error);
}
);
export default api;
// API Error handler
export const handleApiError = (error: any): string => {
if (error.response?.data?.message) {
return error.response.data.message;
} else if (error.response?.data?.error) {
return error.response.data.error;
} else if (error.message) {
return error.message;
}
return 'An unexpected error occurred';
};
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import api from '../services/api';
interface User {
role: string;
loginTime: string;
}
interface AuthState {
isAuthenticated: boolean;
user: User | null;
loading: boolean;
login: (password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isAuthenticated: false,
user: null,
loading: false,
login: async (password: string) => {
set({ loading: true });
try {
const response = await api.post('/auth/login', { password });
const { token, role, loginTime } = response.data;
// Store token if needed
if (token) {
localStorage.setItem('token', token);
}
set({
isAuthenticated: true,
user: { role, loginTime },
loading: false,
});
} catch (error) {
set({ loading: false });
throw error;
}
},
logout: async () => {
try {
await api.post('/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
localStorage.removeItem('token');
set({
isAuthenticated: false,
user: null,
});
}
},
checkAuth: async () => {
try {
const response = await api.get('/auth/status');
if (response.data.authenticated) {
set({
isAuthenticated: true,
user: {
role: response.data.role,
loginTime: response.data.loginTime,
},
});
} else {
set({
isAuthenticated: false,
user: null,
});
}
} catch (error) {
set({
isAuthenticated: false,
user: null,
});
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ isAuthenticated: state.isAuthenticated }),
}
)
);
export default useAuthStore;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
+45
View File
@@ -0,0 +1,45 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self';" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/x-font-ttf font/opentype image/svg+xml image/x-icon;
# API proxy
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;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# React app
location / {
try_files $uri /index.html;
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+64
View File
@@ -0,0 +1,64 @@
# Redirect HTTP to HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name _;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self';" always;
# IP restrictions (adjust as needed)
# allow 192.168.1.0/24;
# allow 10.0.0.0/8;
# deny all;
# Proxy to frontend container
location / {
proxy_pass http://frontend;
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;
}
# API proxy with longer timeouts
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;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
+214
View File
@@ -0,0 +1,214 @@
#!/bin/bash
set -e
echo "======================================"
echo "MinIO WebUI Deployment Script"
echo "======================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check if running as root (not recommended)
if [ "$EUID" -eq 0 ]; then
echo -e "${YELLOW}Warning: Running as root is not recommended${NC}"
read -p "Continue anyway? (y/N): " CONTINUE_ROOT
if [[ ! "$CONTINUE_ROOT" =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Check prerequisites
check_command() {
if ! command -v $1 &> /dev/null; then
echo -e "${RED}Error: $1 is required but not installed.${NC}"
exit 1
fi
}
echo "Checking prerequisites..."
check_command docker
check_command docker-compose
check_command mc
echo -e "${GREEN}✓ All prerequisites installed${NC}"
# Check .env file
if [ ! -f .env ]; then
echo -e "${RED}Error: .env file not found. Please run setup.sh first.${NC}"
exit 1
fi
# Deployment options
echo ""
echo "Deployment Options"
echo "=================="
echo "1. Quick deployment (docker-compose)"
echo "2. Production deployment with SSL"
echo "3. Update existing deployment"
echo "4. Stop deployment"
echo ""
read -p "Select option (1-4): " DEPLOY_OPTION
case $DEPLOY_OPTION in
1)
echo ""
echo "Starting quick deployment..."
echo "============================"
# Build and start containers
docker-compose build
docker-compose up -d
echo ""
echo -e "${GREEN}✓ Deployment completed!${NC}"
echo ""
echo "Services:"
echo "- Frontend: http://localhost"
echo "- Backend API: http://localhost:3000"
echo ""
docker-compose ps
;;
2)
echo ""
echo "Production Deployment with SSL"
echo "=============================="
# Check for SSL certificates
if [ ! -f ssl/cert.pem ] || [ ! -f ssl/key.pem ]; then
echo -e "${YELLOW}SSL certificates not found.${NC}"
echo "Options:"
echo "1. Use Let's Encrypt (recommended for production)"
echo "2. Use existing certificates"
echo "3. Generate self-signed certificate (development only)"
read -p "Select option (1-3): " SSL_OPTION
case $SSL_OPTION in
1)
read -p "Enter your domain name: " DOMAIN
read -p "Enter your email: " EMAIL
# Install certbot if not present
if ! command -v certbot &> /dev/null; then
echo "Installing certbot..."
if [[ "$OSTYPE" == "darwin"* ]]; then
brew install certbot
else
sudo apt-get update
sudo apt-get install -y certbot
fi
fi
# Generate Let's Encrypt certificate
sudo certbot certonly --standalone \
-d $DOMAIN \
--non-interactive \
--agree-tos \
--email $EMAIL
# Copy certificates
sudo cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem ssl/cert.pem
sudo cp /etc/letsencrypt/live/$DOMAIN/privkey.pem ssl/key.pem
sudo chown $(whoami):$(whoami) ssl/*.pem
;;
2)
read -p "Path to certificate file: " CERT_PATH
read -p "Path to private key file: " KEY_PATH
cp $CERT_PATH ssl/cert.pem
cp $KEY_PATH ssl/key.pem
;;
3)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
;;
esac
fi
# Build and start with proxy profile
echo "Building containers..."
docker-compose build
echo "Starting services..."
docker-compose --profile proxy up -d
echo ""
echo -e "${GREEN}✓ Production deployment completed!${NC}"
echo ""
echo "Services:"
echo "- HTTPS: https://localhost"
echo "- HTTP (redirects to HTTPS): http://localhost"
echo ""
docker-compose ps
;;
3)
echo ""
echo "Updating deployment..."
echo "====================="
# Pull latest changes
read -p "Pull latest changes from git? (y/N): " PULL_GIT
if [[ "$PULL_GIT" =~ ^[Yy]$ ]]; then
git pull
fi
# Rebuild containers
echo "Rebuilding containers..."
docker-compose build
# Restart services
echo "Restarting services..."
docker-compose down
docker-compose up -d
echo ""
echo -e "${GREEN}✓ Update completed!${NC}"
docker-compose ps
;;
4)
echo ""
echo "Stopping deployment..."
echo "===================="
docker-compose down
echo ""
echo -e "${GREEN}✓ Services stopped${NC}"
;;
*)
echo -e "${RED}Invalid option${NC}"
exit 1
;;
esac
# Show logs option
echo ""
read -p "View logs? (y/N): " VIEW_LOGS
if [[ "$VIEW_LOGS" =~ ^[Yy]$ ]]; then
echo ""
echo "Showing logs (Ctrl+C to exit)..."
echo "================================"
docker-compose logs -f
fi
echo ""
echo "Deployment script completed!"
echo ""
echo "Useful commands:"
echo "- View logs: docker-compose logs -f"
echo "- View specific service: docker-compose logs -f backend"
echo "- Restart services: docker-compose restart"
echo "- Stop services: docker-compose down"
echo "- Remove everything: docker-compose down -v"
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
set -e
echo "======================================"
echo "MinIO WebUI Setup Script"
echo "======================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check prerequisites
check_command() {
if ! command -v $1 &> /dev/null; then
echo -e "${RED}Error: $1 is required but not installed.${NC}"
exit 1
fi
}
echo "Checking prerequisites..."
check_command node
check_command npm
check_command mc
echo -e "${GREEN}✓ All prerequisites installed${NC}"
echo ""
# Create .env file from template
if [ ! -f .env ]; then
echo "Creating .env file..."
cp .env.example .env
echo -e "${GREEN}✓ Created .env file${NC}"
else
echo -e "${YELLOW}! .env file already exists${NC}"
fi
# Generate secure passwords
echo ""
echo "Generating secure credentials..."
ADMIN_PASSWORD=$(openssl rand -base64 24)
JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')
echo ""
echo -e "${YELLOW}Generated Admin Password:${NC} $ADMIN_PASSWORD"
echo -e "${YELLOW}Please save this password securely!${NC}"
echo ""
# Hash the password using Node.js
echo "Hashing admin password..."
ADMIN_HASH=$(node -e "
const bcrypt = require('bcrypt');
bcrypt.hash('$ADMIN_PASSWORD', 12).then(hash => console.log(hash));
" 2>/dev/null)
if [ -z "$ADMIN_HASH" ]; then
# If bcrypt is not available, install it temporarily
echo "Installing bcrypt temporarily..."
npm install bcrypt --no-save
ADMIN_HASH=$(node -e "
const bcrypt = require('bcrypt');
bcrypt.hash('$ADMIN_PASSWORD', 12).then(hash => console.log(hash));
")
npm uninstall bcrypt --no-save
fi
# Update .env file
echo "Updating .env file..."
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
sed -i '' "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH=$ADMIN_HASH|" .env
sed -i '' "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
else
# Linux
sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH=$ADMIN_HASH|" .env
sed -i "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
fi
echo -e "${GREEN}✓ Updated .env file with secure credentials${NC}"
# Configure MinIO connection
echo ""
echo "MinIO Configuration"
echo "=================="
read -p "Enter MinIO alias name (default: kopiaminio): " MINIO_ALIAS
MINIO_ALIAS=${MINIO_ALIAS:-kopiaminio}
read -p "Enter MinIO endpoint (e.g., https://minio.example.com): " MINIO_ENDPOINT
read -p "Enter MinIO access key: " MINIO_ACCESS_KEY
read -s -p "Enter MinIO secret key: " MINIO_SECRET_KEY
echo ""
# Update .env with MinIO settings
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|DEFAULT_MINIO_ALIAS=.*|DEFAULT_MINIO_ALIAS=$MINIO_ALIAS|" .env
sed -i '' "s|MINIO_ENDPOINT=.*|MINIO_ENDPOINT=$MINIO_ENDPOINT|" .env
sed -i '' "s|MINIO_ACCESS_KEY=.*|MINIO_ACCESS_KEY=$MINIO_ACCESS_KEY|" .env
sed -i '' "s|MINIO_SECRET_KEY=.*|MINIO_SECRET_KEY=$MINIO_SECRET_KEY|" .env
else
sed -i "s|DEFAULT_MINIO_ALIAS=.*|DEFAULT_MINIO_ALIAS=$MINIO_ALIAS|" .env
sed -i "s|MINIO_ENDPOINT=.*|MINIO_ENDPOINT=$MINIO_ENDPOINT|" .env
sed -i "s|MINIO_ACCESS_KEY=.*|MINIO_ACCESS_KEY=$MINIO_ACCESS_KEY|" .env
sed -i "s|MINIO_SECRET_KEY=.*|MINIO_SECRET_KEY=$MINIO_SECRET_KEY|" .env
fi
# Configure IP restrictions
echo ""
echo "IP Restriction Configuration"
echo "==========================="
read -p "Enable IP restrictions? (y/N): " ENABLE_IP
if [[ "$ENABLE_IP" =~ ^[Yy]$ ]]; then
read -p "Enter allowed IPs (comma-separated, e.g., 192.168.1.0/24,10.0.0.5): " ALLOWED_IPS
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|ENABLE_IP_RESTRICTION=.*|ENABLE_IP_RESTRICTION=true|" .env
sed -i '' "s|ALLOWED_IPS=.*|ALLOWED_IPS=$ALLOWED_IPS|" .env
else
sed -i "s|ENABLE_IP_RESTRICTION=.*|ENABLE_IP_RESTRICTION=true|" .env
sed -i "s|ALLOWED_IPS=.*|ALLOWED_IPS=$ALLOWED_IPS|" .env
fi
else
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|ENABLE_IP_RESTRICTION=.*|ENABLE_IP_RESTRICTION=false|" .env
else
sed -i "s|ENABLE_IP_RESTRICTION=.*|ENABLE_IP_RESTRICTION=false|" .env
fi
fi
# Configure email (optional)
echo ""
echo "Email Configuration (for automated reports)"
echo "=========================================="
read -p "Configure email for reports? (y/N): " CONFIGURE_EMAIL
if [[ "$CONFIGURE_EMAIL" =~ ^[Yy]$ ]]; then
read -p "SMTP Host: " SMTP_HOST
read -p "SMTP Port (default: 587): " SMTP_PORT
SMTP_PORT=${SMTP_PORT:-587}
read -p "SMTP User: " SMTP_USER
read -s -p "SMTP Password: " SMTP_PASS
echo ""
read -p "Report recipient email: " REPORT_RECIPIENT
read -p "Report sender email: " REPORT_SENDER
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|SMTP_HOST=.*|SMTP_HOST=$SMTP_HOST|" .env
sed -i '' "s|SMTP_PORT=.*|SMTP_PORT=$SMTP_PORT|" .env
sed -i '' "s|SMTP_USER=.*|SMTP_USER=$SMTP_USER|" .env
sed -i '' "s|SMTP_PASS=.*|SMTP_PASS=$SMTP_PASS|" .env
sed -i '' "s|REPORT_RECIPIENT=.*|REPORT_RECIPIENT=$REPORT_RECIPIENT|" .env
sed -i '' "s|REPORT_SENDER=.*|REPORT_SENDER=$REPORT_SENDER|" .env
else
sed -i "s|SMTP_HOST=.*|SMTP_HOST=$SMTP_HOST|" .env
sed -i "s|SMTP_PORT=.*|SMTP_PORT=$SMTP_PORT|" .env
sed -i "s|SMTP_USER=.*|SMTP_USER=$SMTP_USER|" .env
sed -i "s|SMTP_PASS=.*|SMTP_PASS=$SMTP_PASS|" .env
sed -i "s|REPORT_RECIPIENT=.*|REPORT_RECIPIENT=$REPORT_RECIPIENT|" .env
sed -i "s|REPORT_SENDER=.*|REPORT_SENDER=$REPORT_SENDER|" .env
fi
fi
# Install dependencies
echo ""
echo "Installing dependencies..."
echo "========================="
# Backend dependencies
echo "Installing backend dependencies..."
cd backend
npm ci
cd ..
echo -e "${GREEN}✓ Backend dependencies installed${NC}"
# Frontend dependencies
echo "Installing frontend dependencies..."
cd frontend
npm ci
echo -e "${GREEN}✓ Frontend dependencies installed${NC}"
# Build frontend
echo ""
echo "Building frontend..."
npm run build
cd ..
echo -e "${GREEN}✓ Frontend built successfully${NC}"
# Create necessary directories
echo ""
echo "Creating directories..."
mkdir -p logs temp ssl
echo -e "${GREEN}✓ Directories created${NC}"
# Generate self-signed SSL certificate for development
echo ""
read -p "Generate self-signed SSL certificate for development? (y/N): " GEN_SSL
if [[ "$GEN_SSL" =~ ^[Yy]$ ]]; then
echo "Generating self-signed SSL certificate..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
echo -e "${GREEN}✓ SSL certificate generated${NC}"
fi
echo ""
echo "======================================"
echo -e "${GREEN}Setup completed successfully!${NC}"
echo "======================================"
echo ""
echo "Important information:"
echo "---------------------"
echo -e "Admin Password: ${YELLOW}$ADMIN_PASSWORD${NC}"
echo -e "Please save this password securely!"
echo ""
echo "Next steps:"
echo "-----------"
echo "1. Review and adjust settings in .env file"
echo "2. Start the application:"
echo " - Development: npm run dev (in both backend and frontend folders)"
echo " - Production: docker-compose up -d"
echo "3. Access the WebUI at http://localhost:3000 (or configured port)"
echo ""
echo "For more information, see the README.md file."