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';
|
||||
}
|
||||
167
server/utils/minio.ts
Normal file
167
server/utils/minio.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user