import type { HttpContext } from '@adonisjs/core/http'; import { StatusCodes } from 'http-status-codes'; import redis from '@adonisjs/redis/services/main'; const PREFIXES = ['von', 'van', 'de', 'del', 'della', 'di', 'da', 'dos', 'du', 'le', 'la']; const DEFAULT_SIZE = 50; const MIN_SIZE = 16; const MAX_SIZE = 512; const FONT_SIZE_RATIO = 0.4; const COLOR_LIGHTENING_PERCENT = 60; const COLOR_DARKENING_FACTOR = 0.6; const CACHE_TTL = 24 * 60 * 60; // 24 hours instead of 1 hour export default class AvatarController { public async generateAvatar({ request, response }: HttpContext) { try { const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']); // Enhanced validation if (!name || typeof name !== 'string' || name.trim().length === 0) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required and must be a non-empty string', }); } const parsedSize = this.validateSize(size); if (!parsedSize.isValid) { return response.status(StatusCodes.BAD_REQUEST).json({ error: parsedSize.error, }); } // Build a unique cache key for the given name and size const cacheKey = `avatar:${this.sanitizeName(name)}-${parsedSize.value}`; // const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`; try { const cachedSvg = await redis.get(cacheKey); if (cachedSvg) { this.setResponseHeaders(response); return response.send(cachedSvg); } } catch (redisError) { // Log redis error but continue without cache console.warn('Redis cache read failed:', redisError); } const initials = this.getInitials(name); const colors = this.generateColors(name); const svgContent = this.createSvg(size, colors, initials); // // Cache the generated avatar for future use, e.g. 1 hour expiry try { await redis.setex(cacheKey, CACHE_TTL, svgContent); } catch (redisError) { // Log but don't fail the request console.warn('Redis cache write failed:', redisError); } this.setResponseHeaders(response); return response.send(svgContent); } catch (error) { console.error('Avatar generation error:', error); return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: 'Failed to generate avatar', }); } } private validateSize(size: any): { isValid: boolean; value?: number; error?: string } { const numSize = Number(size); if (isNaN(numSize)) { return { isValid: false, error: 'Size must be a valid number' }; } if (numSize < MIN_SIZE || numSize > MAX_SIZE) { return { isValid: false, error: `Size must be between ${MIN_SIZE} and ${MAX_SIZE}`, }; } return { isValid: true, value: Math.floor(numSize) }; } private sanitizeName(name: string): string { return name .trim() .toLowerCase() .replace(/[^a-z0-9\s-]/gi, ''); } private getInitials(name: string): string { const sanitized = name.trim().replace(/\s+/g, ' '); // normalize whitespace const parts = sanitized .split(' ') .filter((part) => part.length > 0) .map((part) => part.trim()); if (parts.length === 0) { return 'NA'; } if (parts.length === 1) { // For single word, take first 2 characters or first char if only 1 char return parts[0].substring(0, Math.min(2, parts[0].length)).toUpperCase(); } return this.getMultiWordInitials(parts); } private getMultiWordInitials(parts: string[]): string { // Filter out prefixes and short words const significantParts = parts.filter((part) => !PREFIXES.includes(part.toLowerCase()) && part.length > 1); if (significantParts.length === 0) { // Fallback to first and last regardless of prefixes const firstName = parts[0]; const lastName = parts[parts.length - 1]; return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); } if (significantParts.length === 1) { return significantParts[0].substring(0, 2).toUpperCase(); } // Take first and last significant parts const firstName = significantParts[0]; const lastName = significantParts[significantParts.length - 1]; return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase(); } private generateColors(name: string): { background: string; text: string } { const baseColor = this.getColorFromName(name); return { background: this.lightenColor(baseColor, COLOR_LIGHTENING_PERCENT), text: this.darkenColor(baseColor), }; } private createSvg(size: number, colors: { background: string; text: string }, initials: string): string { const fontSize = Math.max(12, Math.floor(size * FONT_SIZE_RATIO)); // Ensure readable font size // Escape any potential HTML/XML characters in initials const escapedInitials = this.escapeXml(initials); return ` ${escapedInitials} `; } private escapeXml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } private setResponseHeaders(response: HttpContext['response']): void { response.header('Content-Type', 'image/svg+xml'); response.header('Cache-Control', 'public, max-age=86400'); // Cache for 1 day response.header('ETag', `"${Date.now()}"`); // Simple ETag } private getColorFromName(name: string): string { let hash = 0; const normalizedName = name.toLowerCase().trim(); for (let i = 0; i < normalizedName.length; i++) { hash = normalizedName.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; // Convert to 32-bit integer } // Ensure we get vibrant colors by constraining the color space const colorParts = []; for (let i = 0; i < 3; i++) { let value = (hash >> (i * 8)) & 0xff; // Ensure minimum color intensity for better contrast value = Math.max(50, value); colorParts.push(value.toString(16).padStart(2, '0')); } return colorParts.join(''); } private lightenColor(hexColor: string, percent: number): string { const r = parseInt(hexColor.substring(0, 2), 16); const g = parseInt(hexColor.substring(2, 4), 16); const b = parseInt(hexColor.substring(4, 6), 16); const lightenValue = (value: number) => Math.min(255, Math.floor(value + (255 - value) * (percent / 100))); const newR = lightenValue(r); const newG = lightenValue(g); const newB = lightenValue(b); return ((newR << 16) | (newG << 8) | newB).toString(16).padStart(6, '0'); } private darkenColor(hexColor: string): string { const r = parseInt(hexColor.slice(0, 2), 16); const g = parseInt(hexColor.slice(2, 4), 16); const b = parseInt(hexColor.slice(4, 6), 16); const darkenValue = (value: number) => Math.max(0, Math.floor(value * COLOR_DARKENING_FACTOR)); const darkerR = darkenValue(r); const darkerG = darkenValue(g); const darkerB = darkenValue(b); return ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16).padStart(6, '0'); } }