feat: Add Storage Explorer with CLI terminal, alias management, and file browser

- Add CLI terminal for executing mc commands from the web interface
  - Command history with up/down arrow navigation
  - Dark theme with monospace font
  - Auto-scroll output and loading states

- Add alias management page
  - List all MinIO aliases with connection status
  - Add, edit, delete aliases
  - Test connection functionality

- Add file browser for navigating bucket contents
  - Breadcrumb navigation (alias > bucket > path)
  - File/folder table with size, type, last modified
  - Upload files (drag-and-drop support)
  - Download, rename, delete operations
  - Create new folders
  - Multi-select for batch delete

Backend:
- New API routes: /api/terminal, /api/aliases, /api/browser
- Multer middleware for file uploads
- Extended minio.service.js with file operations

Frontend:
- New Explorer component with Aliases/Terminal tabs
- Zustand stores for terminal and explorer state
- i18n translations (English and German)
This commit is contained in:
Paul Nothaft
2026-01-04 22:19:03 +01:00
parent feae9882bc
commit f24ed78c38
28 changed files with 3844 additions and 3 deletions
+2 -1
View File
@@ -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",
+254
View File
@@ -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;
+486
View File
@@ -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;
+82
View File
@@ -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;
+22
View File
@@ -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) => {
+129
View File
@@ -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,
};
+321
View File
@@ -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 = [/;/, /\|/, /`/, /\$\(/, />>?/, /<</, /&&/, /\|\|/];
for (const pattern of blockedPatterns) {
if (pattern.test(command)) {
return {
success: false,
output: '',
error: 'Command contains forbidden shell operators',
exitCode: 1,
executionTime: Date.now() - startTime,
};
}
}
try {
logger.debug(`Executing raw command: ${command}`);
const { stdout, stderr } = await execAsync(command, {
timeout,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, MC_NO_COLOR: '1' },
});
return {
success: true,
output: stdout || '',
error: stderr || '',
exitCode: 0,
executionTime: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
output: error.stdout || '',
error: error.stderr || error.message,
exitCode: error.code || 1,
executionTime: Date.now() - startTime,
};
}
}
// ============================================
// Extended Alias Management Methods
// ============================================
async getAlias(aliasName) {
const aliases = await this.listAliases();
const alias = aliases.find(a => 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';