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:
200
server/utils/minio.ts
Normal file
200
server/utils/minio.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
296
server/utils/profile-images.ts
Normal file
296
server/utils/profile-images.ts
Normal 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];
|
||||
};
|
||||
Reference in New Issue
Block a user