Add profile image system with MinIO storage
Some checks failed
Build And Push Image / docker (push) Failing after 1m5s

- Implement ProfileAvatar component for user avatars
- Integrate MinIO for profile image storage and management
- Add profile image fields to Member type definition
- Create server utilities and API endpoints for image handling
- Replace basic avatar icon with new ProfileAvatar in dashboard
- Update sharp dependency to v0.34.3
This commit is contained in:
2025-08-14 10:28:40 +02:00
parent 0952d6c381
commit 2ff0c31bbd
11 changed files with 3099 additions and 5 deletions

View File

@@ -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',
});
}
});

View File

@@ -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',
});
}
});

View File

@@ -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',
});
}
});

200
server/utils/minio.ts Normal file
View File

@@ -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<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);
}
});
};

View File

@@ -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<string> => {
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<string, Buffer> = {};
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<void> => {
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<string | null> => {
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<void> => {
try {
// Find member by member_id using existing pattern
const response = await $fetch<EntityResponse<Member>>(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<void> => {
try {
// Find member by member_id using existing pattern
const response = await $fetch<EntityResponse<Member>>(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];
};