commit bb44b143ecc9c874e33c758ac9f5fd68b151b919 Author: paul Date: Tue Jul 22 16:29:53 2025 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..490c380 --- /dev/null +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fe045e --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Implementation-Guide.md b/Implementation-Guide.md new file mode 100644 index 0000000..686f4fc --- /dev/null +++ b/Implementation-Guide.md @@ -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 ( + + Create New Bucket with User + + + Bucket Details + User Credentials + Complete + + + {error && {error}} + +
+ {activeStep === 0 && ( + <> + + + + )} + + {activeStep === 1 && ( + <> + + + + + + )} + + {activeStep === 2 && ( + + Bucket and user created successfully! + + )} + +
+
+ ); +}; +``` + +#### 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([]); + 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 ( + + + Storage Overview + + + {loading ? ( + + ) : ( + <> + + + + + Total Storage Used + + + {reportService.formatBytes(totalSize)} + + + + + + + Number of Buckets + + + {bucketSizes.length} + + + + + + + + + + Bucket Details + + + + + Bucket Name + Size + Objects + Last Modified + + + + {bucketSizes.map((bucket) => ( + + {bucket.name} + + + + {bucket.objectCount} + {bucket.lastModified} + + ))} + +
+
+
+ + + + + Storage Distribution + + + + + + +
+ + )} +
+ ); +}; +``` + +### 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. \ No newline at end of file diff --git a/MinIO-WebUI-PRD.md b/MinIO-WebUI-PRD.md new file mode 100644 index 0000000..4f04461 --- /dev/null +++ b/MinIO-WebUI-PRD.md @@ -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= +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= +MINIO_SECRET_KEY= + +# Email Configuration (for reports) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=kopiabackup@example.com +SMTP_PASS= +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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..35e8311 --- /dev/null +++ b/README.md @@ -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 +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 \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..1935468 --- /dev/null +++ b/backend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/src/api/auth/index.js b/backend/src/api/auth/index.js new file mode 100644 index 0000000..e4134ef --- /dev/null +++ b/backend/src/api/auth/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/api/buckets/index.js b/backend/src/api/buckets/index.js new file mode 100644 index 0000000..1655f15 --- /dev/null +++ b/backend/src/api/buckets/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/api/policies/index.js b/backend/src/api/policies/index.js new file mode 100644 index 0000000..4f071d3 --- /dev/null +++ b/backend/src/api/policies/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/api/reports/index.js b/backend/src/api/reports/index.js new file mode 100644 index 0000000..08d9703 --- /dev/null +++ b/backend/src/api/reports/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/api/users/index.js b/backend/src/api/users/index.js new file mode 100644 index 0000000..330eeef --- /dev/null +++ b/backend/src/api/users/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..0a50f13 --- /dev/null +++ b/backend/src/app.js @@ -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; \ No newline at end of file diff --git a/backend/src/config/index.js b/backend/src/config/index.js new file mode 100644 index 0000000..2519157 --- /dev/null +++ b/backend/src/config/index.js @@ -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; \ No newline at end of file diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js new file mode 100644 index 0000000..4962b22 --- /dev/null +++ b/backend/src/middleware/auth.middleware.js @@ -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, +}; \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.middleware.js b/backend/src/middleware/errorHandler.middleware.js new file mode 100644 index 0000000..163c601 --- /dev/null +++ b/backend/src/middleware/errorHandler.middleware.js @@ -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; \ No newline at end of file diff --git a/backend/src/middleware/ipFilter.middleware.js b/backend/src/middleware/ipFilter.middleware.js new file mode 100644 index 0000000..3428a49 --- /dev/null +++ b/backend/src/middleware/ipFilter.middleware.js @@ -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; \ No newline at end of file diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js new file mode 100644 index 0000000..07043e2 --- /dev/null +++ b/backend/src/services/auth.service.js @@ -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(); \ No newline at end of file diff --git a/backend/src/services/minio.service.js b/backend/src/services/minio.service.js new file mode 100644 index 0000000..e6b8bef --- /dev/null +++ b/backend/src/services/minio.service.js @@ -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; \ No newline at end of file diff --git a/backend/src/services/report.service.js b/backend/src/services/report.service.js new file mode 100644 index 0000000..78703ab --- /dev/null +++ b/backend/src/services/report.service.js @@ -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 = ` + + + + + + +

MinIO Speicherauswertung

+

Datum: ${report.date}

+ +
+

Zusammenfassung

+
    +
  • Anzahl Buckets: ${report.summary.totalBuckets}
  • +
  • Anzahl Benutzer: ${report.summary.totalUsers}
  • +
  • Gesamtspeicher: ${report.summary.totalSizeFormatted}
  • +
+
+ +

Bucket-Details

+ + + + + + + + + + + ${report.buckets.map(bucket => ` + + + + + + + `).join('')} + +
Bucket NameGrößeObjekteLetzte Änderung
${bucket.name}${bucket.sizeFormatted}${bucket.objects}${bucket.lastModified}
+ +

Benutzer

