import type { HttpContext } from '@adonisjs/core/http'; import { StatusCodes } from 'http-status-codes'; import redis from '@adonisjs/redis/services/main'; const PREFIXES = ['von', 'van']; const DEFAULT_SIZE = 50; const FONT_SIZE_RATIO = 0.4; const COLOR_LIGHTENING_PERCENT = 60; const COLOR_DARKENING_FACTOR = 0.6; export default class AvatarController { public async generateAvatar({ request, response }: HttpContext) { try { const { name, size = DEFAULT_SIZE } = request.only(['name', 'size']); if (!name) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'Name is required' }); } // Build a unique cache key for the given name and size const cacheKey = `avatar:${name.trim().toLowerCase()}-${size}`; const cachedSvg = await redis.get(cacheKey); if (cachedSvg) { this.setResponseHeaders(response); return response.send(cachedSvg); } 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 await redis.setex(cacheKey, 3600, svgContent); this.setResponseHeaders(response); return response.send(svgContent); } catch (error) { return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: error.message }); } } private getInitials(name: string): string { const parts = name .trim() .split(' ') .filter((part) => part.length > 0); if (parts.length === 0) { return 'NA'; } if (parts.length >= 2) { return this.getMultiWordInitials(parts); } return parts[0].substring(0, 2).toUpperCase(); } private getMultiWordInitials(parts: string[]): string { const firstName = parts[0]; const lastName = parts[parts.length - 1]; const firstInitial = firstName.charAt(0).toUpperCase(); const lastInitial = lastName.charAt(0).toUpperCase(); if (PREFIXES.includes(lastName.toLowerCase()) && lastName === lastName.toUpperCase()) { return firstInitial + lastName.charAt(1).toUpperCase(); } return firstInitial + lastInitial; } 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 = size * FONT_SIZE_RATIO; return ` ${initials} `; } private setResponseHeaders(response: HttpContext['response']): void { response.header('Content-type', 'image/svg+xml'); response.header('Cache-Control', 'no-cache'); response.header('Pragma', 'no-cache'); response.header('Expires', '0'); } private getColorFromName(name: string): string { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const colorParts = []; for (let i = 0; i < 3; i++) { const value = (hash >> (i * 8)) & 0xff; 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 * (100 + 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.round(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'); } }