Add profile image system with MinIO storage
Some checks failed
Build And Push Image / docker (push) Failing after 1m5s
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:
94
server/api/profile/image/[memberId].delete.ts
Normal file
94
server/api/profile/image/[memberId].delete.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
61
server/api/profile/image/[memberId]/[size].get.ts
Normal file
61
server/api/profile/image/[memberId]/[size].get.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
148
server/api/profile/upload-image.post.ts
Normal file
148
server/api/profile/upload-image.post.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user