+
    + ${report.users.map(user => `
  • ${user}
  • `).join('')} +
+ + + + + `; + + 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(); \ No newline at end of file diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js new file mode 100644 index 0000000..a8984ef --- /dev/null +++ b/backend/src/utils/logger.js @@ -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, +}; \ No newline at end of file diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..8ce59e6 --- /dev/null +++ b/dev.sh @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e4d78dd --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..cf2076d --- /dev/null +++ b/docker/Dockerfile.backend @@ -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"] \ No newline at end of file diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000..e4e819c --- /dev/null +++ b/docker/Dockerfile.frontend @@ -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;"] \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5141e9e --- /dev/null +++ b/frontend/package.json @@ -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" +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1dd6af3 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + : + } /> + + }> + }> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/components/Auth/Login.tsx b/frontend/src/components/Auth/Login.tsx new file mode 100644 index 0000000..7409a38 --- /dev/null +++ b/frontend/src/components/Auth/Login.tsx @@ -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(); + + const onSubmit = async (data: LoginForm) => { + try { + setError(''); + await login(data.password); + navigate('/', { replace: true }); + } catch (err) { + setError(handleApiError(err)); + } + }; + + return ( + + + + + + + + + MinIO WebUI + + + Sign in to continue + + + + {error && ( + + {error} + + )} + + + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + + + + + + Secure access for administrators only + + + + + ); +}; + +export default Login; \ No newline at end of file diff --git a/frontend/src/components/Auth/ProtectedRoute.tsx b/frontend/src/components/Auth/ProtectedRoute.tsx new file mode 100644 index 0000000..981b97c --- /dev/null +++ b/frontend/src/components/Auth/ProtectedRoute.tsx @@ -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 ? : ; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/Buckets/Buckets.tsx b/frontend/src/components/Buckets/Buckets.tsx new file mode 100644 index 0000000..38ef1c1 --- /dev/null +++ b/frontend/src/components/Buckets/Buckets.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedBucket, setSelectedBucket] = useState(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 ( + + + Buckets + + + + + + + + + + + {error && ( + setError('')}> + {error} + + )} + + {loading ? ( + + ) : ( + + + + + Bucket Name + Size + Objects + Last Modified + Actions + + + + {buckets.length === 0 ? ( + + + + + + No buckets found. Create your first bucket to get started. + + + + + ) : ( + buckets.map((bucket) => ( + + + {bucket.name} + + + + + {bucket.objects} + {formatDate(bucket.lastModified)} + + + openDeleteDialog(bucket.name)} + color="error" + > + + + + + + )) + )} + +
+
+ )} + + setCreateDialogOpen(false)} + onSuccess={() => { + setCreateDialogOpen(false); + loadBuckets(); + }} + /> + + setDeleteDialogOpen(false)} + > + Delete Bucket + + + Are you sure you want to delete the bucket "{selectedBucket}"? + This action cannot be undone. The bucket must be empty to be deleted. + + + + + + + +
+ ); +}; + +export default Buckets; \ No newline at end of file diff --git a/frontend/src/components/Buckets/CreateBucketDialog.tsx b/frontend/src/components/Buckets/CreateBucketDialog.tsx new file mode 100644 index 0000000..a2c6131 --- /dev/null +++ b/frontend/src/components/Buckets/CreateBucketDialog.tsx @@ -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 = ({ + 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({ + 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 ( + + Create New Bucket + + + + {steps.map((label) => ( + + {label} + + ))} + + + {error && ( + + {error} + + )} + +
+ {activeStep === 0 && ( + <> + + + + } + label="Create user with full access to this bucket" + sx={{ mt: 2, mb: 2 }} + /> + + + + + + + )} + + {activeStep === 1 && createUser && ( + <> + + Create a user with full access to the bucket. The user will be + able to read, write, and delete objects in this bucket. + + + + + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + + + + + + + )} + + {activeStep === 1 && !createUser && ( + + + + + )} + + {activeStep === 2 && success && ( + <> + + Bucket created successfully! + + + {credentials && ( + + + User Credentials + + + Save these credentials securely. They won't be shown again. + + + + + + Username: {credentials.username} + + copyToClipboard(credentials.username)} + > + + + + + + Password: {credentials.password} + + copyToClipboard(credentials.password)} + > + + + + + + )} + + + + + + )} + +
+
+
+ ); +}; + +export default CreateBucketDialog; \ No newline at end of file diff --git a/frontend/src/components/Dashboard/Dashboard.tsx b/frontend/src/components/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..0b3b61e --- /dev/null +++ b/frontend/src/components/Dashboard/Dashboard.tsx @@ -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 = ({ title, value, icon, color }) => ( + + + + + {icon} + + + + {title} + + {value} + + + + +); + +const Dashboard: React.FC = () => { + const [stats, setStats] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + Dashboard + + + Welcome to MinIO WebUI. Monitor your storage infrastructure at a glance. + + + + + } + color="primary" + /> + + + } + color="secondary" + /> + + + } + color="success" + /> + + + } + color="info" + /> + + + + + + Quick Actions + + + + + + Getting Started + + + 1. Create a new bucket from the Buckets page +
+ 2. Add users and assign them to buckets +
+ 3. Configure policies for fine-grained access control +
+ 4. Monitor storage usage with weekly reports +
+
+
+ + + + System Information + + + MinIO WebUI provides a simple interface for managing your MinIO + storage infrastructure. All operations are performed securely + through the MinIO CLI. + + + +
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..5bf8c7f --- /dev/null +++ b/frontend/src/components/Layout/Layout.tsx @@ -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: , path: '/' }, + { text: 'Buckets', icon: , path: '/buckets' }, + { text: 'Users', icon: , path: '/users' }, + { text: 'Policies', icon: , path: '/policies' }, + { text: 'Reports', icon: , 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); + + const handleDrawerToggle = () => { + setOpen(!open); + }; + + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( + + + theme.transitions.create(['margin', 'width'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + }} + > + + + {open ? : } + + + + MinIO WebUI + + + + + + + + + + + + + + + + Logged in as {user?.role || 'Admin'} + + + + + + + + Logout + + + + + + + theme.spacing(0, 1), + ...((theme) => theme.mixins.toolbar), + justifyContent: 'center', + }} + > + + MinIO Manager + + + + + {navItems.map((item) => ( + + navigate(item.path)} + > + {item.icon} + + + + ))} + + + + + theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + marginLeft: open ? 0 : `-${drawerWidth}px`, + mt: 8, + }} + > + + + + ); +}; + +export default Layout; \ No newline at end of file diff --git a/frontend/src/components/Policies/Policies.tsx b/frontend/src/components/Policies/Policies.tsx new file mode 100644 index 0000000..7063721 --- /dev/null +++ b/frontend/src/components/Policies/Policies.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +const Policies: React.FC = () => { + return ( + + + Policies + + + Policy management functionality coming soon... + + + ); +}; + +export default Policies; \ No newline at end of file diff --git a/frontend/src/components/Reports/Reports.tsx b/frontend/src/components/Reports/Reports.tsx new file mode 100644 index 0000000..e030f41 --- /dev/null +++ b/frontend/src/components/Reports/Reports.tsx @@ -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(null); + const [scheduleInfo, setScheduleInfo] = useState(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 ; + } + + if (error) { + return ( + + {error} + + ); + } + + 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 ( + + + Storage Reports + + + + + + + + + + + + + + + + + + + + Total Storage + + + {report.summary.totalSizeFormatted} + + + + + + + + + Total Buckets + + + {report.summary.totalBuckets} + + + + + + + + + Total Users + + {report.summary.totalUsers} + + + + + + + + + + + Bucket Name + Size + Objects + Last Modified + + + + {report.buckets.map((bucket) => ( + + {bucket.name} + + + + {bucket.objects} + {bucket.lastModified} + + ))} + +
+
+
+
+ + + + + Storage Distribution + + + {report.buckets.length > 0 ? ( + + ) : ( + + No data to display + + )} + + + + + + + Report Schedule + + {scheduleInfo && ( + <> + + Status:{' '} + + + + Schedule: {scheduleInfo.schedule} + + + Recipients:{' '} + {Array.isArray(scheduleInfo.recipients) + ? scheduleInfo.recipients.join(', ') + : scheduleInfo.recipients} + + {scheduleInfo.nextRun && ( + + Next Run:{' '} + {new Date(scheduleInfo.nextRun).toLocaleString()} + + )} + + )} + + +
+ + setSuccessMessage('')} + message={successMessage} + /> +
+ ); +}; + +export default Reports; \ No newline at end of file diff --git a/frontend/src/components/Users/Users.tsx b/frontend/src/components/Users/Users.tsx new file mode 100644 index 0000000..03fecf2 --- /dev/null +++ b/frontend/src/components/Users/Users.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +const Users: React.FC = () => { + return ( + + + Users + + + User management functionality coming soon... + + + ); +}; + +export default Users; \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..28de88b --- /dev/null +++ b/frontend/src/index.tsx @@ -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( + + + + + + + + +); \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..f29590e --- /dev/null +++ b/frontend/src/services/api.ts @@ -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'; +}; \ No newline at end of file diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..7a68905 --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -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; + logout: () => Promise; + checkAuth: () => Promise; +} + +const useAuthStore = create()( + 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; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..74caf0d --- /dev/null +++ b/frontend/tsconfig.json @@ -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" + ] +} \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..f295a48 --- /dev/null +++ b/nginx/default.conf @@ -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"; + } +} \ No newline at end of file diff --git a/nginx/proxy.conf b/nginx/proxy.conf new file mode 100644 index 0000000..969df11 --- /dev/null +++ b/nginx/proxy.conf @@ -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; + } +} \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5495080 --- /dev/null +++ b/scripts/deploy.sh @@ -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" \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..eca33c1 --- /dev/null +++ b/scripts/setup.sh @@ -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." \ No newline at end of file