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:
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+214
@@ -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"
|
||||
Executable
+224
@@ -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."
|
||||
Reference in New Issue
Block a user