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:
59
server/api/files/create-folder.ts
Normal file
59
server/api/files/create-folder.ts
Normal 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';
|
||||
}
|
||||
62
server/api/files/delete.ts
Normal file
62
server/api/files/delete.ts
Normal 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';
|
||||
}
|
||||
59
server/api/files/download.ts
Normal file
59
server/api/files/download.ts
Normal 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
86
server/api/files/list.ts
Normal 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;
|
||||
}
|
||||
53
server/api/files/preview.ts
Normal file
53
server/api/files/preview.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
97
server/api/files/upload.ts
Normal file
97
server/api/files/upload.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user