diff --git a/components/ProfileAvatar.vue b/components/ProfileAvatar.vue new file mode 100644 index 0000000..4cd1f59 --- /dev/null +++ b/components/ProfileAvatar.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/docs-archive/MinIO_Implementation_Examples_Guide b/docs-archive/MinIO_Implementation_Examples_Guide new file mode 100644 index 0000000..9b637b5 --- /dev/null +++ b/docs-archive/MinIO_Implementation_Examples_Guide @@ -0,0 +1,1975 @@ +# 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(); + + 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 + + + + + +``` + +### Lazy Image Component with Thumbnails + +Create `components/LazyImageViewer.vue`: + +```vue + + + + + +``` + +## 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 = {}; + + // 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 + + + + + +``` + +## 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 => { + // 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 = { + '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. diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue index 3bafe81..b4cf07e 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -120,9 +120,14 @@ diff --git a/package-lock.json b/package-lock.json index fdab777..96e1be9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "minio": "^8.0.5", "nodemailer": "^7.0.5", "nuxt": "^3.15.4", - "sharp": "^0.34.2", + "sharp": "^0.34.3", "systeminformation": "^5.27.7", "vue": "latest", "vue-country-flag-next": "^2.3.2", diff --git a/package.json b/package.json index d07dde4..0f992c6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "minio": "^8.0.5", "nodemailer": "^7.0.5", "nuxt": "^3.15.4", - "sharp": "^0.34.2", + "sharp": "^0.34.3", "systeminformation": "^5.27.7", "vue": "latest", "vue-country-flag-next": "^2.3.2", diff --git a/server/api/profile/image/[memberId].delete.ts b/server/api/profile/image/[memberId].delete.ts new file mode 100644 index 0000000..3bacb19 --- /dev/null +++ b/server/api/profile/image/[memberId].delete.ts @@ -0,0 +1,94 @@ +import { deleteProfileImage, removeMemberProfileImageUrl } from '~/server/utils/profile-images'; + +// Authentication utility - we'll need to check if it exists +async function requireAuth(event: any) { + // Check for session-based authentication + const sessionCookie = getCookie(event, 'auth-token') || getCookie(event, 'nuxt-oidc-auth-session'); + + if (!sessionCookie) { + throw createError({ + statusCode: 401, + statusMessage: 'Authentication required', + }); + } + + // For now, return a basic user object - this should integrate with your existing auth system + const user = event.context.user; + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid authentication', + }); + } + + return user; +} + +// Role-based access control +function canEditMember(user: any, targetMemberId: string): boolean { + // Admin can edit anyone + if (user.tier === 'admin' || user.groups?.includes('admin') || user.groups?.includes('monaco-admin')) { + return true; + } + + // Board members can edit anyone + if (user.tier === 'board' || user.groups?.includes('board') || user.groups?.includes('monaco-board')) { + return true; + } + + // Users can only edit their own profile + return user.email === targetMemberId || user.member_id === targetMemberId; +} + +export default defineEventHandler(async (event) => { + try { + // Check authentication + const user = await requireAuth(event); + + // Get route parameter + const memberId = getRouterParam(event, 'memberId'); + + if (!memberId) { + throw createError({ + statusCode: 400, + statusMessage: 'Member ID is required', + }); + } + + // Check permissions + if (!canEditMember(user, memberId)) { + throw createError({ + statusCode: 403, + statusMessage: 'You can only delete your own profile image', + }); + } + + console.log(`[profile-delete] Deleting profile image for member: ${memberId}`); + + // Delete image files from MinIO + await deleteProfileImage(memberId); + + // Remove image reference from database + await removeMemberProfileImageUrl(memberId); + + console.log(`[profile-delete] Successfully deleted profile image for member: ${memberId}`); + + return { + success: true, + message: 'Profile image deleted successfully', + memberId, + }; + + } catch (error: any) { + console.error('[profile-delete] Delete failed:', error); + + if (error.statusCode) { + throw error; // Re-throw HTTP errors + } + + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to delete profile image', + }); + } +}); diff --git a/server/api/profile/image/[memberId]/[size].get.ts b/server/api/profile/image/[memberId]/[size].get.ts new file mode 100644 index 0000000..0693ec5 --- /dev/null +++ b/server/api/profile/image/[memberId]/[size].get.ts @@ -0,0 +1,61 @@ +import { getProfileImageUrl } from '~/server/utils/profile-images'; + +export default defineEventHandler(async (event) => { + try { + // Get route parameters + const memberId = getRouterParam(event, 'memberId'); + const size = getRouterParam(event, 'size') as 'original' | 'small' | 'medium'; + + if (!memberId) { + throw createError({ + statusCode: 400, + statusMessage: 'Member ID is required', + }); + } + + // Validate size parameter + const validSizes = ['original', 'small', 'medium']; + if (!validSizes.includes(size)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid size. Must be one of: original, small, medium', + }); + } + + console.log(`[profile-image] Getting image URL for member: ${memberId}, size: ${size}`); + + // Get presigned URL for the image + const imageUrl = await getProfileImageUrl(memberId, size); + + if (!imageUrl) { + throw createError({ + statusCode: 404, + statusMessage: 'Profile image not found', + }); + } + + // Set cache headers for better performance + setHeader(event, 'Cache-Control', 'public, max-age=300'); // 5 minutes + setHeader(event, 'Content-Type', 'application/json'); + + return { + success: true, + imageUrl, + memberId, + size, + expiresIn: 3600, // URLs expire in 1 hour + }; + + } catch (error: any) { + console.error('[profile-image] Failed to get image URL:', error); + + if (error.statusCode) { + throw error; // Re-throw HTTP errors + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to retrieve profile image', + }); + } +}); diff --git a/server/api/profile/upload-image.post.ts b/server/api/profile/upload-image.post.ts new file mode 100644 index 0000000..9571493 --- /dev/null +++ b/server/api/profile/upload-image.post.ts @@ -0,0 +1,148 @@ +import formidable from 'formidable'; +import { promises as fs } from 'fs'; +import { + uploadProfileImage, + updateMemberProfileImageUrl, + validateImageFile +} from '~/server/utils/profile-images'; + +// Authentication utility - we'll need to check if it exists +async function requireAuth(event: any) { + // Check for session-based authentication + const sessionCookie = getCookie(event, 'auth-token') || getCookie(event, 'nuxt-oidc-auth-session'); + + if (!sessionCookie) { + throw createError({ + statusCode: 401, + statusMessage: 'Authentication required', + }); + } + + // For now, return a basic user object - this should integrate with your existing auth system + const user = event.context.user; + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid authentication', + }); + } + + return user; +} + +// Role-based access control +function canEditMember(user: any, targetMemberId: string): boolean { + // Admin can edit anyone + if (user.tier === 'admin' || user.groups?.includes('admin') || user.groups?.includes('monaco-admin')) { + return true; + } + + // Board members can edit anyone + if (user.tier === 'board' || user.groups?.includes('board') || user.groups?.includes('monaco-board')) { + return true; + } + + // Users can only edit their own profile + // We'll need to match by email or keycloak ID since users might not know their member_id + return user.email === targetMemberId || user.member_id === targetMemberId; +} + +export default defineEventHandler(async (event) => { + try { + // Check authentication + const user = await requireAuth(event); + + // Get query parameters + const query = getQuery(event); + const targetMemberId = query.memberId as string; + + if (!targetMemberId) { + throw createError({ + statusCode: 400, + statusMessage: 'Member ID is required', + }); + } + + // Check permissions + if (!canEditMember(user, targetMemberId)) { + throw createError({ + statusCode: 403, + statusMessage: 'You can only upload images for your own profile', + }); + } + + console.log(`[profile-upload] Processing upload for member: ${targetMemberId}`); + + // Parse multipart form data + const form = formidable({ + maxFileSize: 5 * 1024 * 1024, // 5MB limit + keepExtensions: true, + allowEmptyFiles: false, + maxFiles: 1, + }); + + const [fields, files] = await form.parse(event.node.req); + + // Get the uploaded file + const uploadedFile = Array.isArray(files.image) ? files.image[0] : files.image; + + if (!uploadedFile) { + throw createError({ + statusCode: 400, + statusMessage: 'No image file provided', + }); + } + + console.log(`[profile-upload] File received: ${uploadedFile.originalFilename}, size: ${uploadedFile.size} bytes`); + + // Read file buffer + const fileBuffer = await fs.readFile(uploadedFile.filepath); + + // Validate the image file + validateImageFile(fileBuffer, uploadedFile.originalFilename || 'image.jpg'); + + // Upload image and generate thumbnails + const imagePath = await uploadProfileImage( + targetMemberId, + fileBuffer, + uploadedFile.originalFilename || 'profile.jpg' + ); + + // Update database with image path + await updateMemberProfileImageUrl( + targetMemberId, + imagePath, + uploadedFile.originalFilename || 'profile.jpg' + ); + + // Clean up temporary file + try { + await fs.unlink(uploadedFile.filepath); + } catch (error) { + console.warn('[profile-upload] Failed to clean up temp file:', error); + } + + console.log(`[profile-upload] Successfully uploaded profile image for member: ${targetMemberId}`); + + return { + success: true, + message: 'Profile image uploaded successfully', + imagePath, + originalName: uploadedFile.originalFilename, + size: uploadedFile.size, + }; + + } catch (error: any) { + console.error('[profile-upload] Upload failed:', error); + + // Provide specific error messages + if (error.statusCode) { + throw error; // Re-throw HTTP errors + } + + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Profile image upload failed', + }); + } +}); diff --git a/server/utils/minio.ts b/server/utils/minio.ts new file mode 100644 index 0000000..f18407a --- /dev/null +++ b/server/utils/minio.ts @@ -0,0 +1,200 @@ +import { Client } 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, + }); +}; + +// 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, + }); +}; + +// 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); +}; + +// Get file statistics +export const getFileStats = async (fileName: string) => { + const client = getMinioClient(); + const bucketName = useRuntimeConfig().minio.bucketName; + + return await client.statObject(bucketName, fileName); +}; + +// 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; + } +}; + +// List files with prefix 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(); + + 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); + } + }); +}; diff --git a/server/utils/profile-images.ts b/server/utils/profile-images.ts new file mode 100644 index 0000000..97e80da --- /dev/null +++ b/server/utils/profile-images.ts @@ -0,0 +1,296 @@ +import sharp from 'sharp'; +import { uploadFile, deleteFile, getPreviewUrl } from './minio'; +import { getNocoDbConfiguration, Table, createTableUrl } from './nocodb'; +import type { Member } from '~/utils/types'; +import type { EntityResponse } from './nocodb'; + +interface ThumbnailSizes { + small: { width: 64, height: 64 }; + medium: { width: 200, height: 200 }; +} + +const THUMBNAIL_SIZES: ThumbnailSizes = { + small: { width: 64, height: 64 }, // For header avatar + medium: { width: 200, height: 200 } // For profile cards and profile page +}; + +/** + * Upload profile image with automatic thumbnail generation + */ +export const uploadProfileImage = async ( + memberId: string, + buffer: Buffer, + originalName: string +): Promise => { + if (!memberId) { + throw new Error('Member ID is required for profile image upload'); + } + + try { + // Validate image format + const metadata = await sharp(buffer).metadata(); + if (!metadata.format || !['jpeg', 'jpg', 'png', 'webp'].includes(metadata.format)) { + throw new Error('Invalid image format. Only JPEG, PNG, and WebP are supported.'); + } + + // Ensure minimum dimensions + if (!metadata.width || !metadata.height || metadata.width < 100 || metadata.height < 100) { + throw new Error('Image must be at least 100x100 pixels.'); + } + + const basePath = `profile-images/${memberId}`; + const timestamp = Date.now(); + + // Process original image (convert to JPEG for consistency) + const processedBuffer = await sharp(buffer) + .jpeg({ quality: 90, mozjpeg: true }) + .toBuffer(); + + const originalPath = `${basePath}/profile.jpg`; + + // Generate thumbnails + const thumbnails: Record = {}; + for (const [sizeName, dimensions] of Object.entries(THUMBNAIL_SIZES)) { + thumbnails[sizeName] = await sharp(buffer) + .resize(dimensions.width, dimensions.height, { + fit: 'cover', + position: 'center' + }) + .jpeg({ quality: 85 }) + .toBuffer(); + } + + // Upload original image + await uploadFile(originalPath, processedBuffer, 'image/jpeg'); + console.log(`✅ Uploaded profile image: ${originalPath}`); + + // Upload thumbnails + for (const [sizeName, thumbnailBuffer] of Object.entries(thumbnails)) { + const thumbnailPath = `${basePath}/profile_${sizeName}.jpg`; + await uploadFile(thumbnailPath, thumbnailBuffer, 'image/jpeg'); + console.log(`✅ Generated ${sizeName} thumbnail: ${thumbnailPath}`); + } + + return originalPath; + } catch (error) { + console.error('Failed to upload profile image:', error); + throw error; + } +}; + +/** + * Delete profile image and all associated thumbnails + */ +export const deleteProfileImage = async (memberId: string): Promise => { + if (!memberId) { + throw new Error('Member ID is required for profile image deletion'); + } + + try { + const basePath = `profile-images/${memberId}`; + + // Delete original image + try { + await deleteFile(`${basePath}/profile.jpg`); + console.log(`🗑️ Deleted profile image: ${basePath}/profile.jpg`); + } catch (error) { + console.warn('Original profile image not found or already deleted'); + } + + // Delete thumbnails + for (const sizeName of Object.keys(THUMBNAIL_SIZES)) { + try { + await deleteFile(`${basePath}/profile_${sizeName}.jpg`); + console.log(`🗑️ Deleted ${sizeName} thumbnail: ${basePath}/profile_${sizeName}.jpg`); + } catch (error) { + console.warn(`${sizeName} thumbnail not found or already deleted`); + } + } + } catch (error) { + console.error('Failed to delete profile image:', error); + throw error; + } +}; + +/** + * Get profile image URL for display + */ +export const getProfileImageUrl = async ( + memberId: string, + size: 'original' | 'small' | 'medium' = 'original' +): Promise => { + if (!memberId) { + return null; + } + + try { + const basePath = `profile-images/${memberId}`; + let imagePath: string; + + if (size === 'original') { + imagePath = `${basePath}/profile.jpg`; + } else { + imagePath = `${basePath}/profile_${size}.jpg`; + } + + // Generate presigned URL + const url = await getPreviewUrl(imagePath, 'image/jpeg'); + return url; + } catch (error) { + console.warn(`Profile image not found for member ${memberId}, size ${size}`); + return null; + } +}; + +/** + * Update member profile image URL in NocoDB + */ +export const updateMemberProfileImageUrl = async ( + memberId: string, + imagePath: string, + originalName: string +): Promise => { + try { + // Find member by member_id using existing pattern + const response = await $fetch>(createTableUrl(Table.Members), { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + where: `(member_id,eq,${memberId})`, + limit: 1, + }, + }); + + if (!response.list || response.list.length === 0) { + throw new Error(`Member not found with ID: ${memberId}`); + } + + const member = response.list[0]; + + // Update profile image fields using PATCH method + const updateData = { + Id: parseInt(member.Id), + profile_image_url: imagePath, + profile_image_updated_at: new Date().toISOString(), + profile_image_original_name: originalName + }; + + await $fetch(createTableUrl(Table.Members), { + method: "PATCH", + headers: { + "xc-token": getNocoDbConfiguration().token, + "Content-Type": "application/json" + }, + body: updateData + }); + + console.log(`✅ Updated profile image URL for member ${memberId}`); + } catch (error) { + console.error('Failed to update member profile image URL:', error); + throw error; + } +}; + +/** + * Remove member profile image URL from NocoDB + */ +export const removeMemberProfileImageUrl = async (memberId: string): Promise => { + try { + // Find member by member_id using existing pattern + const response = await $fetch>(createTableUrl(Table.Members), { + headers: { + "xc-token": getNocoDbConfiguration().token, + }, + params: { + where: `(member_id,eq,${memberId})`, + limit: 1, + }, + }); + + if (!response.list || response.list.length === 0) { + throw new Error(`Member not found with ID: ${memberId}`); + } + + const member = response.list[0]; + + // Clear profile image fields using PATCH method + const updateData = { + Id: parseInt(member.Id), + profile_image_url: null, + profile_image_updated_at: new Date().toISOString(), + profile_image_original_name: null + }; + + await $fetch(createTableUrl(Table.Members), { + method: "PATCH", + headers: { + "xc-token": getNocoDbConfiguration().token, + "Content-Type": "application/json" + }, + body: updateData + }); + + console.log(`✅ Removed profile image URL for member ${memberId}`); + } catch (error) { + console.error('Failed to remove member profile image URL:', error); + throw error; + } +}; + +/** + * Validate image file before processing + */ +export const validateImageFile = (buffer: Buffer, filename: string): void => { + // Check file size (5MB max) + const maxSize = 5 * 1024 * 1024; + if (buffer.length > maxSize) { + throw new Error('Image file size must be less than 5MB'); + } + + // Check file extension + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp']; + const extension = filename.toLowerCase().substring(filename.lastIndexOf('.')); + if (!allowedExtensions.includes(extension)) { + throw new Error('Only JPEG, PNG, and WebP image files are allowed'); + } +}; + +/** + * Generate user initials from name (fallback for missing profile images) + */ +export const generateInitials = (firstName?: string, lastName?: string, fullName?: string): string => { + if (firstName && lastName) { + return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + } + + if (fullName) { + const names = fullName.trim().split(' '); + if (names.length >= 2) { + return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase(); + } + return names[0].charAt(0).toUpperCase(); + } + + return '?'; +}; + +/** + * Generate background color for initials based on name + */ +export const generateAvatarColor = (name: string): string => { + const colors = [ + '#f44336', '#e91e63', '#9c27b0', '#673ab7', + '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4', + '#009688', '#4caf50', '#8bc34a', '#cddc39', + '#ff9800', '#ff5722', '#795548', '#607d8b' + ]; + + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + + const index = Math.abs(hash) % colors.length; + return colors[index]; +}; diff --git a/utils/types.ts b/utils/types.ts index 0834b79..41e88d2 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -135,6 +135,11 @@ export interface Member { registration_date?: string; // New field for tracking registration date portal_group?: 'user' | 'board' | 'admin'; // Portal access level group + // Profile Image Fields + profile_image_url?: string; // MinIO path: profile-images/{member_id}/profile.jpg + profile_image_updated_at?: string; // ISO timestamp for cache invalidation + profile_image_original_name?: string; // Original filename for audit trail + // Computed fields (added by processing) FullName?: string; FormattedPhone?: string;