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