diff --git a/backend/package.json b/backend/package.json index 42c42c9..6437c5a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,7 +33,8 @@ "node-cron": "^3.0.3", "nodemailer": "^6.9.8", "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1" + "winston-daily-rotate-file": "^4.7.1", + "multer": "^1.4.5-lts.1" }, "devDependencies": { "eslint": "^8.56.0", diff --git a/backend/src/api/aliases/index.js b/backend/src/api/aliases/index.js new file mode 100644 index 0000000..6198f6d --- /dev/null +++ b/backend/src/api/aliases/index.js @@ -0,0 +1,254 @@ +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 aliasValidation = { + aliasName: body('aliasName') + .trim() + .notEmpty() + .withMessage('Alias name is required') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Alias name can only contain letters, numbers, hyphens, and underscores') + .isLength({ min: 1, max: 32 }) + .withMessage('Alias name must be 1-32 characters'), + + aliasNameParam: param('name') + .trim() + .notEmpty() + .matches(/^[a-zA-Z0-9_-]+$/), + + endpoint: body('endpoint') + .trim() + .notEmpty() + .withMessage('Endpoint is required') + .isURL({ protocols: ['http', 'https'], require_protocol: true }) + .withMessage('Invalid endpoint URL'), + + accessKey: body('accessKey') + .trim() + .notEmpty() + .withMessage('Access key is required') + .isLength({ min: 3, max: 256 }), + + secretKey: body('secretKey') + .trim() + .notEmpty() + .withMessage('Secret key is required') + .isLength({ min: 8, max: 256 }), +}; + +// 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/aliases + * List all MinIO aliases + */ +router.get('/', async (req, res, next) => { + try { + const aliases = await minioService.listAliases(); + + // Enrich with connection status (async) + const enrichedAliases = await Promise.all( + aliases.map(async (alias) => { + try { + const testResult = await minioService.testConnection(alias.alias); + return { ...alias, status: testResult.status }; + } catch { + return { ...alias, status: 'unknown' }; + } + }) + ); + + res.json({ + aliases: enrichedAliases, + count: enrichedAliases.length, + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /api/aliases/:name + * Get a specific alias + */ +router.get( + '/:name', + [aliasValidation.aliasNameParam], + handleValidationErrors, + async (req, res, next) => { + try { + const { name } = req.params; + const alias = await minioService.getAlias(name); + + // Get connection status + const testResult = await minioService.testConnection(name); + + res.json({ + ...alias, + status: testResult.status, + }); + } catch (error) { + next(error); + } + } +); + +/** + * POST /api/aliases + * Add a new alias + */ +router.post( + '/', + [ + aliasValidation.aliasName, + aliasValidation.endpoint, + aliasValidation.accessKey, + aliasValidation.secretKey, + ], + handleValidationErrors, + async (req, res, next) => { + try { + const { aliasName, endpoint, accessKey, secretKey } = req.body; + + const result = await minioService.addAlias(aliasName, endpoint, accessKey, secretKey); + + logAudit('ALIAS_CREATE', { + userId: req.user.role, + ip: req.ip, + resource: aliasName, + status: 'success', + details: { endpoint }, + }); + + res.status(201).json(result); + } catch (error) { + logAudit('ALIAS_CREATE', { + userId: req.user.role, + ip: req.ip, + resource: req.body.aliasName, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * PUT /api/aliases/:name + * Update an existing alias + */ +router.put( + '/:name', + [ + aliasValidation.aliasNameParam, + aliasValidation.endpoint, + aliasValidation.accessKey, + aliasValidation.secretKey, + ], + handleValidationErrors, + async (req, res, next) => { + try { + const { name } = req.params; + const { endpoint, accessKey, secretKey } = req.body; + + const result = await minioService.updateAlias(name, endpoint, accessKey, secretKey); + + logAudit('ALIAS_UPDATE', { + userId: req.user.role, + ip: req.ip, + resource: name, + status: 'success', + details: { endpoint }, + }); + + res.json(result); + } catch (error) { + logAudit('ALIAS_UPDATE', { + userId: req.user.role, + ip: req.ip, + resource: req.params.name, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * DELETE /api/aliases/:name + * Remove an alias + */ +router.delete( + '/:name', + [aliasValidation.aliasNameParam], + handleValidationErrors, + async (req, res, next) => { + try { + const { name } = req.params; + + const result = await minioService.removeAlias(name); + + logAudit('ALIAS_DELETE', { + userId: req.user.role, + ip: req.ip, + resource: name, + status: 'success', + }); + + res.json(result); + } catch (error) { + logAudit('ALIAS_DELETE', { + userId: req.user.role, + ip: req.ip, + resource: req.params.name, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * POST /api/aliases/:name/test + * Test connection to an alias + */ +router.post( + '/:name/test', + [aliasValidation.aliasNameParam], + handleValidationErrors, + async (req, res, next) => { + try { + const { name } = req.params; + const result = await minioService.testConnection(name); + res.json(result); + } catch (error) { + next(error); + } + } +); + +module.exports = router; diff --git a/backend/src/api/browser/index.js b/backend/src/api/browser/index.js new file mode 100644 index 0000000..6695aa6 --- /dev/null +++ b/backend/src/api/browser/index.js @@ -0,0 +1,486 @@ +const express = require('express'); +const { body, param, query, validationResult } = require('express-validator'); +const fs = require('fs'); +const fsPromises = require('fs').promises; +const MinIOService = require('../../services/minio.service'); +const { authMiddleware } = require('../../middleware/auth.middleware'); +const { upload, cleanupUploadedFiles } = require('../../middleware/upload.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 browserValidation = { + alias: param('alias') + .trim() + .notEmpty() + .matches(/^[a-zA-Z0-9_-]+$/), + + bucket: param('bucket') + .trim() + .notEmpty() + .matches(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/), + + prefix: query('prefix') + .optional() + .custom((value) => { + if (value && value.includes('..')) { + throw new Error('Invalid prefix'); + } + return true; + }), + + recursive: query('recursive') + .optional() + .isBoolean() + .toBoolean(), + + rename: [ + body('source') + .trim() + .notEmpty() + .withMessage('Source path is required'), + body('destination') + .trim() + .notEmpty() + .withMessage('Destination path is required') + .custom((value, { req }) => { + if (value.includes('..') || req.body.source?.includes('..')) { + throw new Error('Path traversal not allowed'); + } + return true; + }), + ], + + folderName: body('folderName') + .trim() + .notEmpty() + .withMessage('Folder name is required') + .matches(/^[a-zA-Z0-9._-]+$/) + .withMessage('Invalid folder name'), +}; + +// 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(); +}; + +// Path parameter handler for wildcard routes +const getPathFromParams = (req) => { + // For routes like /download/* or /stat/*, the path is in params[0] + return req.params[0] || req.params.path || ''; +}; + +/** + * GET /api/browser/:alias/:bucket + * List objects in a bucket/prefix + */ +router.get( + '/:alias/:bucket', + [browserValidation.alias, browserValidation.bucket, browserValidation.prefix, browserValidation.recursive], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const { prefix, recursive } = req.query; + + const result = await minioService.listObjects(alias, bucket, { + prefix: prefix || '', + recursive: recursive || false, + }); + + res.json(result); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/browser/:alias/:bucket/stat/* + * Get object metadata + */ +router.get( + '/:alias/:bucket/stat/*', + [browserValidation.alias, browserValidation.bucket], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const objectPath = getPathFromParams(req); + + if (!objectPath) { + throw new AppError('Object path is required', 400); + } + + const result = await minioService.getObjectStat(alias, bucket, objectPath); + res.json(result); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/browser/:alias/:bucket/size/* + * Get size of path (du) + */ +router.get( + '/:alias/:bucket/size/*', + [browserValidation.alias, browserValidation.bucket], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const objectPath = getPathFromParams(req); + + const result = await minioService.getPathSize(alias, bucket, objectPath); + res.json(result); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/browser/:alias/:bucket/size + * Get bucket size + */ +router.get( + '/:alias/:bucket/size', + [browserValidation.alias, browserValidation.bucket], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const result = await minioService.getPathSize(alias, bucket, ''); + res.json(result); + } catch (error) { + next(error); + } + } +); + +/** + * GET /api/browser/:alias/:bucket/download/* + * Download a file + */ +router.get( + '/:alias/:bucket/download/*', + [browserValidation.alias, browserValidation.bucket], + handleValidationErrors, + async (req, res, next) => { + let downloadResult = null; + + try { + const { alias, bucket } = req.params; + const objectPath = getPathFromParams(req); + + if (!objectPath) { + throw new AppError('Object path is required', 400); + } + + // Download to temp file + downloadResult = await minioService.downloadObject(alias, bucket, objectPath); + + // Get file stats for Content-Length + const stats = await fsPromises.stat(downloadResult.filePath); + + // Set headers + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(downloadResult.fileName)}"` + ); + res.setHeader('Content-Length', stats.size); + res.setHeader('Content-Type', 'application/octet-stream'); + + // Stream file + const fileStream = fs.createReadStream(downloadResult.filePath); + + fileStream.on('error', async (error) => { + logger.error('File stream error:', error); + if (downloadResult) { + await downloadResult.cleanup(); + } + }); + + fileStream.on('end', async () => { + if (downloadResult) { + await downloadResult.cleanup(); + } + }); + + // Handle client disconnect + res.on('close', async () => { + if (!res.writableEnded && downloadResult) { + await downloadResult.cleanup(); + } + }); + + logAudit('FILE_DOWNLOAD', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${objectPath}`, + status: 'success', + }); + + fileStream.pipe(res); + } catch (error) { + if (downloadResult) { + await downloadResult.cleanup(); + } + + logAudit('FILE_DOWNLOAD', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}/${getPathFromParams(req)}`, + status: 'failed', + details: { error: error.message }, + }); + + next(error); + } + } +); + +/** + * POST /api/browser/:alias/:bucket/upload + * Upload files + */ +router.post( + '/:alias/:bucket/upload', + [browserValidation.alias, browserValidation.bucket, browserValidation.prefix], + handleValidationErrors, + upload.array('files', 100), + async (req, res, next) => { + const uploadedFiles = req.files || []; + + try { + const { alias, bucket } = req.params; + const prefix = req.query.prefix || ''; + + if (uploadedFiles.length === 0) { + throw new AppError('No files uploaded', 400); + } + + const results = { + uploaded: [], + failed: [], + }; + + // Upload each file + for (const file of uploadedFiles) { + try { + const objectPath = prefix ? `${prefix}${file.originalname}` : file.originalname; + await minioService.uploadObject(alias, bucket, objectPath, file.path); + results.uploaded.push({ + name: file.originalname, + key: objectPath, + size: file.size, + }); + } catch (error) { + results.failed.push({ + name: file.originalname, + error: error.message, + }); + } + } + + logAudit('FILE_UPLOAD', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${prefix}`, + status: results.failed.length === 0 ? 'success' : 'partial', + details: { + uploaded: results.uploaded.length, + failed: results.failed.length, + }, + }); + + res.status(201).json({ + ...results, + count: results.uploaded.length, + }); + } catch (error) { + logAudit('FILE_UPLOAD', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}`, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } finally { + // Always cleanup uploaded files + await cleanupUploadedFiles(uploadedFiles); + } + } +); + +/** + * DELETE /api/browser/:alias/:bucket/* + * Delete an object + */ +router.delete( + '/:alias/:bucket/*', + [browserValidation.alias, browserValidation.bucket], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const objectPath = getPathFromParams(req); + const recursive = req.query.recursive === 'true'; + + if (!objectPath) { + throw new AppError('Object path is required', 400); + } + + const result = await minioService.deleteObject(alias, bucket, objectPath, recursive); + + logAudit('FILE_DELETE', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${objectPath}`, + status: 'success', + details: { recursive }, + }); + + res.json(result); + } catch (error) { + logAudit('FILE_DELETE', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}/${getPathFromParams(req)}`, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * POST /api/browser/:alias/:bucket/rename + * Rename/move an object + */ +router.post( + '/:alias/:bucket/rename', + [browserValidation.alias, browserValidation.bucket, ...browserValidation.rename], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const { source, destination } = req.body; + + const result = await minioService.renameObject(alias, bucket, source, destination); + + logAudit('FILE_RENAME', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${source}`, + status: 'success', + details: { destination }, + }); + + res.json(result); + } catch (error) { + logAudit('FILE_RENAME', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}/${req.body.source}`, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * POST /api/browser/:alias/:bucket/copy + * Copy an object + */ +router.post( + '/:alias/:bucket/copy', + [browserValidation.alias, browserValidation.bucket, ...browserValidation.rename], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const { source, destination } = req.body; + + const result = await minioService.copyObject(alias, bucket, source, destination); + + logAudit('FILE_COPY', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${source}`, + status: 'success', + details: { destination }, + }); + + res.json(result); + } catch (error) { + logAudit('FILE_COPY', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}/${req.body.source}`, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +/** + * POST /api/browser/:alias/:bucket/mkdir + * Create a folder + */ +router.post( + '/:alias/:bucket/mkdir', + [browserValidation.alias, browserValidation.bucket, browserValidation.folderName, browserValidation.prefix], + handleValidationErrors, + async (req, res, next) => { + try { + const { alias, bucket } = req.params; + const { folderName } = req.body; + const prefix = req.query.prefix || ''; + + const folderPath = prefix ? `${prefix}${folderName}` : folderName; + const result = await minioService.createFolder(alias, bucket, folderPath); + + logAudit('FOLDER_CREATE', { + userId: req.user.role, + ip: req.ip, + resource: `${alias}/${bucket}/${folderPath}`, + status: 'success', + }); + + res.status(201).json(result); + } catch (error) { + logAudit('FOLDER_CREATE', { + userId: req.user.role, + ip: req.ip, + resource: `${req.params.alias}/${req.params.bucket}/${req.body.folderName}`, + status: 'failed', + details: { error: error.message }, + }); + next(error); + } + } +); + +module.exports = router; diff --git a/backend/src/api/terminal/index.js b/backend/src/api/terminal/index.js new file mode 100644 index 0000000..f5df328 --- /dev/null +++ b/backend/src/api/terminal/index.js @@ -0,0 +1,82 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const MinIOService = require('../../services/minio.service'); +const { authMiddleware } = require('../../middleware/auth.middleware'); +const { logger, logAudit } = require('../../utils/logger'); + +const router = express.Router(); +const minioService = new MinIOService(); + +// Apply auth middleware to all routes +router.use(authMiddleware); + +// Validation rules +const terminalValidation = { + command: body('command') + .trim() + .notEmpty() + .withMessage('Command is required') + .isLength({ max: 4096 }) + .withMessage('Command too long (max 4096 characters)'), + timeout: body('timeout') + .optional() + .isInt({ min: 1000, max: 300000 }) + .withMessage('Timeout must be between 1000 and 300000 ms'), +}; + +// 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/terminal/execute + * Execute an mc command and return the output + */ +router.post( + '/execute', + [terminalValidation.command, terminalValidation.timeout], + handleValidationErrors, + async (req, res, next) => { + try { + const { command, timeout } = req.body; + + // Execute the command + const result = await minioService.executeRawCommand(command, { + timeout: timeout || 30000, + }); + + // Audit log all terminal commands + logAudit('TERMINAL_EXECUTE', { + userId: req.user.role, + ip: req.ip, + resource: command.substring(0, 100), // Truncate for log + status: result.success ? 'success' : 'failed', + details: { + exitCode: result.exitCode, + executionTime: result.executionTime, + }, + }); + + res.json(result); + } catch (error) { + logAudit('TERMINAL_EXECUTE', { + userId: req.user.role, + ip: req.ip, + resource: req.body.command?.substring(0, 100), + status: 'error', + details: { error: error.message }, + }); + next(error); + } + } +); + +module.exports = router; diff --git a/backend/src/app.js b/backend/src/app.js index 0a50f13..677c8d6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,6 +18,9 @@ const bucketRoutes = require('./api/buckets'); const userRoutes = require('./api/users'); const policyRoutes = require('./api/policies'); const reportRoutes = require('./api/reports'); +const terminalRoutes = require('./api/terminal'); +const aliasRoutes = require('./api/aliases'); +const browserRoutes = require('./api/browser'); // Create Express app const app = express(); @@ -111,6 +114,22 @@ const authLimiter = rateLimit({ app.use('/api/auth/login', authLimiter); +// Rate limiting for terminal commands (30 per minute) +const terminalLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + message: 'Too many commands, please wait.', +}); +app.use('/api/terminal', terminalLimiter); + +// Rate limiting for browser operations (60 per minute) +const browserLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + message: 'Too many file operations, please slow down.', +}); +app.use('/api/browser', browserLimiter); + // Health check endpoint app.get('/health', (req, res) => { res.json({ @@ -126,6 +145,9 @@ app.use('/api/buckets', bucketRoutes); app.use('/api/users', userRoutes); app.use('/api/policies', policyRoutes); app.use('/api/reports', reportRoutes); +app.use('/api/terminal', terminalRoutes); +app.use('/api/aliases', aliasRoutes); +app.use('/api/browser', browserRoutes); // 404 handler app.use((req, res) => { diff --git a/backend/src/middleware/upload.middleware.js b/backend/src/middleware/upload.middleware.js new file mode 100644 index 0000000..f95fcbc --- /dev/null +++ b/backend/src/middleware/upload.middleware.js @@ -0,0 +1,129 @@ +const multer = require('multer'); +const path = require('path'); +const crypto = require('crypto'); +const fs = require('fs').promises; +const os = require('os'); +const { logger } = require('../utils/logger'); + +// Determine upload directory with fallbacks +const getUploadDir = async () => { + const candidates = [ + path.join(__dirname, '../../temp/uploads'), + path.join(process.cwd(), 'temp/uploads'), + path.join(os.tmpdir(), 'minio-webui-uploads'), + ]; + + for (const dir of candidates) { + try { + await fs.mkdir(dir, { recursive: true, mode: 0o755 }); + // Test write permissions + const testFile = path.join(dir, `.write-test-${Date.now()}`); + await fs.writeFile(testFile, 'test'); + await fs.unlink(testFile); + logger.info(`Using upload directory: ${dir}`); + return dir; + } catch (error) { + logger.debug(`Upload directory ${dir} not usable:`, error.message); + } + } + + // Last resort + const fallback = path.join(process.cwd(), '.uploads'); + await fs.mkdir(fallback, { recursive: true }).catch(() => {}); + return fallback; +}; + +// Initialize upload directory +let uploadDir = null; +const initUploadDir = async () => { + if (!uploadDir) { + uploadDir = await getUploadDir(); + } + return uploadDir; +}; + +// Configure storage +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + const dir = await initUploadDir(); + cb(null, dir); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + // Generate unique filename to prevent conflicts + const uniqueSuffix = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`; + // Sanitize original filename + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${uniqueSuffix}-${sanitizedName}`); + }, +}); + +// File filter - allow all file types for object storage +const fileFilter = (req, file, cb) => { + cb(null, true); +}; + +// Create multer instance +const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 5 * 1024 * 1024 * 1024, // 5GB limit + files: 100, // Max 100 files per request + }, +}); + +// Cleanup helper +const cleanupUploadedFiles = async (files) => { + if (!files) return; + const fileArray = Array.isArray(files) ? files : [files]; + for (const file of fileArray) { + try { + if (file.path) { + await fs.unlink(file.path); + logger.debug(`Cleaned up uploaded file: ${file.path}`); + } + } catch (error) { + logger.debug(`Failed to cleanup file ${file.path}:`, error.message); + } + } +}; + +// Cleanup old uploads (files older than 1 hour) +const cleanupOldUploads = async () => { + try { + const dir = await initUploadDir(); + const files = await fs.readdir(dir); + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + + for (const file of files) { + if (file.startsWith('.')) continue; // Skip hidden files + const filePath = path.join(dir, file); + try { + const stats = await fs.stat(filePath); + if (now - stats.mtimeMs > oneHour) { + await fs.unlink(filePath); + logger.debug(`Cleaned up old upload: ${file}`); + } + } catch (error) { + // Ignore errors for individual files + } + } + } catch (error) { + logger.debug('Error cleaning old uploads:', error.message); + } +}; + +// Run cleanup periodically (every 30 minutes) +setInterval(cleanupOldUploads, 30 * 60 * 1000); + +module.exports = { + upload, + cleanupUploadedFiles, + cleanupOldUploads, + initUploadDir, +}; diff --git a/backend/src/services/minio.service.js b/backend/src/services/minio.service.js index 932a705..16149d1 100644 --- a/backend/src/services/minio.service.js +++ b/backend/src/services/minio.service.js @@ -483,7 +483,328 @@ class MinIOService { return name && name.length >= 1 && name.length <= 32 && regex.test(name); } +// ============================================ + // Terminal Methods (Raw Command Execution) + // ============================================ + + async executeRawCommand(command, options = {}) { + const { timeout = 30000 } = options; + const startTime = Date.now(); + + // Security: Only allow mc commands + if (!command.trim().startsWith('mc ')) { + return { + success: false, + output: '', + error: 'Only mc commands are allowed', + exitCode: 1, + executionTime: Date.now() - startTime, + }; + } + + // Security: Block shell operators + const blockedPatterns = [/;/, /\|/, /`/, /\$\(/, />>?/, /< a.alias === aliasName); + if (!alias) { + throw new AppError('Alias not found', 404); + } + return alias; + } + + async updateAlias(aliasName, endpoint, accessKey, secretKey) { + if (!this.isValidAliasName(aliasName)) { + throw new AppError('Invalid alias name', 400); + } + + // mc alias set overwrites existing alias + await this.executeCommand( + `mc alias set ${aliasName} ${endpoint} ${accessKey} ${secretKey}` + ); + + const testResult = await this.testConnection(aliasName); + return { + message: 'Alias updated successfully', + aliasName, + connectionStatus: testResult.status, + }; + } + + async removeAlias(aliasName) { + if (!this.isValidAliasName(aliasName)) { + throw new AppError('Invalid alias name', 400); + } + + await this.executeCommand(`mc alias remove ${aliasName}`); + return { message: 'Alias removed successfully', aliasName }; + } + + // ============================================ + // File Browser Methods + // ============================================ + + validatePath(pathStr) { + if (!pathStr) return true; + + const decoded = decodeURIComponent(pathStr); + + // Path traversal prevention + if (decoded.includes('..')) { + throw new AppError('Path traversal not allowed', 400); + } + + // Null byte injection prevention + if (decoded.includes('\0')) { + throw new AppError('Invalid path', 400); + } + + // Command injection prevention in paths + const dangerousPatterns = [';', '|', '`', '$', '>', '<', '&']; + for (const pattern of dangerousPatterns) { + if (decoded.includes(pattern)) { + throw new AppError('Invalid characters in path', 400); + } + } + + return true; + } + + async listObjects(alias, bucket, options = {}) { + const { prefix = '', recursive = false } = options; + + // Validate path security + this.validatePath(prefix); + + const targetPath = prefix + ? `${alias}/${bucket}/${prefix}` + : `${alias}/${bucket}`; + const recursiveFlag = recursive ? '--recursive' : ''; + + try { + const output = await this.executeCommand( + `mc ls "${targetPath}" ${recursiveFlag} --json`, + { parseJson: true, timeout: 60000 } + ); + + let objects = Array.isArray(output) ? output : output ? [output] : []; + + // Transform results + objects = objects.map(obj => ({ + key: obj.key || '', + name: (obj.key || '').split('/').filter(Boolean).pop() || obj.key || '', + size: obj.size || 0, + sizeFormatted: this.formatBytes(obj.size || 0), + lastModified: obj.lastModified || null, + type: (obj.key || '').endsWith('/') ? 'folder' : 'file', + etag: obj.etag || null, + })); + + return { + objects, + prefix, + count: objects.length, + }; + } catch (error) { + // Return empty list if path doesn't exist or is empty + if (error.message && error.message.includes('not found')) { + return { objects: [], prefix, count: 0 }; + } + throw error; + } + } + + async getObjectStat(alias, bucket, objectPath) { + this.validatePath(objectPath); + + const output = await this.executeCommand( + `mc stat "${alias}/${bucket}/${objectPath}" --json`, + { parseJson: true } + ); + + return { + key: objectPath, + name: objectPath.split('/').filter(Boolean).pop() || objectPath, + size: output.size || 0, + sizeFormatted: this.formatBytes(output.size || 0), + lastModified: output.lastModified || null, + contentType: output.metadata?.['content-type'] || 'application/octet-stream', + etag: output.etag || null, + metadata: output.metadata || {}, + }; + } + + async downloadObject(alias, bucket, objectPath) { + this.validatePath(objectPath); + + // Generate unique temp file path + const fileName = objectPath.split('/').filter(Boolean).pop() || 'download'; + const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_'); + const tempFilePath = path.join( + this.tempDir, + `${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}` + ); + + await this.executeCommand( + `mc cp "${alias}/${bucket}/${objectPath}" "${tempFilePath}"`, + { timeout: 300000 } // 5 min for large files + ); + + return { + filePath: tempFilePath, + fileName, + cleanup: async () => { + await fs.unlink(tempFilePath).catch(() => {}); + }, + }; + } + + async uploadObject(alias, bucket, objectPath, localFilePath) { + this.validatePath(objectPath); + + await this.executeCommand( + `mc cp "${localFilePath}" "${alias}/${bucket}/${objectPath}"`, + { timeout: 300000 } + ); + + return { key: objectPath, message: 'File uploaded successfully' }; + } + + async deleteObject(alias, bucket, objectPath, recursive = false) { + this.validatePath(objectPath); + + const recursiveFlag = recursive ? '--recursive --force' : ''; + + await this.executeCommand( + `mc rm "${alias}/${bucket}/${objectPath}" ${recursiveFlag}`, + { timeout: 60000 } + ); + + return { message: 'Object deleted successfully', key: objectPath }; + } + + async renameObject(alias, bucket, sourcePath, destPath) { + this.validatePath(sourcePath); + this.validatePath(destPath); + + // mc mv for rename + await this.executeCommand( + `mc mv "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`, + { timeout: 60000 } + ); + + return { + message: 'Object renamed successfully', + source: sourcePath, + destination: destPath, + }; + } + + async copyObject(alias, bucket, sourcePath, destPath) { + this.validatePath(sourcePath); + this.validatePath(destPath); + + await this.executeCommand( + `mc cp "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`, + { timeout: 60000 } + ); + + return { + message: 'Object copied successfully', + source: sourcePath, + destination: destPath, + }; + } + + async createFolder(alias, bucket, folderPath) { + this.validatePath(folderPath); + + // Ensure path ends with / + const normalizedPath = folderPath.endsWith('/') ? folderPath : `${folderPath}/`; + + // Create empty object to represent folder (.keep file) + const emptyFile = path.join(this.tempDir, `.empty-${Date.now()}`); + await fs.writeFile(emptyFile, ''); + + try { + await this.executeCommand( + `mc cp "${emptyFile}" "${alias}/${bucket}/${normalizedPath}.keep"`, + { timeout: 30000 } + ); + } finally { + await fs.unlink(emptyFile).catch(() => {}); + } + + return { message: 'Folder created successfully', path: normalizedPath }; + } + + async getPathSize(alias, bucket, objectPath = '') { + this.validatePath(objectPath); + + const targetPath = objectPath + ? `${alias}/${bucket}/${objectPath}` + : `${alias}/${bucket}`; + + const output = await this.executeCommand( + `mc du "${targetPath}" --json`, + { parseJson: true, timeout: 120000 } + ); + + return { + path: objectPath || '/', + size: output.size || 0, + sizeFormatted: this.formatBytes(output.size || 0), + objects: output.objects || 0, + }; + } + + // ============================================ // Utility Methods + // ============================================ + formatBytes(bytes) { if (bytes === 0) return '0 B'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d0e967..ebb7fb1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets'; import Users from './components/Users/Users'; import Policies from './components/Policies/Policies'; import Reports from './components/Reports/Reports'; +import Explorer from './components/Explorer/Explorer'; +import Aliases from './components/Explorer/Aliases/Aliases'; +import Terminal from './components/Explorer/Terminal/Terminal'; +import FileBrowser from './components/Explorer/FileBrowser/FileBrowser'; import './i18n'; function App() { @@ -30,6 +34,13 @@ function App() { }> } /> } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/Explorer/Aliases/AliasDialog.tsx b/frontend/src/components/Explorer/Aliases/AliasDialog.tsx new file mode 100644 index 0000000..4be3c3c --- /dev/null +++ b/frontend/src/components/Explorer/Aliases/AliasDialog.tsx @@ -0,0 +1,161 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Alert, + CircularProgress, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useExplorerStore, Alias } from '../../../store/explorerStore'; + +interface AliasDialogProps { + open: boolean; + alias: Alias | null; + onClose: () => void; + onSuccess: () => void; +} + +const AliasDialog: React.FC = ({ open, alias, onClose, onSuccess }) => { + const { t } = useTranslation(['explorer', 'common']); + const { addAlias, updateAlias } = useExplorerStore(); + + const [aliasName, setAliasName] = useState(''); + const [endpoint, setEndpoint] = useState(''); + const [accessKey, setAccessKey] = useState(''); + const [secretKey, setSecretKey] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const isEdit = !!alias; + + useEffect(() => { + if (open) { + if (alias) { + setAliasName(alias.alias); + setEndpoint(alias.URL); + setAccessKey(''); + setSecretKey(''); + } else { + setAliasName(''); + setEndpoint(''); + setAccessKey(''); + setSecretKey(''); + } + setError(''); + } + }, [open, alias]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // Validation + if (!aliasName.trim()) { + setError('Alias name is required'); + return; + } + if (!endpoint.trim()) { + setError('Endpoint is required'); + return; + } + if (!accessKey.trim()) { + setError('Access key is required'); + return; + } + if (!secretKey.trim()) { + setError('Secret key is required'); + return; + } + + setLoading(true); + try { + if (isEdit) { + await updateAlias(aliasName, endpoint, accessKey, secretKey); + } else { + await addAlias(aliasName, endpoint, accessKey, secretKey); + } + onSuccess(); + } catch (err: any) { + setError(err.response?.data?.message || err.message || 'Operation failed'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + {t('explorer:aliases.dialog.title')} + + + + {error && ( + setError('')}> + {error} + + )} + + setAliasName(e.target.value)} + disabled={isEdit} + required + fullWidth + helperText={t('explorer:aliases.dialog.aliasNameHelper')} + inputProps={{ pattern: '[a-zA-Z0-9_-]+' }} + /> + + setEndpoint(e.target.value)} + required + fullWidth + placeholder="https://minio.example.com" + helperText={t('explorer:aliases.dialog.endpointHelper')} + /> + + setAccessKey(e.target.value)} + required + fullWidth + /> + + setSecretKey(e.target.value)} + required + fullWidth + /> + + + + + + +
+
+ ); +}; + +export default AliasDialog; diff --git a/frontend/src/components/Explorer/Aliases/Aliases.tsx b/frontend/src/components/Explorer/Aliases/Aliases.tsx new file mode 100644 index 0000000..c12ee92 --- /dev/null +++ b/frontend/src/components/Explorer/Aliases/Aliases.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Chip, + Typography, + Tooltip, + CircularProgress, + Snackbar, + Alert, +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Refresh as RefreshIcon, + NetworkCheck as TestIcon, +} from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { useExplorerStore, Alias } from '../../../store/explorerStore'; +import AliasDialog from './AliasDialog'; +import ConfirmDialog from '../../shared/ConfirmDialog'; +import api from '../../../services/api'; + +const Aliases: React.FC = () => { + const { t } = useTranslation(['explorer', 'common']); + const navigate = useNavigate(); + const { aliases, aliasesLoading, loadAliases, removeAlias, testConnection } = useExplorerStore(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAlias, setEditingAlias] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [aliasToDelete, setAliasToDelete] = useState(null); + const [testingAlias, setTestingAlias] = useState(null); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ + open: false, + message: '', + severity: 'success', + }); + + + useEffect(() => { + loadAliases(); + }, [loadAliases]); + + const handleAddAlias = () => { + setEditingAlias(null); + setDialogOpen(true); + }; + + const handleEditAlias = (alias: Alias) => { + setEditingAlias(alias); + setDialogOpen(true); + }; + + const handleDeleteClick = (aliasName: string) => { + setAliasToDelete(aliasName); + setDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (aliasToDelete) { + try { + await removeAlias(aliasToDelete); + setSnackbar({ + open: true, + message: t('explorer:aliases.messages.deleted'), + severity: 'success', + }); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.response?.data?.message || 'Delete failed', + severity: 'error', + }); + } + } + setDeleteDialogOpen(false); + setAliasToDelete(null); + }; + + const handleTestConnection = async (aliasName: string) => { + setTestingAlias(aliasName); + try { + const result = await testConnection(aliasName); + setSnackbar({ + open: true, + message: result.status === 'connected' + ? t('explorer:aliases.messages.testSuccess') + : t('explorer:aliases.messages.testFailed'), + severity: result.status === 'connected' ? 'success' : 'error', + }); + // Reload to update status + loadAliases(); + } catch (error) { + setSnackbar({ + open: true, + message: t('explorer:aliases.messages.testFailed'), + severity: 'error', + }); + } + setTestingAlias(null); + }; + + const handleAliasClick = async (alias: Alias) => { + // Fetch buckets for this alias and navigate to first one, or show bucket picker + try { + const response = await api.get<{ buckets: { key: string }[] }>('/buckets'); + if (response.data.buckets && response.data.buckets.length > 0) { + // Navigate to first bucket + navigate(`/explorer/browse/${alias.alias}/${response.data.buckets[0].key}`); + } else { + setSnackbar({ + open: true, + message: 'No buckets found for this alias', + severity: 'error', + }); + } + } catch (error: any) { + setSnackbar({ + open: true, + message: error.response?.data?.message || 'Failed to load buckets', + severity: 'error', + }); + } + }; + + const handleDialogSuccess = () => { + setDialogOpen(false); + loadAliases(); + setSnackbar({ + open: true, + message: editingAlias + ? t('explorer:aliases.messages.updated') + : t('explorer:aliases.messages.created'), + severity: 'success', + }); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'connected': + return 'success'; + case 'disconnected': + return 'error'; + default: + return 'default'; + } + }; + + if (aliasesLoading && aliases.length === 0) { + return ( + + + + ); + } + + return ( + + + {t('explorer:aliases.title')} + + + + + + + + + + + {t('explorer:aliases.columns.name')} + {t('explorer:aliases.columns.endpoint')} + {t('explorer:aliases.columns.status')} + {t('explorer:aliases.columns.actions')} + + + + {aliases.length === 0 ? ( + + + + + {t('explorer:aliases.empty.title')} + + + {t('explorer:aliases.empty.message')} + + + + + ) : ( + aliases.map((alias) => ( + handleAliasClick(alias)} + > + + + {alias.alias} + + + + + {alias.URL} + + + + + + e.stopPropagation()}> + + handleTestConnection(alias.alias)} + disabled={testingAlias === alias.alias} + > + {testingAlias === alias.alias ? ( + + ) : ( + + )} + + + + handleEditAlias(alias)} + > + + + + + handleDeleteClick(alias.alias)} + > + + + + + + )) + )} + +
+
+ + setDialogOpen(false)} + onSuccess={handleDialogSuccess} + /> + + setDeleteDialogOpen(false)} + /> + + setSnackbar({ ...snackbar, open: false })} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ); +}; + +export default Aliases; diff --git a/frontend/src/components/Explorer/Explorer.tsx b/frontend/src/components/Explorer/Explorer.tsx new file mode 100644 index 0000000..a137711 --- /dev/null +++ b/frontend/src/components/Explorer/Explorer.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { Box, Typography, Tabs, Tab, Paper } from '@mui/material'; +import { + Storage as StorageIcon, + Terminal as TerminalIcon, +} from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; + +const Explorer: React.FC = () => { + const { t } = useTranslation(['explorer']); + const navigate = useNavigate(); + const location = useLocation(); + + // Determine current tab based on URL + const getCurrentTab = () => { + if (location.pathname.includes('/terminal')) return 1; + if (location.pathname.includes('/browse')) return -1; // Hide tabs when browsing + return 0; // aliases + }; + + const currentTab = getCurrentTab(); + + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + if (newValue === 0) navigate('/explorer/aliases'); + else if (newValue === 1) navigate('/explorer/terminal'); + }; + + return ( + + + {t('explorer:title')} + + + {currentTab !== -1 && ( + + + } + iconPosition="start" + label={t('explorer:tabs.aliases')} + /> + } + iconPosition="start" + label={t('explorer:tabs.terminal')} + /> + + + )} + + + + ); +}; + +export default Explorer; diff --git a/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx b/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx new file mode 100644 index 0000000..5f47185 --- /dev/null +++ b/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Breadcrumbs, Link, Typography, Box } from '@mui/material'; +import { + NavigateNext as NavigateNextIcon, + Storage as StorageIcon, + Home as HomeIcon, +} from '@mui/icons-material'; +interface BreadcrumbNavProps { + alias: string; + bucket: string; + path: string; + onNavigate: (path: string) => void; +} + +const BreadcrumbNav: React.FC = ({ + alias, + bucket, + path, + onNavigate, +}) => { + + const pathParts = path ? path.split('/').filter(Boolean) : []; + + const handleClick = (index: number) => { + if (index === -1) { + // Navigate to bucket root + onNavigate(''); + } else { + // Navigate to specific path + const newPath = pathParts.slice(0, index + 1).join('/'); + onNavigate(newPath); + } + }; + + return ( + + } + aria-label="breadcrumb" + > + {/* Alias link - goes back to aliases list */} + window.location.href = '/explorer/aliases'} + underline="hover" + color="inherit" + sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} + > + + {alias} + + + {/* Bucket link */} + {pathParts.length > 0 ? ( + handleClick(-1)} + underline="hover" + color="inherit" + sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} + > + + {bucket} + + ) : ( + + + {bucket} + + )} + + {/* Path parts */} + {pathParts.map((part, index) => { + const isLast = index === pathParts.length - 1; + return isLast ? ( + + {part} + + ) : ( + handleClick(index)} + underline="hover" + color="inherit" + sx={{ cursor: 'pointer' }} + > + {part} + + ); + })} + + + ); +}; + +export default BreadcrumbNav; diff --git a/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx b/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx new file mode 100644 index 0000000..d7f8101 --- /dev/null +++ b/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx @@ -0,0 +1,87 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface CreateFolderDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (name: string) => void; +} + +const CreateFolderDialog: React.FC = ({ + open, + onClose, + onConfirm, +}) => { + const { t } = useTranslation(['explorer', 'common']); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + setName(''); + setError(''); + } + }, [open]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedName = name.trim(); + if (!trimmedName) { + setError(t('explorer:browser.createFolderDialog.errors.required')); + return; + } + + // Validate folder name + if (trimmedName.includes('/') || trimmedName.includes('\\')) { + setError(t('explorer:browser.createFolderDialog.errors.invalidChars')); + return; + } + + if (trimmedName.startsWith('.')) { + setError(t('explorer:browser.createFolderDialog.errors.dotStart')); + return; + } + + onConfirm(trimmedName); + }; + + return ( + +
+ {t('explorer:browser.createFolderDialog.title')} + + { + setName(e.target.value); + setError(''); + }} + error={!!error} + helperText={error || t('explorer:browser.createFolderDialog.nameHelper')} + /> + + + + + +
+
+ ); +}; + +export default CreateFolderDialog; diff --git a/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx b/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx new file mode 100644 index 0000000..0599759 --- /dev/null +++ b/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx @@ -0,0 +1,480 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Typography, + Tooltip, + CircularProgress, + Snackbar, + Alert, + Button, + Checkbox, + Toolbar, +} from '@mui/material'; +import { + Folder as FolderIcon, + InsertDriveFile as FileIcon, + Download as DownloadIcon, + Delete as DeleteIcon, + Edit as EditIcon, + CreateNewFolder as CreateFolderIcon, + CloudUpload as UploadIcon, + Refresh as RefreshIcon, + ArrowBack as BackIcon, +} from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { useExplorerStore, FileEntry } from '../../../store/explorerStore'; +import BreadcrumbNav from './BreadcrumbNav'; +import UploadDialog from './UploadDialog'; +import CreateFolderDialog from './CreateFolderDialog'; +import RenameDialog from './RenameDialog'; +import ConfirmDialog from '../../shared/ConfirmDialog'; + +const FileBrowser: React.FC = () => { + const { t } = useTranslation(['explorer', 'common']); + const { alias, bucket, '*': pathParam } = useParams(); + const navigate = useNavigate(); + const { + files, + filesLoading, + loadFiles, + downloadFile, + deleteFile, + renameFile, + createFolder, + uploadFiles, + } = useExplorerStore(); + + const currentPath = pathParam || ''; + + const [selected, setSelected] = useState([]); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToRename, setItemToRename] = useState(null); + const [itemToDelete, setItemToDelete] = useState(null); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ + open: false, + message: '', + severity: 'success', + }); + + useEffect(() => { + if (alias && bucket) { + loadFiles(alias, bucket, currentPath); + setSelected([]); + } + }, [alias, bucket, currentPath, loadFiles]); + + const handleNavigate = useCallback((path: string) => { + if (alias && bucket) { + const newPath = path ? `/explorer/browse/${alias}/${bucket}/${path}` : `/explorer/browse/${alias}/${bucket}`; + navigate(newPath); + } + }, [alias, bucket, navigate]); + + const handleItemClick = useCallback((item: FileEntry) => { + if (item.type === 'folder') { + const newPath = currentPath ? `${currentPath}/${item.name}` : item.name; + handleNavigate(newPath); + } + }, [currentPath, handleNavigate]); + + const handleBack = useCallback(() => { + if (currentPath) { + const parts = currentPath.split('/'); + parts.pop(); + handleNavigate(parts.join('/')); + } else { + navigate('/explorer/aliases'); + } + }, [currentPath, handleNavigate, navigate]); + + const handleSelectAll = useCallback((e: React.ChangeEvent) => { + if (e.target.checked) { + setSelected(files.map((f) => f.key)); + } else { + setSelected([]); + } + }, [files]); + + const handleSelect = useCallback((key: string) => { + setSelected((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key] + ); + }, []); + + const handleDownload = useCallback(async (item: FileEntry) => { + if (!alias || !bucket) return; + try { + await downloadFile(alias, bucket, item.key); + setSnackbar({ + open: true, + message: t('explorer:browser.messages.downloadStarted'), + severity: 'success', + }); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.downloadFailed'), + severity: 'error', + }); + } + }, [alias, bucket, downloadFile, t]); + + const handleRenameClick = useCallback((item: FileEntry) => { + setItemToRename(item); + setRenameDialogOpen(true); + }, []); + + const handleRenameConfirm = useCallback(async (newName: string) => { + if (!alias || !bucket || !itemToRename) return; + try { + await renameFile(alias, bucket, itemToRename.key, newName); + setSnackbar({ + open: true, + message: t('explorer:browser.messages.renamed'), + severity: 'success', + }); + loadFiles(alias, bucket, currentPath); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.renameFailed'), + severity: 'error', + }); + } + setRenameDialogOpen(false); + setItemToRename(null); + }, [alias, bucket, itemToRename, renameFile, loadFiles, currentPath, t]); + + const handleDeleteClick = useCallback((key: string) => { + setItemToDelete(key); + setDeleteDialogOpen(true); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!alias || !bucket || !itemToDelete) return; + try { + await deleteFile(alias, bucket, itemToDelete); + setSnackbar({ + open: true, + message: t('explorer:browser.messages.deleted'), + severity: 'success', + }); + loadFiles(alias, bucket, currentPath); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.deleteFailed'), + severity: 'error', + }); + } + setDeleteDialogOpen(false); + setItemToDelete(null); + }, [alias, bucket, itemToDelete, deleteFile, loadFiles, currentPath, t]); + + const handleBulkDelete = useCallback(async () => { + if (!alias || !bucket || selected.length === 0) return; + try { + for (const key of selected) { + await deleteFile(alias, bucket, key); + } + setSnackbar({ + open: true, + message: t('explorer:browser.messages.deleted'), + severity: 'success', + }); + setSelected([]); + loadFiles(alias, bucket, currentPath); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.deleteFailed'), + severity: 'error', + }); + } + }, [alias, bucket, selected, deleteFile, loadFiles, currentPath, t]); + + const handleCreateFolder = useCallback(async (name: string) => { + if (!alias || !bucket) return; + try { + const folderPath = currentPath ? `${currentPath}/${name}` : name; + await createFolder(alias, bucket, folderPath); + setSnackbar({ + open: true, + message: t('explorer:browser.messages.folderCreated'), + severity: 'success', + }); + loadFiles(alias, bucket, currentPath); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.folderCreateFailed'), + severity: 'error', + }); + } + setCreateFolderDialogOpen(false); + }, [alias, bucket, currentPath, createFolder, loadFiles, t]); + + const handleUpload = useCallback(async (uploadedFiles: File[]) => { + if (!alias || !bucket) return; + try { + await uploadFiles(alias, bucket, uploadedFiles, currentPath); + setSnackbar({ + open: true, + message: t('explorer:browser.messages.uploaded'), + severity: 'success', + }); + loadFiles(alias, bucket, currentPath); + } catch (error: any) { + setSnackbar({ + open: true, + message: error.message || t('explorer:browser.messages.uploadFailed'), + severity: 'error', + }); + } + setUploadDialogOpen(false); + }, [alias, bucket, currentPath, uploadFiles, loadFiles, t]); + + const formatSize = (bytes: number): string => { + if (bytes === 0) return '-'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; + }; + + const formatDate = (date: string | null): string => { + if (!date) return '-'; + return new Date(date).toLocaleString(); + }; + + if (!alias || !bucket) { + return ( + + {t('explorer:browser.errors.missingParams')} + + ); + } + + return ( + + {/* Breadcrumb navigation */} + + + {/* Toolbar */} + 0 ? 'action.selected' : 'transparent', + mb: 2, + }} + > + {selected.length > 0 ? ( + <> + + {t('explorer:browser.selected', { count: selected.length })} + + + + + + + + ) : ( + <> + + + + + + + {bucket} + + + loadFiles(alias, bucket, currentPath)}> + + + + + setCreateFolderDialogOpen(true)}> + + + + + + )} + + + {/* File table */} + {filesLoading ? ( + + + + ) : ( + + + + + + 0 && selected.length < files.length} + checked={files.length > 0 && selected.length === files.length} + onChange={handleSelectAll} + /> + + {t('explorer:browser.columns.name')} + {t('explorer:browser.columns.size')} + {t('explorer:browser.columns.type')} + {t('explorer:browser.columns.lastModified')} + {t('explorer:browser.columns.actions')} + + + + {files.length === 0 ? ( + + + + + {t('explorer:browser.empty.title')} + + + {t('explorer:browser.empty.message')} + + + + + ) : ( + files.map((item) => ( + handleItemClick(item)} + > + e.stopPropagation()}> + handleSelect(item.key)} + /> + + + + {item.type === 'folder' ? ( + + ) : ( + + )} + {item.name} + + + {formatSize(item.size)} + + + {item.type === 'folder' ? t('explorer:browser.types.folder') : (item.contentType || t('explorer:browser.types.file'))} + + + {formatDate(item.lastModified)} + e.stopPropagation()}> + {item.type !== 'folder' && ( + + handleDownload(item)}> + + + + )} + + handleRenameClick(item)}> + + + + + handleDeleteClick(item.key)} + > + + + + + + )) + )} + +
+
+ )} + + {/* Dialogs */} + setUploadDialogOpen(false)} + onUpload={handleUpload} + /> + + setCreateFolderDialogOpen(false)} + onConfirm={handleCreateFolder} + /> + + { + setRenameDialogOpen(false); + setItemToRename(null); + }} + onConfirm={handleRenameConfirm} + /> + + { + setDeleteDialogOpen(false); + setItemToDelete(null); + }} + /> + + setSnackbar({ ...snackbar, open: false })} + > + setSnackbar({ ...snackbar, open: false })}> + {snackbar.message} + + +
+ ); +}; + +export default FileBrowser; diff --git a/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx b/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx new file mode 100644 index 0000000..0971e1b --- /dev/null +++ b/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface RenameDialogProps { + open: boolean; + currentName: string; + onClose: () => void; + onConfirm: (newName: string) => void; +} + +const RenameDialog: React.FC = ({ + open, + currentName, + onClose, + onConfirm, +}) => { + const { t } = useTranslation(['explorer', 'common']); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + setName(currentName); + setError(''); + } + }, [open, currentName]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedName = name.trim(); + if (!trimmedName) { + setError(t('explorer:browser.renameDialog.errors.required')); + return; + } + + if (trimmedName === currentName) { + setError(t('explorer:browser.renameDialog.errors.same')); + return; + } + + // Validate name + if (trimmedName.includes('/') || trimmedName.includes('\\')) { + setError(t('explorer:browser.renameDialog.errors.invalidChars')); + return; + } + + onConfirm(trimmedName); + }; + + return ( + +
+ {t('explorer:browser.renameDialog.title')} + + { + setName(e.target.value); + setError(''); + }} + error={!!error} + helperText={error} + /> + + + + + +
+
+ ); +}; + +export default RenameDialog; diff --git a/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx b/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx new file mode 100644 index 0000000..4396b8f --- /dev/null +++ b/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx @@ -0,0 +1,197 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + List, + ListItem, + ListItemIcon, + ListItemText, + IconButton, + LinearProgress, +} from '@mui/material'; +import { + CloudUpload as UploadIcon, + InsertDriveFile as FileIcon, + Delete as DeleteIcon, +} from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; + +interface UploadDialogProps { + open: boolean; + onClose: () => void; + onUpload: (files: File[]) => Promise; +} + +const UploadDialog: React.FC = ({ open, onClose, onUpload }) => { + const { t } = useTranslation(['explorer', 'common']); + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + + const droppedFiles = Array.from(e.dataTransfer.files); + setFiles((prev) => [...prev, ...droppedFiles]); + }, []); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + if (e.target.files) { + const selectedFiles = Array.from(e.target.files); + setFiles((prev) => [...prev, ...selectedFiles]); + } + }, []); + + const handleRemoveFile = useCallback((index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleUpload = useCallback(async () => { + if (files.length === 0) return; + + setUploading(true); + try { + await onUpload(files); + setFiles([]); + } finally { + setUploading(false); + } + }, [files, onUpload]); + + const handleClose = useCallback(() => { + if (!uploading) { + setFiles([]); + onClose(); + } + }, [uploading, onClose]); + + const formatSize = (bytes: number): string => { + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; + }; + + return ( + + {t('explorer:browser.uploadDialog.title')} + + {/* Drop zone */} + inputRef.current?.click()} + sx={{ + border: 2, + borderStyle: 'dashed', + borderColor: dragOver ? 'primary.main' : 'grey.400', + borderRadius: 2, + p: 4, + textAlign: 'center', + cursor: 'pointer', + bgcolor: dragOver ? 'action.hover' : 'transparent', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: 'primary.main', + bgcolor: 'action.hover', + }, + }} + > + + + + {t('explorer:browser.uploadDialog.dropzone')} + + + {t('explorer:browser.uploadDialog.or')} + + + + + {/* File list */} + {files.length > 0 && ( + + + {t('explorer:browser.uploadDialog.selectedFiles', { count: files.length })} + + + {files.map((file, index) => ( + handleRemoveFile(index)} + disabled={uploading} + > + + + } + > + + + + + + ))} + + + )} + + {/* Upload progress */} + {uploading && ( + + + + {t('explorer:browser.uploadDialog.uploading')} + + + )} + + + + + + + ); +}; + +export default UploadDialog; diff --git a/frontend/src/components/Explorer/Terminal/Terminal.tsx b/frontend/src/components/Explorer/Terminal/Terminal.tsx new file mode 100644 index 0000000..e74f951 --- /dev/null +++ b/frontend/src/components/Explorer/Terminal/Terminal.tsx @@ -0,0 +1,214 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + Box, + TextField, + IconButton, + Typography, + Paper, + Tooltip, + CircularProgress, +} from '@mui/material'; +import { + Clear as ClearIcon, + Send as SendIcon, +} from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { useTerminalStore } from '../../../store/terminalStore'; + +const Terminal: React.FC = () => { + const { t } = useTranslation(['explorer', 'common']); + const { + commandHistory, + outputHistory, + isExecuting, + executeCommand, + clearOutput, + } = useTerminalStore(); + + const [inputValue, setInputValue] = useState(''); + const [historyIndex, setHistoryIndex] = useState(-1); + const outputRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll to bottom when output changes + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [outputHistory]); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = useCallback(async (e?: React.FormEvent) => { + e?.preventDefault(); + const command = inputValue.trim(); + if (!command || isExecuting) return; + + setInputValue(''); + setHistoryIndex(-1); + await executeCommand(command); + }, [inputValue, isExecuting, executeCommand]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (commandHistory.length === 0) return; + + const newIndex = historyIndex < commandHistory.length - 1 + ? historyIndex + 1 + : historyIndex; + setHistoryIndex(newIndex); + setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || ''); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex <= 0) { + setHistoryIndex(-1); + setInputValue(''); + } else { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || ''); + } + } + }, [commandHistory, historyIndex]); + + const handleClear = useCallback(() => { + clearOutput(); + inputRef.current?.focus(); + }, [clearOutput]); + + return ( + + + {t('explorer:terminal.title')} + + + + + + + + + {/* Output area */} + + {outputHistory.length === 0 ? ( + + {t('explorer:terminal.welcomeMessage')} + + ) : ( + outputHistory.map((entry) => ( + + + $ {entry.command} + + {entry.output && ( +
{entry.output}
+ )} + {entry.error && ( +
{entry.error}
+ )} +
+ )) + )} + {isExecuting && ( + + + {t('explorer:terminal.executing')} + + )} +
+ + {/* Input area */} + + + $ + + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isExecuting} + placeholder={t('explorer:terminal.placeholder')} + variant="standard" + fullWidth + autoComplete="off" + InputProps={{ + disableUnderline: true, + sx: { + fontFamily: 'monospace', + fontSize: '14px', + color: 'grey.100', + '& input::placeholder': { + color: 'grey.600', + opacity: 1, + }, + }, + }} + /> + + + + + + + + +
+ + + {t('explorer:terminal.helpText')} + +
+ ); +}; + +export default Terminal; diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx index bf5e25e..11f6a2b 100644 --- a/frontend/src/components/Layout/Layout.tsx +++ b/frontend/src/components/Layout/Layout.tsx @@ -25,6 +25,7 @@ import { ChevronLeft as ChevronLeftIcon, Dashboard as DashboardIcon, Storage as StorageIcon, + FolderOpen as FolderOpenIcon, People as PeopleIcon, Policy as PolicyIcon, Assessment as AssessmentIcon, @@ -55,6 +56,7 @@ const Layout: React.FC = () => { const navItems: NavItem[] = [ { text: 'Dashboard', icon: , path: '/', translationKey: 'navigation.dashboard' }, { text: 'Buckets', icon: , path: '/buckets', translationKey: 'navigation.buckets' }, + { text: 'Explorer', icon: , path: '/explorer', translationKey: 'navigation.explorer' }, { text: 'Users', icon: , path: '/users', translationKey: 'navigation.users' }, { text: 'Policies', icon: , path: '/policies', translationKey: 'navigation.policies' }, { text: 'Reports', icon: , path: '/reports', translationKey: 'navigation.reports' }, @@ -224,7 +226,7 @@ const Layout: React.FC = () => { {navItems.map((item) => ( navigate(item.path)} > {item.icon} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 41f2307..cebfe00 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -8,12 +8,14 @@ import enDashboard from './locales/en/dashboard.json'; import enQuickWizard from './locales/en/quickWizard.json'; import enReports from './locales/en/reports.json'; import enErrors from './locales/en/errors.json'; +import enExplorer from './locales/en/explorer.json'; import deCommon from './locales/de/common.json'; import deDashboard from './locales/de/dashboard.json'; import deQuickWizard from './locales/de/quickWizard.json'; import deReports from './locales/de/reports.json'; import deErrors from './locales/de/errors.json'; +import deExplorer from './locales/de/explorer.json'; const resources = { en: { @@ -22,6 +24,7 @@ const resources = { quickWizard: enQuickWizard, reports: enReports, errors: enErrors, + explorer: enExplorer, }, de: { common: deCommon, @@ -29,6 +32,7 @@ const resources = { quickWizard: deQuickWizard, reports: deReports, errors: deErrors, + explorer: deExplorer, }, }; @@ -39,7 +43,7 @@ i18n resources, lng: 'de', // Default to German fallbackLng: 'en', - ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors'], + ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors', 'explorer'], defaultNS: 'common', interpolation: { escapeValue: false, // React already escapes values diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json index 3bccb6d..2922b24 100644 --- a/frontend/src/locales/de/common.json +++ b/frontend/src/locales/de/common.json @@ -3,6 +3,7 @@ "navigation": { "dashboard": "Übersicht", "buckets": "Buckets", + "explorer": "Explorer", "users": "Benutzer", "policies": "Richtlinien", "reports": "Berichte", diff --git a/frontend/src/locales/de/explorer.json b/frontend/src/locales/de/explorer.json new file mode 100644 index 0000000..a5ee235 --- /dev/null +++ b/frontend/src/locales/de/explorer.json @@ -0,0 +1,133 @@ +{ + "title": "Speicher-Explorer", + "tabs": { + "aliases": "Aliase", + "terminal": "Terminal" + }, + "aliases": { + "title": "MinIO Aliase", + "addAlias": "Alias hinzufügen", + "editAlias": "Alias bearbeiten", + "deleteAlias": "Alias löschen", + "testConnection": "Verbindung testen", + "columns": { + "name": "Name", + "endpoint": "Endpunkt", + "status": "Status", + "actions": "Aktionen" + }, + "status": { + "connected": "Verbunden", + "disconnected": "Getrennt", + "unknown": "Unbekannt", + "testing": "Teste..." + }, + "dialog": { + "title": "Alias-Konfiguration", + "aliasName": "Alias-Name", + "endpoint": "Endpunkt-URL", + "accessKey": "Zugriffsschlüssel", + "secretKey": "Geheimer Schlüssel", + "aliasNameHelper": "Ein eindeutiger Name für diese Verbindung (z.B. produktion, staging)", + "endpointHelper": "MinIO Server-URL (z.B. https://minio.example.com)" + }, + "empty": { + "title": "Keine Aliase konfiguriert", + "message": "Fügen Sie einen MinIO-Alias hinzu, um Dateien zu durchsuchen" + }, + "messages": { + "created": "Alias erfolgreich erstellt", + "updated": "Alias erfolgreich aktualisiert", + "deleted": "Alias erfolgreich gelöscht", + "testSuccess": "Verbindung erfolgreich", + "testFailed": "Verbindung fehlgeschlagen" + } + }, + "terminal": { + "title": "MinIO CLI Terminal", + "placeholder": "mc Befehl eingeben (z.B. mc ls myminio)", + "clear": "Löschen", + "execute": "Ausführen", + "executing": "Wird ausgeführt...", + "running": "Wird ausgeführt...", + "hint": "Pfeiltasten für Befehlsverlauf verwenden", + "welcomeMessage": "Willkommen im MinIO CLI Terminal. Geben Sie 'mc' Befehle ein, um mit Ihren MinIO-Servern zu interagieren.", + "helpText": "Tipp: Verwenden Sie Pfeiltasten hoch/runter für den Befehlsverlauf" + }, + "browser": { + "title": "Datei-Browser", + "selectBucket": "Bucket auswählen", + "upload": "Hochladen", + "createFolder": "Ordner erstellen", + "newFolder": "Neuer Ordner", + "download": "Herunterladen", + "delete": "Löschen", + "rename": "Umbenennen", + "copy": "Kopieren", + "refresh": "Aktualisieren", + "deleteItem": "Element löschen", + "selected": "{{count}} Element(e) ausgewählt", + "columns": { + "name": "Name", + "size": "Größe", + "type": "Typ", + "lastModified": "Zuletzt geändert", + "actions": "Aktionen" + }, + "types": { + "file": "Datei", + "folder": "Ordner" + }, + "empty": { + "title": "Keine Dateien", + "message": "Dieser Ordner ist leer. Laden Sie Dateien hoch oder erstellen Sie einen neuen Ordner." + }, + "errors": { + "missingParams": "Fehlender Alias- oder Bucket-Parameter" + }, + "uploadDialog": { + "title": "Dateien hochladen", + "dropzone": "Dateien hierher ziehen", + "or": "oder", + "browse": "Dateien durchsuchen", + "selectedFiles": "{{count}} Datei(en) ausgewählt", + "uploading": "Wird hochgeladen..." + }, + "createFolderDialog": { + "title": "Ordner erstellen", + "nameLabel": "Ordnername", + "nameHelper": "Geben Sie einen Namen für den neuen Ordner ein", + "errors": { + "required": "Ordnername ist erforderlich", + "invalidChars": "Ordnername darf kein / oder \\ enthalten", + "dotStart": "Ordnername darf nicht mit einem Punkt beginnen" + } + }, + "renameDialog": { + "title": "Umbenennen", + "newNameLabel": "Neuer Name", + "errors": { + "required": "Name ist erforderlich", + "same": "Neuer Name muss sich vom aktuellen Namen unterscheiden", + "invalidChars": "Name darf kein / oder \\ enthalten" + } + }, + "deleteConfirm": { + "title": "{{name}} löschen?", + "message": "Diese Aktion kann nicht rückgängig gemacht werden.", + "folderWarning": "Dieser Ordner und sein gesamter Inhalt werden dauerhaft gelöscht." + }, + "messages": { + "folderCreated": "Ordner erfolgreich erstellt", + "folderCreateFailed": "Ordner konnte nicht erstellt werden", + "renamed": "Element erfolgreich umbenannt", + "renameFailed": "Element konnte nicht umbenannt werden", + "deleted": "Element erfolgreich gelöscht", + "deleteFailed": "Element konnte nicht gelöscht werden", + "downloadStarted": "Download gestartet", + "downloadFailed": "Datei konnte nicht heruntergeladen werden", + "uploaded": "Dateien erfolgreich hochgeladen", + "uploadFailed": "Dateien konnten nicht hochgeladen werden" + } + } +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index e08a510..7e423a3 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -3,6 +3,7 @@ "navigation": { "dashboard": "Dashboard", "buckets": "Buckets", + "explorer": "Explorer", "users": "Users", "policies": "Policies", "reports": "Reports", diff --git a/frontend/src/locales/en/explorer.json b/frontend/src/locales/en/explorer.json new file mode 100644 index 0000000..f75032f --- /dev/null +++ b/frontend/src/locales/en/explorer.json @@ -0,0 +1,133 @@ +{ + "title": "Storage Explorer", + "tabs": { + "aliases": "Aliases", + "terminal": "Terminal" + }, + "aliases": { + "title": "MinIO Aliases", + "addAlias": "Add Alias", + "editAlias": "Edit Alias", + "deleteAlias": "Delete Alias", + "testConnection": "Test Connection", + "columns": { + "name": "Name", + "endpoint": "Endpoint", + "status": "Status", + "actions": "Actions" + }, + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "unknown": "Unknown", + "testing": "Testing..." + }, + "dialog": { + "title": "Alias Configuration", + "aliasName": "Alias Name", + "endpoint": "Endpoint URL", + "accessKey": "Access Key", + "secretKey": "Secret Key", + "aliasNameHelper": "A unique name for this connection (e.g., production, staging)", + "endpointHelper": "MinIO server URL (e.g., https://minio.example.com)" + }, + "empty": { + "title": "No aliases configured", + "message": "Add a MinIO alias to start browsing files" + }, + "messages": { + "created": "Alias created successfully", + "updated": "Alias updated successfully", + "deleted": "Alias deleted successfully", + "testSuccess": "Connection successful", + "testFailed": "Connection failed" + } + }, + "terminal": { + "title": "MinIO CLI Terminal", + "placeholder": "Enter mc command (e.g., mc ls myminio)", + "clear": "Clear", + "execute": "Execute", + "executing": "Executing...", + "running": "Running...", + "hint": "Use arrow keys to navigate command history", + "welcomeMessage": "Welcome to MinIO CLI Terminal. Type 'mc' commands to interact with your MinIO servers.", + "helpText": "Tip: Use up/down arrow keys to navigate command history" + }, + "browser": { + "title": "File Browser", + "selectBucket": "Select Bucket", + "upload": "Upload", + "createFolder": "Create Folder", + "newFolder": "New Folder", + "download": "Download", + "delete": "Delete", + "rename": "Rename", + "copy": "Copy", + "refresh": "Refresh", + "deleteItem": "Delete Item", + "selected": "{{count}} item(s) selected", + "columns": { + "name": "Name", + "size": "Size", + "type": "Type", + "lastModified": "Last Modified", + "actions": "Actions" + }, + "types": { + "file": "File", + "folder": "Folder" + }, + "empty": { + "title": "No files", + "message": "This folder is empty. Upload files or create a new folder." + }, + "errors": { + "missingParams": "Missing alias or bucket parameter" + }, + "uploadDialog": { + "title": "Upload Files", + "dropzone": "Drag and drop files here", + "or": "or", + "browse": "Browse Files", + "selectedFiles": "{{count}} file(s) selected", + "uploading": "Uploading..." + }, + "createFolderDialog": { + "title": "Create Folder", + "nameLabel": "Folder Name", + "nameHelper": "Enter a name for the new folder", + "errors": { + "required": "Folder name is required", + "invalidChars": "Folder name cannot contain / or \\", + "dotStart": "Folder name cannot start with a dot" + } + }, + "renameDialog": { + "title": "Rename", + "newNameLabel": "New Name", + "errors": { + "required": "Name is required", + "same": "New name must be different from current name", + "invalidChars": "Name cannot contain / or \\" + } + }, + "deleteConfirm": { + "title": "Delete {{name}}?", + "message": "This action cannot be undone.", + "folderWarning": "This folder and all its contents will be permanently deleted." + }, + "messages": { + "folderCreated": "Folder created successfully", + "folderCreateFailed": "Failed to create folder", + "renamed": "Item renamed successfully", + "renameFailed": "Failed to rename item", + "deleted": "Item deleted successfully", + "deleteFailed": "Failed to delete item", + "downloadStarted": "Download started", + "downloadFailed": "Failed to download file", + "uploaded": "Files uploaded successfully", + "uploadFailed": "Failed to upload files" + } + } +} diff --git a/frontend/src/services/aliasService.ts b/frontend/src/services/aliasService.ts new file mode 100644 index 0000000..c0d2211 --- /dev/null +++ b/frontend/src/services/aliasService.ts @@ -0,0 +1,52 @@ +import api from './api'; + +export interface Alias { + alias: string; + URL: string; + accessKey: string; + status: 'connected' | 'disconnected' | 'unknown'; +} + +export interface AliasInput { + aliasName: string; + endpoint: string; + accessKey: string; + secretKey: string; +} + +export interface ConnectionStatus { + status: string; + alias: string; + error?: string; +} + +class AliasService { + async listAliases(): Promise { + const response = await api.get<{ aliases: Alias[]; count: number }>('/aliases'); + return response.data.aliases; + } + + async getAlias(name: string): Promise { + const response = await api.get(`/aliases/${name}`); + return response.data; + } + + async addAlias(alias: AliasInput): Promise { + await api.post('/aliases', alias); + } + + async updateAlias(name: string, alias: Omit): Promise { + await api.put(`/aliases/${name}`, alias); + } + + async removeAlias(name: string): Promise { + await api.delete(`/aliases/${name}`); + } + + async testConnection(name: string): Promise { + const response = await api.post(`/aliases/${name}/test`); + return response.data; + } +} + +export default new AliasService(); diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts new file mode 100644 index 0000000..a408792 --- /dev/null +++ b/frontend/src/services/fileService.ts @@ -0,0 +1,123 @@ +import api from './api'; + +export interface FileEntry { + key: string; + name: string; + size: number; + sizeFormatted: string; + lastModified: string | null; + type: 'file' | 'folder'; + etag?: string | null; +} + +export interface ListFilesResponse { + objects: FileEntry[]; + prefix: string; + count: number; +} + +export interface FileStat { + key: string; + name: string; + size: number; + sizeFormatted: string; + lastModified: string | null; + contentType: string; + etag: string | null; + metadata: Record; +} + +export interface PathSize { + path: string; + size: number; + sizeFormatted: string; + objects: number; +} + +class FileService { + async listFiles(alias: string, bucket: string, prefix?: string, recursive?: boolean): Promise { + const response = await api.get(`/browser/${alias}/${bucket}`, { + params: { prefix, recursive }, + }); + return response.data.objects; + } + + async getFileStat(alias: string, bucket: string, path: string): Promise { + const response = await api.get(`/browser/${alias}/${bucket}/stat/${encodeURIComponent(path)}`); + return response.data; + } + + async getPathSize(alias: string, bucket: string, path?: string): Promise { + const url = path + ? `/browser/${alias}/${bucket}/size/${encodeURIComponent(path)}` + : `/browser/${alias}/${bucket}/size`; + const response = await api.get(url); + return response.data; + } + + async uploadFiles(alias: string, bucket: string, files: File[], prefix?: string): Promise<{ + uploaded: { name: string; key: string; size: number }[]; + failed: { name: string; error: string }[]; + count: number; + }> { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + + const response = await api.post(`/browser/${alias}/${bucket}/upload`, formData, { + params: { prefix }, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000, // 5 min for large uploads + }); + return response.data; + } + + async deleteFile(alias: string, bucket: string, path: string, recursive?: boolean): Promise { + await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, { + params: { recursive }, + }); + } + + async renameFile(alias: string, bucket: string, source: string, destination: string): Promise { + await api.post(`/browser/${alias}/${bucket}/rename`, { + source, + destination, + }); + } + + async copyFile(alias: string, bucket: string, source: string, destination: string): Promise { + await api.post(`/browser/${alias}/${bucket}/copy`, { + source, + destination, + }); + } + + async createFolder(alias: string, bucket: string, folderName: string, prefix?: string): Promise { + await api.post( + `/browser/${alias}/${bucket}/mkdir`, + { folderName }, + { params: { prefix } } + ); + } + + async downloadFile(alias: string, bucket: string, path: string): Promise { + const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, { + responseType: 'blob', + timeout: 300000, // 5 min for large downloads + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + const fileName = path.split('/').pop() || 'download'; + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } +} + +export default new FileService(); diff --git a/frontend/src/services/terminalService.ts b/frontend/src/services/terminalService.ts new file mode 100644 index 0000000..48e4293 --- /dev/null +++ b/frontend/src/services/terminalService.ts @@ -0,0 +1,21 @@ +import api from './api'; + +export interface CommandResponse { + success: boolean; + output: string; + error: string; + exitCode: number; + executionTime: number; +} + +class TerminalService { + async executeCommand(command: string, timeout?: number): Promise { + const response = await api.post('/terminal/execute', { + command, + timeout, + }); + return response.data; + } +} + +export default new TerminalService(); diff --git a/frontend/src/store/explorerStore.ts b/frontend/src/store/explorerStore.ts new file mode 100644 index 0000000..4fb841d --- /dev/null +++ b/frontend/src/store/explorerStore.ts @@ -0,0 +1,235 @@ +import { create } from 'zustand'; +import api from '../services/api'; + +export interface Alias { + alias: string; + URL: string; + accessKey: string; + status: 'connected' | 'disconnected' | 'unknown'; +} + +export interface FileEntry { + key: string; + name: string; + size: number; + sizeFormatted: string; + lastModified: string | null; + type: 'file' | 'folder'; + etag?: string | null; + contentType?: string | null; +} + +interface ExplorerState { + // Aliases + aliases: Alias[]; + aliasesLoading: boolean; + aliasesError: string | null; + + // File Browser + currentAlias: string | null; + currentBucket: string | null; + currentPath: string; + files: FileEntry[]; + filesLoading: boolean; + filesError: string | null; + selectedFiles: string[]; + + // Actions - Aliases + loadAliases: () => Promise; + addAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise; + updateAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise; + removeAlias: (aliasName: string) => Promise; + testConnection: (aliasName: string) => Promise<{ status: string; error?: string }>; + + // Actions - File Browser + setCurrentLocation: (alias: string | null, bucket: string | null, path: string) => void; + loadFiles: (alias: string, bucket: string, prefix?: string) => Promise; + uploadFiles: (alias: string, bucket: string, files: File[], prefix?: string) => Promise; + deleteFile: (alias: string, bucket: string, path: string, recursive?: boolean) => Promise; + renameFile: (alias: string, bucket: string, source: string, destination: string) => Promise; + createFolder: (alias: string, bucket: string, folderName: string, prefix?: string) => Promise; + downloadFile: (alias: string, bucket: string, path: string) => Promise; + + // Selection + toggleFileSelection: (key: string) => void; + clearSelection: () => void; + selectAll: () => void; +} + +export const useExplorerStore = create((set, get) => ({ + // Initial state + aliases: [], + aliasesLoading: false, + aliasesError: null, + + currentAlias: null, + currentBucket: null, + currentPath: '', + files: [], + filesLoading: false, + filesError: null, + selectedFiles: [], + + // Alias Actions + loadAliases: async () => { + set({ aliasesLoading: true, aliasesError: null }); + try { + const response = await api.get<{ aliases: Alias[] }>('/aliases'); + set({ aliases: response.data.aliases, aliasesLoading: false }); + } catch (error: any) { + set({ + aliasesError: error.response?.data?.message || 'Failed to load aliases', + aliasesLoading: false, + }); + throw error; + } + }, + + addAlias: async (aliasName, endpoint, accessKey, secretKey) => { + const response = await api.post('/aliases', { + aliasName, + endpoint, + accessKey, + secretKey, + }); + // Reload aliases after adding + await get().loadAliases(); + return response.data; + }, + + updateAlias: async (aliasName, endpoint, accessKey, secretKey) => { + await api.put(`/aliases/${aliasName}`, { + endpoint, + accessKey, + secretKey, + }); + await get().loadAliases(); + }, + + removeAlias: async (aliasName) => { + await api.delete(`/aliases/${aliasName}`); + await get().loadAliases(); + }, + + testConnection: async (aliasName) => { + const response = await api.post<{ status: string; error?: string }>(`/aliases/${aliasName}/test`); + return response.data; + }, + + // File Browser Actions + setCurrentLocation: (alias, bucket, path) => { + set({ + currentAlias: alias, + currentBucket: bucket, + currentPath: path, + selectedFiles: [], + }); + }, + + loadFiles: async (alias, bucket, prefix = '') => { + set({ filesLoading: true, filesError: null, selectedFiles: [] }); + try { + const response = await api.get<{ objects: FileEntry[]; prefix: string; count: number }>( + `/browser/${alias}/${bucket}`, + { params: { prefix } } + ); + set({ + files: response.data.objects, + currentPath: prefix, + filesLoading: false, + }); + } catch (error: any) { + set({ + filesError: error.response?.data?.message || 'Failed to load files', + filesLoading: false, + files: [], + }); + throw error; + } + }, + + uploadFiles: async (alias, bucket, files, prefix = '') => { + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); + + await api.post(`/browser/${alias}/${bucket}/upload`, formData, { + params: { prefix }, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000, // 5 min for large uploads + }); + + // Reload files after upload + await get().loadFiles(alias, bucket, prefix); + }, + + deleteFile: async (alias, bucket, path, recursive = false) => { + await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, { + params: { recursive }, + }); + // Reload files after delete + const state = get(); + await get().loadFiles(alias, bucket, state.currentPath); + }, + + renameFile: async (alias, bucket, source, destination) => { + await api.post(`/browser/${alias}/${bucket}/rename`, { + source, + destination, + }); + // Reload files after rename + const state = get(); + await get().loadFiles(alias, bucket, state.currentPath); + }, + + createFolder: async (alias, bucket, folderName, prefix = '') => { + await api.post( + `/browser/${alias}/${bucket}/mkdir`, + { folderName }, + { params: { prefix } } + ); + // Reload files after creating folder + await get().loadFiles(alias, bucket, prefix); + }, + + downloadFile: async (alias, bucket, path) => { + const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, { + responseType: 'blob', + timeout: 300000, // 5 min for large downloads + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + const fileName = path.split('/').pop() || 'download'; + link.href = url; + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // Selection Actions + toggleFileSelection: (key) => { + set((state) => { + const isSelected = state.selectedFiles.includes(key); + return { + selectedFiles: isSelected + ? state.selectedFiles.filter((k) => k !== key) + : [...state.selectedFiles, key], + }; + }); + }, + + clearSelection: () => { + set({ selectedFiles: [] }); + }, + + selectAll: () => { + set((state) => ({ + selectedFiles: state.files.map((f) => f.key), + })); + }, +})); diff --git a/frontend/src/store/terminalStore.ts b/frontend/src/store/terminalStore.ts new file mode 100644 index 0000000..fd38c28 --- /dev/null +++ b/frontend/src/store/terminalStore.ts @@ -0,0 +1,128 @@ +import { create } from 'zustand'; +import api from '../services/api'; + +export interface OutputEntry { + id: string; + command: string; + output: string; + error: string; + isError: boolean; + timestamp: Date; + executionTime: number; +} + +interface TerminalState { + commandHistory: string[]; + outputHistory: OutputEntry[]; + currentCommand: string; + historyIndex: number; + isExecuting: boolean; + + // Actions + executeCommand: (command: string) => Promise; + clearOutput: () => void; + navigateHistory: (direction: 'up' | 'down') => void; + setCurrentCommand: (command: string) => void; +} + +export const useTerminalStore = create((set, get) => ({ + commandHistory: [], + outputHistory: [], + currentCommand: '', + historyIndex: -1, + isExecuting: false, + + executeCommand: async (command: string) => { + if (!command.trim()) return; + + const state = get(); + + // Add to command history (avoid duplicates at the end) + const newHistory = [...state.commandHistory]; + if (newHistory[newHistory.length - 1] !== command) { + newHistory.push(command); + } + + set({ + isExecuting: true, + currentCommand: '', + commandHistory: newHistory, + historyIndex: -1, + }); + + try { + const response = await api.post<{ + success: boolean; + output: string; + error: string; + exitCode: number; + executionTime: number; + }>('/terminal/execute', { command }); + + const entry: OutputEntry = { + id: Date.now().toString(), + command, + output: response.data.output, + error: response.data.error, + isError: !response.data.success, + timestamp: new Date(), + executionTime: response.data.executionTime, + }; + + set((state) => ({ + outputHistory: [...state.outputHistory, entry], + isExecuting: false, + })); + } catch (error: any) { + const entry: OutputEntry = { + id: Date.now().toString(), + command, + output: '', + error: error.response?.data?.message || error.message || 'Command execution failed', + isError: true, + timestamp: new Date(), + executionTime: 0, + }; + + set((state) => ({ + outputHistory: [...state.outputHistory, entry], + isExecuting: false, + })); + } + }, + + clearOutput: () => { + set({ outputHistory: [] }); + }, + + navigateHistory: (direction: 'up' | 'down') => { + const state = get(); + const { commandHistory, historyIndex } = state; + + if (commandHistory.length === 0) return; + + let newIndex: number; + if (direction === 'up') { + newIndex = historyIndex === -1 + ? commandHistory.length - 1 + : Math.max(0, historyIndex - 1); + } else { + newIndex = historyIndex === -1 + ? -1 + : Math.min(commandHistory.length - 1, historyIndex + 1); + + if (historyIndex === commandHistory.length - 1) { + newIndex = -1; + } + } + + set({ + historyIndex: newIndex, + currentCommand: newIndex === -1 ? '' : commandHistory[newIndex], + }); + }, + + setCurrentCommand: (command: string) => { + set({ currentCommand: command, historyIndex: -1 }); + }, +}));