monacousa-portal/docs/minio_example_guide.md

50 KiB

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
  2. Core MinIO Utility Functions
  3. API Endpoints Implementation
  4. Frontend Components
  5. Advanced Features
  6. Security & Authentication
  7. Best Practices
  8. Troubleshooting

Project Setup & Configuration

Dependencies

First, install the required dependencies:

npm install minio formidable mime-types
npm install --save-dev @types/formidable @types/mime-types

Environment Variables

Add these variables to your .env file:

# 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:

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:

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:

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:

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:

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:

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:

<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:

<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:

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:

<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:

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:

// 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

// 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

// 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

# 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

// 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

// 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

# Add to your Dockerfile
RUN npm install sharp --platform=linux --arch=x64

Environment Variables

# 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

# 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.