/** * API endpoint to serve cached images from the database * Serves avatars, emojis, attachments, etc. from local cache * * Security: Uses HMAC signatures to prevent enumeration of image IDs. * Images can only be accessed with a valid signature generated server-side. */ import sql from '../../../utils/db.ts'; import crypto from 'crypto'; import { checkRateLimit, recordRequest, } from '../../../utils/rateLimit.ts'; import type { APIContext } from 'astro'; // Secret for signing image IDs - prevents enumeration attacks const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; if (!IMAGE_CACHE_SECRET) { console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!'); } /** * Generate HMAC signature for an image ID * @param imageId - The image ID to sign * @returns The hex signature */ export function signImageId(imageId: string | number): string { if (!IMAGE_CACHE_SECRET) { throw new Error('IMAGE_CACHE_SECRET not configured'); } const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET); hmac.update(String(imageId)); return hmac.digest('hex').substring(0, 16); // Short signature is sufficient } /** * Verify HMAC signature for an image ID * @param imageId - The image ID * @param signature - The signature to verify * @returns Whether signature is valid */ function verifyImageSignature(imageId: string | number, signature: string | null): boolean { if (!IMAGE_CACHE_SECRET || !signature) return false; const expected = signImageId(imageId); // Timing-safe comparison try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } catch { return false; } } export async function GET({ request }: APIContext): Promise { // Rate limit check - higher limit for images but still protected const rateCheck = checkRateLimit(request, { limit: 100, windowMs: 1000, burstLimit: 500, burstWindowMs: 10_000, }); if (!rateCheck.allowed) { return new Response('Rate limit exceeded', { status: 429, headers: { 'Retry-After': '1' }, }); } recordRequest(request, 1000); const url = new URL(request.url); const imageId = url.searchParams.get('id'); const signature = url.searchParams.get('sig'); const sourceUrl = url.searchParams.get('url'); if (!imageId && !sourceUrl) { return new Response('Missing id or url parameter', { status: 400 }); } // Validate imageId is a valid integer if provided if (imageId && !/^\d+$/.test(imageId)) { return new Response('Invalid image id', { status: 400 }); } // Require valid signature for ID-based lookups to prevent enumeration if (imageId && !verifyImageSignature(imageId, signature)) { return new Response('Invalid or missing signature', { status: 403 }); } try { let image; if (imageId) { // Look up by image_id (signature already verified above) const result = await sql` SELECT image_data, content_type, source_url FROM image_cache WHERE image_id = ${imageId} `; image = result[0]; } else { // Look up by source_url - no signature needed as URL itself is the identifier const result = await sql` SELECT image_data, content_type, source_url FROM image_cache WHERE source_url = ${sourceUrl} `; image = result[0]; } if (!image) { return new Response('Image not found in cache', { status: 404 }); } // image_data is a Buffer (bytea) const imageBuffer = image.image_data; const contentType = image.content_type || 'image/png'; return new Response(imageBuffer, { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': imageBuffer.length.toString(), 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year since it's immutable 'X-Content-Type-Options': 'nosniff', }, }); } catch (error) { console.error('Error serving cached image:', error); return new Response('Failed to serve image', { status: 500 }); } }