- Updated Person interface to include first_name and last_name fields for better clarity and organization handling. - Modified TablePersons.vue to support new fields, including improved pagination and drag-and-drop functionality. - Added loading states and error handling for form controls within the table. - Enhanced the visual layout of the table with responsive design adjustments. - Updated solr.xslt to correctly reference ServerDateModified and EmbargoDate attributes. - updated AvatarController - improved download method for editor, and reviewer - improved security for officlial download file file API: filterd by server_state
212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
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 `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">
|
|
<rect width="100%" height="100%" fill="#${colors.background}" rx="${size * 0.1}"/>
|
|
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
|
|
font-weight="600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
|
font-size="${fontSize}" fill="#${colors.text}">${escapedInitials}</text>
|
|
</svg>`;
|
|
}
|
|
|
|
private escapeXml(text: string): string {
|
|
return text.replace(/&/g, '&').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');
|
|
}
|
|
}
|