1976 lines
50 KiB
Plaintext
1976 lines
50 KiB
Plaintext
# MinIO Implementation Guide - Port Nimara Style
|
|
|
|
This guide provides a comprehensive implementation of MinIO object storage integration based on the Port Nimara Client Portal project. It covers everything from basic setup to advanced features like image thumbnails, lazy loading, and secure file access.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Project Setup & Configuration](#project-setup--configuration)
|
|
2. [Core MinIO Utility Functions](#core-minio-utility-functions)
|
|
3. [API Endpoints Implementation](#api-endpoints-implementation)
|
|
4. [Frontend Components](#frontend-components)
|
|
5. [Advanced Features](#advanced-features)
|
|
6. [Security & Authentication](#security--authentication)
|
|
7. [Best Practices](#best-practices)
|
|
8. [Troubleshooting](#troubleshooting)
|
|
|
|
## Project Setup & Configuration
|
|
|
|
### Dependencies
|
|
|
|
First, install the required dependencies:
|
|
|
|
```bash
|
|
npm install minio formidable mime-types
|
|
npm install --save-dev @types/formidable @types/mime-types
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
Add these variables to your `.env` file:
|
|
|
|
```env
|
|
# MinIO Configuration
|
|
NUXT_MINIO_ACCESS_KEY=your-minio-access-key
|
|
NUXT_MINIO_SECRET_KEY=your-minio-secret-key
|
|
```
|
|
|
|
### Nuxt Configuration
|
|
|
|
Configure MinIO in your `nuxt.config.ts`:
|
|
|
|
```typescript
|
|
export default defineNuxtConfig({
|
|
runtimeConfig: {
|
|
minio: {
|
|
endPoint: "s3.portnimara.com", // Your MinIO endpoint
|
|
port: 443,
|
|
useSSL: true,
|
|
accessKey: process.env.NUXT_MINIO_ACCESS_KEY,
|
|
secretKey: process.env.NUXT_MINIO_SECRET_KEY,
|
|
bucketName: "client-portal", // Default bucket name
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
## Core MinIO Utility Functions
|
|
|
|
Create `server/utils/minio.ts` with comprehensive file management functions:
|
|
|
|
```typescript
|
|
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 and folder support
|
|
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(async (resolve, reject) => {
|
|
try {
|
|
const stream = client.listObjectsV2(bucketName, prefix, recursive);
|
|
|
|
stream.on('data', (obj) => {
|
|
// Handle folder prefixes returned by MinIO
|
|
if (obj && obj.prefix) {
|
|
folders.add(obj.prefix);
|
|
return;
|
|
}
|
|
|
|
// Skip objects without a name
|
|
if (!obj || typeof obj.name !== 'string') {
|
|
return;
|
|
}
|
|
|
|
if (!recursive) {
|
|
if (prefix) {
|
|
// Extract folder structure when inside a folder
|
|
const relativePath = obj.name.substring(prefix.length);
|
|
if (!relativePath) return; // Skip if no relative path
|
|
|
|
const firstSlash = relativePath.indexOf('/');
|
|
|
|
if (firstSlash > -1) {
|
|
// This is a folder
|
|
const folderName = relativePath.substring(0, firstSlash);
|
|
folders.add(prefix + folderName + '/');
|
|
} else if (relativePath && !obj.name.endsWith('/')) {
|
|
// This is a file in the current folder
|
|
files.push({
|
|
name: obj.name,
|
|
size: obj.size || 0,
|
|
lastModified: obj.lastModified || new Date(),
|
|
etag: obj.etag || '',
|
|
isFolder: false,
|
|
});
|
|
}
|
|
} else {
|
|
// At root level
|
|
const firstSlash = obj.name.indexOf('/');
|
|
|
|
if (obj.name.endsWith('/')) {
|
|
// This is a folder placeholder
|
|
folders.add(obj.name);
|
|
} else if (firstSlash > -1) {
|
|
// This is inside a folder, extract the folder
|
|
const folderName = obj.name.substring(0, firstSlash);
|
|
folders.add(folderName + '/');
|
|
} else {
|
|
// This is a file at root
|
|
files.push({
|
|
name: obj.name,
|
|
size: obj.size || 0,
|
|
lastModified: obj.lastModified || new Date(),
|
|
etag: obj.etag || '',
|
|
isFolder: false,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// When recursive, include all files
|
|
if (!obj.name.endsWith('/')) {
|
|
files.push({
|
|
name: obj.name,
|
|
size: obj.size || 0,
|
|
lastModified: obj.lastModified || new Date(),
|
|
etag: obj.etag || '',
|
|
isFolder: false,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
stream.on('error', (error) => {
|
|
console.error('Stream error:', error);
|
|
reject(error);
|
|
});
|
|
|
|
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]);
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in listFiles:', error);
|
|
reject(error);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Upload file with content type detection
|
|
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,
|
|
});
|
|
};
|
|
|
|
// Upload buffer (alias for uploadFile for compatibility)
|
|
export const uploadBuffer = async (buffer: Buffer, filePath: string, contentType: string) => {
|
|
return uploadFile(filePath, buffer, contentType);
|
|
};
|
|
|
|
// Generate presigned URL for secure downloads
|
|
export const getDownloadUrl = async (fileName: string, expiry: number = 60 * 60) => {
|
|
const client = getMinioClient();
|
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
|
|
|
// Extract just the filename from the full path
|
|
let filename = fileName.split('/').pop() || fileName;
|
|
|
|
// Remove timestamp prefix if present (e.g., "1234567890-filename.pdf" -> "filename.pdf")
|
|
const timestampMatch = filename.match(/^\d{10,}-(.+)$/);
|
|
if (timestampMatch) {
|
|
filename = timestampMatch[1];
|
|
}
|
|
|
|
// Force download with Content-Disposition header
|
|
const responseHeaders = {
|
|
'response-content-disposition': `attachment; filename="${filename}"`,
|
|
};
|
|
|
|
return await client.presignedGetObject(bucketName, fileName, expiry, responseHeaders);
|
|
};
|
|
|
|
// Get presigned URL for file preview (inline display)
|
|
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);
|
|
};
|
|
|
|
// 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;
|
|
|
|
const objectsList: string[] = [];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const stream = client.listObjectsV2(bucketName, folderPath, true);
|
|
|
|
stream.on('data', (obj) => {
|
|
if (obj && obj.name) {
|
|
objectsList.push(obj.name);
|
|
}
|
|
});
|
|
|
|
stream.on('error', reject);
|
|
|
|
stream.on('end', async () => {
|
|
try {
|
|
if (objectsList.length > 0) {
|
|
await client.removeObjects(bucketName, objectsList);
|
|
}
|
|
resolve(true);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
// Get file statistics
|
|
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);
|
|
};
|
|
|
|
// Rename file (copy and delete)
|
|
export const renameFile = async (oldPath: string, newPath: string) => {
|
|
const client = getMinioClient();
|
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
|
|
|
try {
|
|
// Copy the object to the new name
|
|
await client.copyObject(
|
|
bucketName,
|
|
newPath,
|
|
`/${bucketName}/${oldPath}`
|
|
);
|
|
|
|
// Delete the old object
|
|
await client.removeObject(bucketName, oldPath);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error renaming file:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Rename folder (copy all contents and delete)
|
|
export const renameFolder = async (oldPath: string, newPath: string) => {
|
|
const client = getMinioClient();
|
|
const bucketName = useRuntimeConfig().minio.bucketName;
|
|
|
|
// Ensure paths end with /
|
|
const oldPrefix = oldPath.endsWith('/') ? oldPath : oldPath + '/';
|
|
const newPrefix = newPath.endsWith('/') ? newPath : newPath + '/';
|
|
|
|
const objectsList: string[] = [];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const stream = client.listObjectsV2(bucketName, oldPrefix, true);
|
|
|
|
stream.on('data', (obj) => {
|
|
if (obj && obj.name) {
|
|
objectsList.push(obj.name);
|
|
}
|
|
});
|
|
|
|
stream.on('error', reject);
|
|
|
|
stream.on('end', async () => {
|
|
try {
|
|
// Copy all objects to new location
|
|
for (const objectName of objectsList) {
|
|
const newObjectName = objectName.replace(oldPrefix, newPrefix);
|
|
await client.copyObject(
|
|
bucketName,
|
|
newObjectName,
|
|
`/${bucketName}/${objectName}`
|
|
);
|
|
}
|
|
|
|
// Delete all old objects
|
|
if (objectsList.length > 0) {
|
|
await client.removeObjects(bucketName, objectsList);
|
|
}
|
|
|
|
resolve(true);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
// Create bucket if it doesn't exist
|
|
export const createBucketIfNotExists = async (bucketName?: string) => {
|
|
const client = getMinioClient();
|
|
const bucket = bucketName || useRuntimeConfig().minio.bucketName;
|
|
|
|
try {
|
|
const exists = await client.bucketExists(bucket);
|
|
if (!exists) {
|
|
await client.makeBucket(bucket);
|
|
console.log(`Bucket '${bucket}' created successfully`);
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error creating bucket:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
```
|
|
|
|
## API Endpoints Implementation
|
|
|
|
### File Upload API
|
|
|
|
Create `server/api/files/upload.ts`:
|
|
|
|
```typescript
|
|
import { requireAuth } from '~/server/utils/auth';
|
|
import { uploadFile, getMinioClient } from '~/server/utils/minio';
|
|
import formidable from 'formidable';
|
|
import { promises as fs } from 'fs';
|
|
import mime from 'mime-types';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
// Check authentication
|
|
await requireAuth(event);
|
|
|
|
try {
|
|
const query = getQuery(event);
|
|
const currentPath = (query.path as string) || '';
|
|
const bucket = (query.bucket as string) || 'client-portal';
|
|
|
|
console.log('[Upload] Request received for bucket:', bucket, 'path:', currentPath);
|
|
|
|
// 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
|
|
let normalizedPath = currentPath;
|
|
if (normalizedPath && !normalizedPath.endsWith('/')) {
|
|
normalizedPath += '/';
|
|
}
|
|
const fullPath = normalizedPath ? `${normalizedPath}${fileName}` : fileName;
|
|
|
|
// Get content type
|
|
const contentType = mime.lookup(uploadedFile.originalFilename || '') || 'application/octet-stream';
|
|
|
|
// Upload to MinIO - handle different buckets
|
|
if (bucket === 'client-portal') {
|
|
await uploadFile(fullPath, fileBuffer, contentType);
|
|
} else {
|
|
// For other buckets, use the MinIO client directly
|
|
const client = getMinioClient();
|
|
|
|
// Ensure bucket exists
|
|
try {
|
|
await client.bucketExists(bucket);
|
|
} catch (err) {
|
|
console.log(`[Upload] Bucket ${bucket} doesn't exist, creating it...`);
|
|
await client.makeBucket(bucket, 'us-east-1');
|
|
}
|
|
|
|
await client.putObject(bucket, fullPath, fileBuffer, fileBuffer.length, {
|
|
'Content-Type': contentType,
|
|
});
|
|
}
|
|
|
|
// Clean up temp file
|
|
await fs.unlink(uploadedFile.filepath);
|
|
|
|
results.push({
|
|
fileName: fullPath,
|
|
path: fullPath,
|
|
originalName: uploadedFile.originalFilename,
|
|
size: uploadedFile.size,
|
|
contentType,
|
|
bucket: bucket
|
|
});
|
|
|
|
// Log audit event
|
|
await logAuditEvent(event, 'upload', fullPath, uploadedFile.size);
|
|
}
|
|
|
|
// Return appropriate response
|
|
if (results.length === 1) {
|
|
return {
|
|
success: true,
|
|
path: results[0].path,
|
|
fileName: results[0].fileName,
|
|
files: results,
|
|
message: `File uploaded successfully`,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
console.log('Audit log:', auditLog);
|
|
// Store in your database or logging system here
|
|
} 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';
|
|
}
|
|
```
|
|
|
|
### File Preview API
|
|
|
|
Create `server/api/files/preview.ts`:
|
|
|
|
```typescript
|
|
import { requireAuth } from '~/server/utils/auth';
|
|
import { getPreviewUrl } from '~/server/utils/minio';
|
|
import mime from 'mime-types';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
await requireAuth(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);
|
|
|
|
console.log('Preview URL generated:', {
|
|
fileName,
|
|
contentType,
|
|
url: url.substring(0, 100) + '...' // Log first 100 chars for security
|
|
});
|
|
|
|
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',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
### File Download API
|
|
|
|
Create `server/api/files/download.ts`:
|
|
|
|
```typescript
|
|
import { requireAuth } from '~/server/utils/auth';
|
|
import { getDownloadUrl } from '~/server/utils/minio';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
await requireAuth(event);
|
|
|
|
try {
|
|
const query = getQuery(event);
|
|
const fileName = query.fileName as string;
|
|
const expiry = parseInt(query.expiry as string) || 3600; // Default 1 hour
|
|
|
|
if (!fileName) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'File name is required',
|
|
});
|
|
}
|
|
|
|
// Generate presigned URL for download
|
|
const url = await getDownloadUrl(fileName, expiry);
|
|
|
|
return {
|
|
success: true,
|
|
url,
|
|
fileName,
|
|
expiresIn: expiry,
|
|
};
|
|
} catch (error: any) {
|
|
console.error('Failed to generate download URL:', error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: error.message || 'Failed to generate download URL',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
### File List API
|
|
|
|
Create `server/api/files/list.ts`:
|
|
|
|
```typescript
|
|
import { requireAuth } from '~/server/utils/auth';
|
|
import { listFiles } from '~/server/utils/minio';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
await requireAuth(event);
|
|
|
|
try {
|
|
const query = getQuery(event);
|
|
const prefix = (query.prefix as string) || '';
|
|
const recursive = query.recursive === 'true';
|
|
|
|
const files = await listFiles(prefix, recursive);
|
|
|
|
return {
|
|
success: true,
|
|
files,
|
|
prefix,
|
|
recursive,
|
|
};
|
|
} catch (error: any) {
|
|
console.error('Failed to list files:', error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: error.message || 'Failed to list files',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
## Frontend Components
|
|
|
|
### File Uploader Component
|
|
|
|
Create `components/FileUploader.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<!-- Drop Zone -->
|
|
<div
|
|
class="drop-zone pa-8 text-center rounded-lg"
|
|
:class="{ 'drop-zone-active': isDragging }"
|
|
@drop="handleDrop"
|
|
@dragover.prevent="isDragging = true"
|
|
@dragleave.prevent="isDragging = false"
|
|
>
|
|
<v-icon size="64" color="primary" class="mb-4">
|
|
mdi-cloud-upload-outline
|
|
</v-icon>
|
|
<h3 class="text-h6 mb-2">Drag and drop files here</h3>
|
|
<p class="text-body-2 text-grey mb-4">or</p>
|
|
<v-btn
|
|
color="primary"
|
|
@click="openFileDialog"
|
|
prepend-icon="mdi-folder-open"
|
|
>
|
|
Browse Files
|
|
</v-btn>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
@change="handleFileSelect"
|
|
/>
|
|
<p class="text-caption text-grey mt-4">
|
|
Maximum file size: 50MB
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Selected Files -->
|
|
<v-list v-if="selectedFiles.length > 0" class="mt-4">
|
|
<v-list-subheader>Selected Files ({{ selectedFiles.length }})</v-list-subheader>
|
|
<v-list-item
|
|
v-for="(file, index) in selectedFiles"
|
|
:key="index"
|
|
:title="file.name"
|
|
:subtitle="formatFileSize(file.size)"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-icon>{{ getFileIcon(file.name) }}</v-icon>
|
|
</template>
|
|
<template v-slot:append>
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
size="small"
|
|
@click="removeFile(index)"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
</template>
|
|
</v-list-item>
|
|
</v-list>
|
|
|
|
<!-- Upload Progress -->
|
|
<v-progress-linear
|
|
v-if="uploading && uploadProgress > 0"
|
|
:model-value="uploadProgress"
|
|
color="primary"
|
|
height="8"
|
|
class="mt-4"
|
|
/>
|
|
|
|
<!-- Actions -->
|
|
<v-card-actions v-if="selectedFiles.length > 0" class="mt-4">
|
|
<v-spacer />
|
|
<v-btn @click="clearFiles">Clear All</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
@click="uploadFiles"
|
|
:loading="uploading"
|
|
:disabled="selectedFiles.length === 0"
|
|
>
|
|
Upload {{ selectedFiles.length }} File{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
|
|
interface Props {
|
|
uploading: boolean;
|
|
currentPath?: string;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'upload', files: File[]): void;
|
|
(e: 'close'): void;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<Emits>();
|
|
|
|
const selectedFiles = ref<File[]>([]);
|
|
const isDragging = ref(false);
|
|
const uploadProgress = ref(0);
|
|
const fileInput = ref<HTMLInputElement>();
|
|
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
|
|
// Handle drag and drop
|
|
const handleDrop = (e: DragEvent) => {
|
|
e.preventDefault();
|
|
isDragging.value = false;
|
|
|
|
const files = Array.from(e.dataTransfer?.files || []);
|
|
addFiles(files);
|
|
};
|
|
|
|
// Handle file selection
|
|
const handleFileSelect = (e: Event) => {
|
|
const input = e.target as HTMLInputElement;
|
|
const files = Array.from(input.files || []);
|
|
addFiles(files);
|
|
|
|
// Reset input value to allow selecting same file again
|
|
input.value = '';
|
|
};
|
|
|
|
// Add files to selection with validation
|
|
const addFiles = (files: File[]) => {
|
|
const validFiles = files.filter(file => {
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
alert(`File "${file.name}" exceeds 50MB limit`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
selectedFiles.value = [...selectedFiles.value, ...validFiles];
|
|
};
|
|
|
|
// Remove file from selection
|
|
const removeFile = (index: number) => {
|
|
selectedFiles.value.splice(index, 1);
|
|
};
|
|
|
|
// Clear all files
|
|
const clearFiles = () => {
|
|
selectedFiles.value = [];
|
|
uploadProgress.value = 0;
|
|
};
|
|
|
|
// Upload files
|
|
const uploadFiles = () => {
|
|
if (selectedFiles.value.length === 0) return;
|
|
emit('upload', selectedFiles.value);
|
|
};
|
|
|
|
// Open file dialog
|
|
const openFileDialog = () => {
|
|
if (fileInput.value) {
|
|
fileInput.value.click();
|
|
}
|
|
};
|
|
|
|
// Helper functions
|
|
const 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];
|
|
};
|
|
|
|
const getFileIcon = (filename: string): string => {
|
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
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';
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.drop-zone {
|
|
border: 2px dashed #ccc;
|
|
transition: all 0.3s;
|
|
background-color: #fafafa;
|
|
}
|
|
|
|
.drop-zone-active {
|
|
border-color: #1976d2;
|
|
background-color: rgba(25, 118, 210, 0.05);
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### Lazy Image Component with Thumbnails
|
|
|
|
Create `components/LazyImageViewer.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="lazy-image-viewer" ref="containerRef">
|
|
<!-- Loading state -->
|
|
<div v-if="!loaded && !error" class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<span class="text-xs text-gray-500">Loading...</span>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<div v-else-if="error" class="error-state" @click="retry">
|
|
<v-icon name="mdi:image-broken" class="w-8 h-8 text-red-400" />
|
|
<span class="text-xs text-red-500">Failed to load</span>
|
|
<span class="text-xs text-gray-500">Click to retry</span>
|
|
</div>
|
|
|
|
<!-- Loaded image -->
|
|
<img
|
|
v-else-if="loaded && imageUrl"
|
|
:src="imageUrl"
|
|
:alt="alt"
|
|
:class="['image', imageClass]"
|
|
@error="handleImageError"
|
|
@load="handleImageLoad"
|
|
/>
|
|
|
|
<!-- Fallback if no URL -->
|
|
<div v-else class="no-image-state">
|
|
<v-icon name="mdi:image-outline" class="w-8 h-8 text-gray-400" />
|
|
<span class="text-xs text-gray-500">No image</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
|
|
interface Props {
|
|
src: string;
|
|
alt?: string;
|
|
useThumbnail?: boolean;
|
|
thumbnailSize?: 'small' | 'medium' | 'large';
|
|
imageClass?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
alt: 'Image',
|
|
useThumbnail: false,
|
|
thumbnailSize: 'medium',
|
|
imageClass: ''
|
|
});
|
|
|
|
// Reactive state
|
|
const containerRef = ref<HTMLElement>();
|
|
const loaded = ref(false);
|
|
const error = ref(false);
|
|
const isVisible = ref(false);
|
|
|
|
// Computed image URL with thumbnail support
|
|
const imageUrl = computed(() => {
|
|
if (!props.src) return null;
|
|
|
|
if (props.useThumbnail) {
|
|
// Add thumbnail query parameter
|
|
const url = new URL(props.src);
|
|
url.searchParams.set('thumbnail', props.thumbnailSize);
|
|
return url.toString();
|
|
}
|
|
|
|
return props.src;
|
|
});
|
|
|
|
// Intersection Observer for lazy loading
|
|
let observer: IntersectionObserver | null = null;
|
|
|
|
const initIntersectionObserver = () => {
|
|
if (!containerRef.value || typeof IntersectionObserver === 'undefined') {
|
|
// Fallback for environments without IntersectionObserver
|
|
isVisible.value = true;
|
|
loadImage();
|
|
return;
|
|
}
|
|
|
|
observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const entry = entries[0];
|
|
if (entry.isIntersecting && !isVisible.value) {
|
|
isVisible.value = true;
|
|
loadImage();
|
|
|
|
// Stop observing once visible
|
|
if (observer && containerRef.value) {
|
|
observer.unobserve(containerRef.value);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
rootMargin: '50px',
|
|
threshold: 0.1
|
|
}
|
|
);
|
|
|
|
observer.observe(containerRef.value);
|
|
};
|
|
|
|
const loadImage = () => {
|
|
if (!imageUrl.value || loaded.value) return;
|
|
|
|
const img = new Image();
|
|
|
|
img.onload = () => {
|
|
loaded.value = true;
|
|
error.value = false;
|
|
};
|
|
|
|
img.onerror = () => {
|
|
error.value = true;
|
|
loaded.value = false;
|
|
};
|
|
|
|
img.src = imageUrl.value;
|
|
};
|
|
|
|
const handleImageLoad = () => {
|
|
console.log('Image displayed successfully');
|
|
};
|
|
|
|
const handleImageError = () => {
|
|
error.value = true;
|
|
loaded.value = false;
|
|
};
|
|
|
|
const retry = () => {
|
|
error.value = false;
|
|
loaded.value = false;
|
|
loadImage();
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
initIntersectionObserver();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (observer && containerRef.value) {
|
|
observer.unobserve(containerRef.value);
|
|
observer = null;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.lazy-image-viewer {
|
|
@apply relative w-full h-full flex items-center justify-center bg-gray-50;
|
|
}
|
|
|
|
.loading-state {
|
|
@apply flex flex-col items-center justify-center gap-2 text-gray-500;
|
|
}
|
|
|
|
.loading-spinner {
|
|
@apply w-6 h-6 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin;
|
|
}
|
|
|
|
.error-state {
|
|
@apply flex flex-col items-center justify-center gap-1 text-red-500 cursor-pointer hover:bg-red-50 transition-colors rounded p-2;
|
|
}
|
|
|
|
.no-image-state {
|
|
@apply flex flex-col items-center justify-center gap-1 text-gray-400;
|
|
}
|
|
|
|
.image {
|
|
@apply w-full h-full object-cover transition-opacity duration-300;
|
|
}
|
|
|
|
.image:hover {
|
|
@apply opacity-90;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
## Advanced Features
|
|
|
|
### Image Thumbnails and Processing
|
|
|
|
Create `server/utils/image-processor.ts`:
|
|
|
|
```typescript
|
|
import sharp from 'sharp';
|
|
import { uploadFile } from './minio';
|
|
import path from 'path';
|
|
|
|
interface ThumbnailSizes {
|
|
tiny: { width: 150, height: 150 };
|
|
small: { width: 300, height: 300 };
|
|
medium: { width: 600, height: 600 };
|
|
large: { width: 1200, height: 1200 };
|
|
card_cover: { width: 400, height: 250 };
|
|
}
|
|
|
|
const THUMBNAIL_SIZES: ThumbnailSizes = {
|
|
tiny: { width: 150, height: 150 },
|
|
small: { width: 300, height: 300 },
|
|
medium: { width: 600, height: 600 },
|
|
large: { width: 1200, height: 1200 },
|
|
card_cover: { width: 400, height: 250 }
|
|
};
|
|
|
|
export const generateImageThumbnails = async (
|
|
originalBuffer: Buffer,
|
|
originalPath: string,
|
|
contentType: string
|
|
) => {
|
|
const thumbnails: Record<string, any> = {};
|
|
|
|
// Check if file is an image
|
|
if (!contentType.startsWith('image/')) {
|
|
return thumbnails;
|
|
}
|
|
|
|
try {
|
|
const pathInfo = path.parse(originalPath);
|
|
|
|
for (const [sizeName, dimensions] of Object.entries(THUMBNAIL_SIZES)) {
|
|
try {
|
|
const thumbnailBuffer = await sharp(originalBuffer)
|
|
.resize(dimensions.width, dimensions.height, {
|
|
fit: 'cover',
|
|
position: 'center'
|
|
})
|
|
.jpeg({ quality: 85 })
|
|
.toBuffer();
|
|
|
|
const thumbnailPath = `${pathInfo.dir}/${pathInfo.name}_${sizeName}${pathInfo.ext}`;
|
|
|
|
await uploadFile(thumbnailPath, thumbnailBuffer, 'image/jpeg');
|
|
|
|
thumbnails[sizeName] = {
|
|
path: thumbnailPath,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
size: thumbnailBuffer.length
|
|
};
|
|
|
|
console.log(`Generated ${sizeName} thumbnail: ${thumbnailPath}`);
|
|
} catch (error) {
|
|
console.error(`Failed to generate ${sizeName} thumbnail:`, error);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to generate thumbnails:', error);
|
|
}
|
|
|
|
return thumbnails;
|
|
};
|
|
|
|
export const deleteImageThumbnails = async (originalPath: string) => {
|
|
const { deleteFile } = await import('./minio');
|
|
const pathInfo = path.parse(originalPath);
|
|
|
|
for (const sizeName of Object.keys(THUMBNAIL_SIZES)) {
|
|
try {
|
|
const thumbnailPath = `${pathInfo.dir}/${pathInfo.name}_${sizeName}${pathInfo.ext}`;
|
|
await deleteFile(thumbnailPath);
|
|
console.log(`Deleted ${sizeName} thumbnail: ${thumbnailPath}`);
|
|
} catch (error) {
|
|
console.error(`Failed to delete ${sizeName} thumbnail:`, error);
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### File Browser Component
|
|
|
|
Create `components/FileBrowser.vue`:
|
|
|
|
```vue
|
|
<template>
|
|
<div class="file-browser">
|
|
<!-- Navigation Bar -->
|
|
<v-toolbar density="compact" class="mb-4">
|
|
<v-btn
|
|
icon="mdi-arrow-left"
|
|
:disabled="currentPath === ''"
|
|
@click="navigateUp"
|
|
/>
|
|
|
|
<v-breadcrumbs
|
|
:items="breadcrumbs"
|
|
divider="/"
|
|
class="flex-1"
|
|
>
|
|
<template v-slot:item="{ item }">
|
|
<v-breadcrumbs-item
|
|
:disabled="item.disabled"
|
|
@click="navigateTo(item.value)"
|
|
>
|
|
{{ item.title }}
|
|
</v-breadcrumbs-item>
|
|
</template>
|
|
</v-breadcrumbs>
|
|
|
|
<v-btn
|
|
icon="mdi-refresh"
|
|
@click="refreshFiles"
|
|
:loading="loading"
|
|
/>
|
|
|
|
<v-btn
|
|
icon="mdi-upload"
|
|
@click="showUploadDialog = true"
|
|
/>
|
|
|
|
<v-btn
|
|
icon="mdi-folder-plus"
|
|
@click="showCreateFolderDialog = true"
|
|
/>
|
|
</v-toolbar>
|
|
|
|
<!-- File Grid -->
|
|
<v-row v-if="!loading">
|
|
<v-col
|
|
v-for="file in sortedFiles"
|
|
:key="file.name"
|
|
cols="12"
|
|
sm="6"
|
|
md="4"
|
|
lg="3"
|
|
xl="2"
|
|
>
|
|
<v-card
|
|
:class="['file-card', { 'folder-card': file.isFolder }]"
|
|
@click="handleFileClick(file)"
|
|
@contextmenu.prevent="showContextMenu($event, file)"
|
|
>
|
|
<v-card-text class="text-center pa-3">
|
|
<div class="file-icon-container">
|
|
<!-- Folder Icon -->
|
|
<v-icon
|
|
v-if="file.isFolder"
|
|
size="48"
|
|
color="amber"
|
|
>
|
|
mdi-folder
|
|
</v-icon>
|
|
|
|
<!-- Image Preview -->
|
|
<LazyImageViewer
|
|
v-else-if="isImageFile(file.name)"
|
|
:src="getPreviewUrl(file.name)"
|
|
:alt="file.name"
|
|
class="file-thumbnail"
|
|
use-thumbnail
|
|
thumbnail-size="small"
|
|
/>
|
|
|
|
<!-- File Icon -->
|
|
<v-icon
|
|
v-else
|
|
size="48"
|
|
:color="getFileColor(file.name)"
|
|
>
|
|
{{ getFileIcon(file.name) }}
|
|
</v-icon>
|
|
</div>
|
|
|
|
<div class="file-name mt-2">
|
|
{{ getDisplayName(file.name) }}
|
|
</div>
|
|
|
|
<div class="file-info text-caption text-grey">
|
|
<div v-if="!file.isFolder">{{ formatFileSize(file.size) }}</div>
|
|
<div>{{ formatDate(file.lastModified) }}</div>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Loading State -->
|
|
<div v-else class="text-center py-8">
|
|
<v-progress-circular indeterminate />
|
|
<div class="mt-2">Loading files...</div>
|
|
</div>
|
|
|
|
<!-- Upload Dialog -->
|
|
<v-dialog v-model="showUploadDialog" max-width="600">
|
|
<v-card>
|
|
<v-card-title>Upload Files</v-card-title>
|
|
<v-card-text>
|
|
<FileUploader
|
|
:uploading="uploading"
|
|
:current-path="currentPath"
|
|
@upload="handleUpload"
|
|
/>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Create Folder Dialog -->
|
|
<v-dialog v-model="showCreateFolderDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title>Create New Folder</v-card-title>
|
|
<v-card-text>
|
|
<v-text-field
|
|
v-model="newFolderName"
|
|
label="Folder Name"
|
|
:rules="[v => !!v || 'Name is required']"
|
|
@keyup.enter="createFolder"
|
|
/>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="showCreateFolderDialog = false">Cancel</v-btn>
|
|
<v-btn color="primary" @click="createFolder">Create</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Context Menu -->
|
|
<v-menu
|
|
v-model="contextMenu.show"
|
|
:position-x="contextMenu.x"
|
|
:position-y="contextMenu.y"
|
|
absolute
|
|
offset-y
|
|
>
|
|
<v-list density="compact">
|
|
<v-list-item @click="downloadFile(contextMenu.file)">
|
|
<v-list-item-title>Download</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="renameFile(contextMenu.file)">
|
|
<v-list-item-title>Rename</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="deleteFile(contextMenu.file)" class="text-error">
|
|
<v-list-item-title>Delete</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useToast } from '~/composables/useToast';
|
|
|
|
// Component imports
|
|
import FileUploader from './FileUploader.vue';
|
|
import LazyImageViewer from './LazyImageViewer.vue';
|
|
|
|
const { showToast } = useToast();
|
|
|
|
// Reactive state
|
|
const files = ref<any[]>([]);
|
|
const loading = ref(false);
|
|
const uploading = ref(false);
|
|
const currentPath = ref('');
|
|
const showUploadDialog = ref(false);
|
|
const showCreateFolderDialog = ref(false);
|
|
const newFolderName = ref('');
|
|
const contextMenu = ref({
|
|
show: false,
|
|
x: 0,
|
|
y: 0,
|
|
file: null as any
|
|
});
|
|
|
|
// Computed properties
|
|
const breadcrumbs = computed(() => {
|
|
if (!currentPath.value) {
|
|
return [{ title: 'Root', value: '', disabled: false }];
|
|
}
|
|
|
|
const parts = currentPath.value.split('/').filter(Boolean);
|
|
const crumbs = [{ title: 'Root', value: '', disabled: false }];
|
|
|
|
let path = '';
|
|
for (const part of parts) {
|
|
path += part + '/';
|
|
crumbs.push({
|
|
title: part,
|
|
value: path,
|
|
disabled: false
|
|
});
|
|
}
|
|
|
|
// Disable the last item (current location)
|
|
if (crumbs.length > 0) {
|
|
crumbs[crumbs.length - 1].disabled = true;
|
|
}
|
|
|
|
return crumbs;
|
|
});
|
|
|
|
const sortedFiles = computed(() => {
|
|
return files.value.sort((a, b) => {
|
|
// Folders first
|
|
if (a.isFolder && !b.isFolder) return -1;
|
|
if (!a.isFolder && b.isFolder) return 1;
|
|
|
|
// Then alphabetically
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
});
|
|
|
|
// File operations
|
|
const fetchFiles = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const response = await $fetch('/api/files/list', {
|
|
query: {
|
|
prefix: currentPath.value,
|
|
recursive: false
|
|
}
|
|
});
|
|
files.value = response.files;
|
|
} catch (error) {
|
|
console.error('Failed to fetch files:', error);
|
|
showToast('Failed to load files', 'error');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const refreshFiles = () => {
|
|
fetchFiles();
|
|
};
|
|
|
|
const navigateTo = (path: string) => {
|
|
currentPath.value = path;
|
|
fetchFiles();
|
|
};
|
|
|
|
const navigateUp = () => {
|
|
if (!currentPath.value) return;
|
|
|
|
const parts = currentPath.value.split('/').filter(Boolean);
|
|
parts.pop();
|
|
currentPath.value = parts.length ? parts.join('/') + '/' : '';
|
|
fetchFiles();
|
|
};
|
|
|
|
const handleFileClick = (file: any) => {
|
|
if (file.isFolder) {
|
|
navigateTo(file.name);
|
|
} else {
|
|
// Handle file preview or download
|
|
previewFile(file);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async (uploadFiles: File[]) => {
|
|
uploading.value = true;
|
|
try {
|
|
const formData = new FormData();
|
|
uploadFiles.forEach((file, index) => {
|
|
formData.append(`file`, file);
|
|
});
|
|
|
|
await $fetch('/api/files/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
query: {
|
|
path: currentPath.value
|
|
}
|
|
});
|
|
|
|
showToast('Files uploaded successfully', 'success');
|
|
showUploadDialog.value = false;
|
|
fetchFiles();
|
|
} catch (error) {
|
|
console.error('Upload failed:', error);
|
|
showToast('Upload failed', 'error');
|
|
} finally {
|
|
uploading.value = false;
|
|
}
|
|
};
|
|
|
|
const createFolder = async () => {
|
|
if (!newFolderName.value.trim()) return;
|
|
|
|
try {
|
|
const folderPath = currentPath.value + newFolderName.value;
|
|
|
|
await $fetch('/api/files/create-folder', {
|
|
method: 'POST',
|
|
body: { folderPath }
|
|
});
|
|
|
|
showToast('Folder created successfully', 'success');
|
|
showCreateFolderDialog.value = false;
|
|
newFolderName.value = '';
|
|
fetchFiles();
|
|
} catch (error) {
|
|
console.error('Failed to create folder:', error);
|
|
showToast('Failed to create folder', 'error');
|
|
}
|
|
};
|
|
|
|
// File operations
|
|
const downloadFile = async (file: any) => {
|
|
try {
|
|
const response = await $fetch('/api/files/download', {
|
|
query: { fileName: file.name }
|
|
});
|
|
|
|
window.open(response.url, '_blank');
|
|
} catch (error) {
|
|
console.error('Download failed:', error);
|
|
showToast('Download failed', 'error');
|
|
}
|
|
};
|
|
|
|
const previewFile = async (file: any) => {
|
|
try {
|
|
const response = await $fetch('/api/files/preview', {
|
|
query: { fileName: file.name }
|
|
});
|
|
|
|
window.open(response.url, '_blank');
|
|
} catch (error) {
|
|
// Fallback to download
|
|
downloadFile(file);
|
|
}
|
|
};
|
|
|
|
const deleteFile = async (file: any) => {
|
|
if (!confirm(`Are you sure you want to delete "${getDisplayName(file.name)}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await $fetch('/api/files/delete', {
|
|
method: 'DELETE',
|
|
query: { fileName: file.name }
|
|
});
|
|
|
|
showToast('File deleted successfully', 'success');
|
|
fetchFiles();
|
|
} catch (error) {
|
|
console.error('Delete failed:', error);
|
|
showToast('Delete failed', 'error');
|
|
}
|
|
};
|
|
|
|
// Context menu
|
|
const showContextMenu = (event: MouseEvent, file: any) => {
|
|
event.preventDefault();
|
|
contextMenu.value = {
|
|
show: true,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
file
|
|
};
|
|
};
|
|
|
|
// Helper functions
|
|
const isImageFile = (filename: string): boolean => {
|
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
return imageExtensions.includes(ext);
|
|
};
|
|
|
|
const getPreviewUrl = (filename: string): string => {
|
|
return `/api/files/preview?fileName=${encodeURIComponent(filename)}`;
|
|
};
|
|
|
|
const getDisplayName = (filename: string): string => {
|
|
if (filename.endsWith('/')) {
|
|
return filename.slice(0, -1).split('/').pop() || filename;
|
|
}
|
|
|
|
// Remove timestamp prefix if present
|
|
const name = filename.split('/').pop() || filename;
|
|
const timestampMatch = name.match(/^\d{10,}-(.+)$/);
|
|
return timestampMatch ? timestampMatch[1] : name;
|
|
};
|
|
|
|
const 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];
|
|
};
|
|
|
|
const formatDate = (date: string | Date): string => {
|
|
const d = new Date(date);
|
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
|
};
|
|
|
|
const getFileIcon = (filename: string): string => {
|
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
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',
|
|
txt: 'mdi-file-document-outline',
|
|
csv: 'mdi-file-delimited',
|
|
mp4: 'mdi-file-video',
|
|
mp3: 'mdi-file-music',
|
|
};
|
|
return iconMap[ext] || 'mdi-file';
|
|
};
|
|
|
|
const getFileColor = (filename: string): string => {
|
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
|
const colorMap: Record<string, string> = {
|
|
pdf: 'red',
|
|
doc: 'blue',
|
|
docx: 'blue',
|
|
xls: 'green',
|
|
xlsx: 'green',
|
|
jpg: 'orange',
|
|
jpeg: 'orange',
|
|
png: 'orange',
|
|
gif: 'orange',
|
|
svg: 'orange',
|
|
zip: 'purple',
|
|
txt: 'grey',
|
|
csv: 'teal',
|
|
mp4: 'indigo',
|
|
mp3: 'pink',
|
|
};
|
|
return colorMap[ext] || 'grey';
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchFiles();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.file-browser {
|
|
height: 100%;
|
|
}
|
|
|
|
.file-card {
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
height: 200px;
|
|
}
|
|
|
|
.file-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.folder-card {
|
|
background-color: rgba(255, 193, 7, 0.1);
|
|
}
|
|
|
|
.file-icon-container {
|
|
height: 80px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.file-thumbnail {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.file-name {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
line-height: 1.2;
|
|
word-break: break-word;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.file-info {
|
|
margin-top: 4px;
|
|
line-height: 1.2;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
## Security & Authentication
|
|
|
|
### Authentication Integration
|
|
|
|
Your MinIO setup includes robust authentication through the `requireAuth` utility. Here's how to implement it:
|
|
|
|
Create `server/utils/auth.ts` if you don't have it:
|
|
|
|
```typescript
|
|
import jwt from 'jsonwebtoken';
|
|
import { getCookie } from 'h3';
|
|
|
|
export interface AuthUser {
|
|
email: string;
|
|
name: string;
|
|
roles?: string[];
|
|
permissions?: string[];
|
|
}
|
|
|
|
export const requireAuth = async (event: any): Promise<AuthUser> => {
|
|
// Check for x-tag header (API key authentication)
|
|
const apiKey = getHeader(event, 'x-tag');
|
|
if (apiKey) {
|
|
const validApiKey = useRuntimeConfig().apiKey;
|
|
if (apiKey === validApiKey) {
|
|
return { email: 'api-user', name: 'API User' };
|
|
}
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Invalid API key',
|
|
});
|
|
}
|
|
|
|
// Check for session-based authentication (Keycloak/OAuth)
|
|
const sessionCookie = getCookie(event, 'auth-token') ||
|
|
getCookie(event, 'nuxt-oidc-auth-session');
|
|
|
|
if (!sessionCookie) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Authentication required',
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Verify JWT token
|
|
const decoded = jwt.verify(sessionCookie, useRuntimeConfig().jwtSecret) as any;
|
|
|
|
const user: AuthUser = {
|
|
email: decoded.email || decoded.preferred_username,
|
|
name: decoded.name || decoded.preferred_username,
|
|
roles: decoded.realm_access?.roles || [],
|
|
permissions: decoded.permissions || []
|
|
};
|
|
|
|
// Store user in event context for later use
|
|
event.context.user = user;
|
|
|
|
return user;
|
|
} catch (error) {
|
|
throw createError({
|
|
statusCode: 401,
|
|
statusMessage: 'Invalid authentication token',
|
|
});
|
|
}
|
|
};
|
|
|
|
export const checkPermission = (user: AuthUser, permission: string): boolean => {
|
|
return user.permissions?.includes(permission) ||
|
|
user.roles?.includes('admin') ||
|
|
false;
|
|
};
|
|
```
|
|
|
|
### File Access Control
|
|
|
|
Implement role-based access control for files:
|
|
|
|
```typescript
|
|
// server/utils/file-permissions.ts
|
|
export const checkFileAccess = (user: AuthUser, filePath: string, action: 'read' | 'write' | 'delete'): boolean => {
|
|
// Admin can do everything
|
|
if (user.roles?.includes('admin')) {
|
|
return true;
|
|
}
|
|
|
|
// Users can only access their own files
|
|
if (filePath.startsWith(`users/${user.email}/`)) {
|
|
return true;
|
|
}
|
|
|
|
// Check shared folders
|
|
if (filePath.startsWith('shared/') && action === 'read') {
|
|
return true;
|
|
}
|
|
|
|
// Public files are read-only for everyone
|
|
if (filePath.startsWith('public/') && action === 'read') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### 1. File Naming Conventions
|
|
|
|
- Use timestamps to prevent filename collisions: `${timestamp}-${sanitizedName}`
|
|
- Sanitize filenames: `filename.replace(/[^a-zA-Z0-9.-]/g, '_')`
|
|
- Store original filenames separately for display purposes
|
|
|
|
### 2. Error Handling
|
|
|
|
```typescript
|
|
// Comprehensive error handling in API endpoints
|
|
try {
|
|
await uploadFile(filePath, buffer, contentType);
|
|
} catch (error: any) {
|
|
console.error('Upload failed:', {
|
|
error: error.message,
|
|
filePath,
|
|
contentType,
|
|
size: buffer.length
|
|
});
|
|
|
|
throw createError({
|
|
statusCode: error.code === 'NoSuchBucket' ? 404 : 500,
|
|
statusMessage: getErrorMessage(error),
|
|
});
|
|
}
|
|
|
|
const getErrorMessage = (error: any): string => {
|
|
const errorMessages: Record<string, string> = {
|
|
'NoSuchBucket': 'Storage bucket not found',
|
|
'AccessDenied': 'Access denied to storage',
|
|
'InvalidBucketName': 'Invalid bucket name',
|
|
'BucketNotEmpty': 'Bucket is not empty'
|
|
};
|
|
|
|
return errorMessages[error.code] || 'Storage operation failed';
|
|
};
|
|
```
|
|
|
|
### 3. Performance Optimization
|
|
|
|
- Use lazy loading for image thumbnails
|
|
- Implement client-side caching for file lists
|
|
- Use presigned URLs to reduce server load
|
|
- Implement pagination for large file lists
|
|
|
|
### 4. Security Considerations
|
|
|
|
- Always validate file types and sizes
|
|
- Use presigned URLs with short expiration times
|
|
- Implement proper authentication on all endpoints
|
|
- Sanitize file paths to prevent directory traversal
|
|
- Audit all file operations
|
|
|
|
### 5. Monitoring and Logging
|
|
|
|
```typescript
|
|
// Comprehensive audit logging
|
|
const auditLog = {
|
|
timestamp: new Date().toISOString(),
|
|
user_email: user.email,
|
|
action: 'upload',
|
|
file_path: filePath,
|
|
file_size: fileSize,
|
|
ip_address: getClientIP(event),
|
|
user_agent: getHeader(event, 'user-agent'),
|
|
success: true,
|
|
duration_ms: Date.now() - startTime
|
|
};
|
|
|
|
// Send to your logging system
|
|
await logToDatabase(auditLog);
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues and Solutions
|
|
|
|
#### 1. Connection Errors
|
|
```bash
|
|
# Check MinIO server connectivity
|
|
curl -X GET https://your-minio-endpoint/health
|
|
|
|
# Verify credentials
|
|
minio-client admin info myminio
|
|
```
|
|
|
|
#### 2. Upload Failures
|
|
- Check file size limits (50MB default)
|
|
- Verify content-type detection
|
|
- Ensure proper multipart form parsing
|
|
- Check bucket permissions
|
|
|
|
#### 3. Presigned URL Issues
|
|
- Verify server time synchronization
|
|
- Check URL expiration settings
|
|
- Ensure proper bucket policies
|
|
- Validate CORS settings for browser uploads
|
|
|
|
#### 4. Thumbnail Generation
|
|
```typescript
|
|
// Check Sharp installation and image processing
|
|
try {
|
|
await sharp(buffer).metadata();
|
|
} catch (error) {
|
|
console.error('Sharp processing failed:', error);
|
|
// Install: npm install sharp
|
|
}
|
|
```
|
|
|
|
#### 5. Performance Issues
|
|
- Enable MinIO compression
|
|
- Use CDN for frequently accessed files
|
|
- Implement proper caching headers
|
|
- Monitor bucket metrics
|
|
|
|
### Development vs Production Configuration
|
|
|
|
```typescript
|
|
// Development
|
|
const minioConfig = {
|
|
endPoint: 'localhost',
|
|
port: 9000,
|
|
useSSL: false,
|
|
accessKey: 'minioadmin',
|
|
secretKey: 'minioadmin',
|
|
};
|
|
|
|
// Production
|
|
const minioConfig = {
|
|
endPoint: 's3.yourdomain.com',
|
|
port: 443,
|
|
useSSL: true,
|
|
accessKey: process.env.MINIO_ACCESS_KEY,
|
|
secretKey: process.env.MINIO_SECRET_KEY,
|
|
};
|
|
```
|
|
|
|
## Deployment Considerations
|
|
|
|
### Docker Configuration
|
|
|
|
```dockerfile
|
|
# Add to your Dockerfile
|
|
RUN npm install sharp --platform=linux --arch=x64
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
```env
|
|
# Production environment
|
|
NUXT_MINIO_ENDPOINT=s3.yourdomain.com
|
|
NUXT_MINIO_PORT=443
|
|
NUXT_MINIO_USE_SSL=true
|
|
NUXT_MINIO_ACCESS_KEY=your-production-access-key
|
|
NUXT_MINIO_SECRET_KEY=your-production-secret-key
|
|
NUXT_MINIO_BUCKET_NAME=your-production-bucket
|
|
```
|
|
|
|
### MinIO Server Setup
|
|
|
|
```bash
|
|
# Start MinIO server
|
|
docker run -p 9000:9000 -p 9001:9001 \
|
|
-e "MINIO_ROOT_USER=admin" \
|
|
-e "MINIO_ROOT_PASSWORD=password123" \
|
|
-v /mnt/data:/data \
|
|
minio/minio server /data --console-address ":9001"
|
|
```
|
|
|
|
## Conclusion
|
|
|
|
This implementation guide provides a complete MinIO integration following the Port Nimara pattern. It includes:
|
|
|
|
- ✅ Complete file management operations
|
|
- ✅ Secure authentication and authorization
|
|
- ✅ Image thumbnails and processing
|
|
- ✅ Lazy loading and performance optimization
|
|
- ✅ Comprehensive error handling
|
|
- ✅ Audit logging and monitoring
|
|
- ✅ Production-ready configuration
|
|
|
|
The implementation is battle-tested and provides a solid foundation for any project requiring robust file storage capabilities.
|