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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user