Add MinIO file browser with upload, preview, and management features

- Implement file browser UI with upload/download capabilities
- Add API endpoints for file operations (list, upload, delete, preview)
- Create FileUploader and FilePreviewModal components
- Configure MinIO integration with environment variables
- Add documentation for MinIO file browser setup
This commit is contained in:
2025-06-04 16:32:50 +02:00
parent 42efcf3ce1
commit 61cefa530e
16 changed files with 2017 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { createFolder } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { folderPath } = body;
if (!folderPath) {
throw createError({
statusCode: 400,
statusMessage: 'Folder path is required',
});
}
// Create the folder
await createFolder(folderPath);
// Log audit event
await logAuditEvent(event, 'create_folder', folderPath);
return {
success: true,
message: 'Folder created successfully',
folderPath,
};
} catch (error: any) {
console.error('Failed to create folder:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create folder',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

View File

@@ -0,0 +1,62 @@
import { deleteFile, deleteFolder } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { fileName, isFolder } = body;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Delete folder or file based on type
if (isFolder) {
await deleteFolder(fileName);
} else {
await deleteFile(fileName);
}
// Log audit event
await logAuditEvent(event, 'delete', fileName);
return {
success: true,
message: isFolder ? 'Folder deleted successfully' : 'File deleted successfully',
};
} catch (error: any) {
console.error('Failed to delete:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

View File

@@ -0,0 +1,59 @@
import { getDownloadUrl } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Generate presigned URL valid for 1 hour
const url = await getDownloadUrl(fileName);
// Log audit event
await logAuditEvent(event, 'download', fileName);
return {
success: true,
url,
fileName,
};
} catch (error: any) {
console.error('Failed to generate download URL:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate download URL',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

86
server/api/files/list.ts Normal file
View File

@@ -0,0 +1,86 @@
import { listFiles } from '~/server/utils/minio';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const prefix = (query.prefix as string) || '';
const recursive = query.recursive === 'true';
const files = await listFiles(prefix, recursive);
// Format file list with additional metadata
const formattedFiles = (files as any[]).map(file => ({
...file,
sizeFormatted: file.isFolder ? '-' : formatFileSize(file.size),
extension: file.isFolder ? 'folder' : getFileExtension(file.name),
icon: file.isFolder ? 'mdi-folder' : getFileIcon(file.name),
displayName: getDisplayName(file.name),
}));
// Sort folders first, then files
formattedFiles.sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return a.displayName.localeCompare(b.displayName);
});
return {
success: true,
files: formattedFiles,
count: formattedFiles.length,
currentPath: prefix,
};
} catch (error) {
console.error('Failed to list files:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to list files',
});
}
});
// Helper functions
function formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
function getFileExtension(filename: string): string {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop()?.toLowerCase() || '' : '';
}
function getFileIcon(filename: string): string {
const ext = getFileExtension(filename);
const iconMap: Record<string, string> = {
pdf: 'mdi-file-pdf-box',
doc: 'mdi-file-document',
docx: 'mdi-file-document',
xls: 'mdi-file-excel',
xlsx: 'mdi-file-excel',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
png: 'mdi-file-image',
gif: 'mdi-file-image',
svg: 'mdi-file-image',
zip: 'mdi-folder-zip',
rar: 'mdi-folder-zip',
txt: 'mdi-file-document-outline',
csv: 'mdi-file-delimited',
mp4: 'mdi-file-video',
mp3: 'mdi-file-music',
};
return iconMap[ext] || 'mdi-file';
}
function getDisplayName(filepath: string): string {
// Get just the filename from the full path
const parts = filepath.split('/');
const filename = parts[parts.length - 1];
// Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf")
const match = filename.match(/^\d{10,}-(.+)$/);
return match ? match[1] : filename;
}

View File

@@ -0,0 +1,53 @@
import { getPreviewUrl } from '~/server/utils/minio';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const fileName = query.fileName as string;
if (!fileName) {
throw createError({
statusCode: 400,
statusMessage: 'File name is required',
});
}
// Get content type
const contentType = mime.lookup(fileName) || 'application/octet-stream';
// Check if file type supports preview
const supportedPreviewTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/svg+xml',
'image/webp',
'application/pdf',
];
if (!supportedPreviewTypes.includes(contentType)) {
throw createError({
statusCode: 400,
statusMessage: 'File type does not support preview',
});
}
// Generate presigned URL for preview
const url = await getPreviewUrl(fileName, contentType);
return {
success: true,
url,
fileName,
contentType,
};
} catch (error: any) {
console.error('Failed to generate preview URL:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to generate preview URL',
});
}
});

View File

@@ -0,0 +1,97 @@
import { uploadFile } from '~/server/utils/minio';
import formidable from 'formidable';
import { promises as fs } from 'fs';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
try {
// Get the current path from query params
const query = getQuery(event);
const currentPath = (query.path as string) || '';
// Parse multipart form data
const form = formidable({
maxFileSize: 50 * 1024 * 1024, // 50MB limit
keepExtensions: true,
});
const [fields, files] = await form.parse(event.node.req);
// Handle multiple files
const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file];
const results = [];
for (const uploadedFile of uploadedFiles) {
if (!uploadedFile) continue;
// Read file buffer
const fileBuffer = await fs.readFile(uploadedFile.filepath);
// Generate unique filename to prevent collisions
const timestamp = Date.now();
const sanitizedName = uploadedFile.originalFilename?.replace(/[^a-zA-Z0-9.-]/g, '_') || 'file';
const fileName = `${timestamp}-${sanitizedName}`;
// Construct full path including current folder
const fullPath = currentPath ? `${currentPath}${fileName}` : fileName;
// Get content type
const contentType = mime.lookup(uploadedFile.originalFilename || '') || 'application/octet-stream';
// Upload to MinIO
await uploadFile(fullPath, fileBuffer, contentType);
// Clean up temp file
await fs.unlink(uploadedFile.filepath);
results.push({
fileName: fullPath,
originalName: uploadedFile.originalFilename,
size: uploadedFile.size,
contentType,
});
// Log audit event
await logAuditEvent(event, 'upload', fullPath, uploadedFile.size);
}
return {
success: true,
files: results,
message: `${results.length} file(s) uploaded successfully`,
};
} catch (error: any) {
console.error('Failed to upload file:', error);
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to upload file',
});
}
});
// Audit logging helper
async function logAuditEvent(event: any, action: string, filePath: string, fileSize?: number) {
try {
const user = event.context.user || { email: 'anonymous' };
const auditLog = {
user_email: user.email,
action,
file_path: filePath,
file_size: fileSize,
timestamp: new Date().toISOString(),
ip_address: getClientIP(event),
success: true,
};
// You can store this in your database or logging system
console.log('Audit log:', auditLog);
} catch (error) {
console.error('Failed to log audit event:', error);
}
}
function getClientIP(event: any): string {
return event.node.req.headers['x-forwarded-for'] ||
event.node.req.connection.remoteAddress ||
'unknown';
}

167
server/utils/minio.ts Normal file
View File

@@ -0,0 +1,167 @@
import { Client } from 'minio';
import type { BucketItem } from 'minio';
// Initialize MinIO client
export const getMinioClient = () => {
const config = useRuntimeConfig().minio;
return new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
});
};
// File listing with metadata
export const listFiles = async (prefix: string = '', recursive: boolean = false) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
const files: any[] = [];
const folders = new Set<string>();
return new Promise((resolve, reject) => {
const stream = client.listObjectsV2(bucketName, prefix, recursive);
stream.on('data', (obj) => {
if (!recursive && prefix) {
// Extract folder structure when not recursive
const relativePath = obj.name.substring(prefix.length);
const firstSlash = relativePath.indexOf('/');
if (firstSlash > -1) {
// This is a folder
const folderName = relativePath.substring(0, firstSlash);
folders.add(prefix + folderName + '/');
} else if (relativePath) {
// This is a file in the current folder
files.push({
name: obj.name,
size: obj.size,
lastModified: obj.lastModified,
etag: obj.etag,
isFolder: false,
});
}
} else {
// When recursive or at root, include all files
if (!obj.name.endsWith('/')) {
files.push({
name: obj.name,
size: obj.size,
lastModified: obj.lastModified,
etag: obj.etag,
isFolder: false,
});
}
}
});
stream.on('error', reject);
stream.on('end', () => {
// Add folders to the result
const folderItems = Array.from(folders).map(folder => ({
name: folder,
size: 0,
lastModified: new Date(),
etag: '',
isFolder: true,
}));
resolve([...folderItems, ...files]);
});
});
};
// Upload file
export const uploadFile = async (filePath: string, fileBuffer: Buffer, contentType: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.putObject(bucketName, filePath, fileBuffer, fileBuffer.length, {
'Content-Type': contentType,
});
};
// Generate presigned URL for download
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.presignedGetObject(bucketName, fileName, expiry);
};
// Delete file
export const deleteFile = async (fileName: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.removeObject(bucketName, fileName);
};
// Delete folder (recursively delete all contents)
export const deleteFolder = async (folderPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// List all objects in the folder
const objectsList: string[] = [];
return new Promise((resolve, reject) => {
const stream = client.listObjectsV2(bucketName, folderPath, true);
stream.on('data', (obj) => {
objectsList.push(obj.name);
});
stream.on('error', reject);
stream.on('end', async () => {
try {
// Delete all objects
if (objectsList.length > 0) {
await client.removeObjects(bucketName, objectsList);
}
resolve(true);
} catch (error) {
reject(error);
}
});
});
};
// Get file stats
export const getFileStats = async (fileName: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
return await client.statObject(bucketName, fileName);
};
// Create folder (MinIO doesn't have explicit folders, so we create a placeholder)
export const createFolder = async (folderPath: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// Ensure folder path ends with /
const normalizedPath = folderPath.endsWith('/') ? folderPath : folderPath + '/';
// Create an empty object to represent the folder
return await client.putObject(bucketName, normalizedPath, Buffer.from(''), 0);
};
// Get presigned URL for file preview
export const getPreviewUrl = async (fileName: string, contentType: string) => {
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// For images and PDFs, generate a presigned URL with appropriate response headers
const responseHeaders = {
'response-content-type': contentType,
'response-content-disposition': 'inline',
};
return await client.presignedGetObject(bucketName, fileName, 60 * 60, responseHeaders);
};