2025-12-03 13:27:37 -05:00
|
|
|
/**
|
|
|
|
|
* API endpoint to serve cached images from the database
|
|
|
|
|
* Serves avatars, emojis, attachments, etc. from local cache
|
|
|
|
|
*
|
2025-12-05 14:21:52 -05:00
|
|
|
* Security: Uses HMAC signatures to prevent enumeration of image IDs.
|
|
|
|
|
* Images can only be accessed with a valid signature generated server-side.
|
2025-12-03 13:27:37 -05:00
|
|
|
*/
|
|
|
|
|
import sql from '../../../utils/db.js';
|
2025-12-05 14:21:52 -05:00
|
|
|
import crypto from 'crypto';
|
|
|
|
|
import {
|
|
|
|
|
checkRateLimit,
|
|
|
|
|
recordRequest,
|
|
|
|
|
} from '../../../utils/rateLimit.js';
|
|
|
|
|
|
|
|
|
|
// 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 {string|number} imageId - The image ID to sign
|
|
|
|
|
* @returns {string} - The hex signature
|
|
|
|
|
*/
|
|
|
|
|
export function signImageId(imageId) {
|
|
|
|
|
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 {string|number} imageId - The image ID
|
|
|
|
|
* @param {string} signature - The signature to verify
|
|
|
|
|
* @returns {boolean} - Whether signature is valid
|
|
|
|
|
*/
|
|
|
|
|
function verifyImageSignature(imageId, signature) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-03 13:27:37 -05:00
|
|
|
|
|
|
|
|
export async function GET({ request }) {
|
2025-12-05 14:21:52 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
const url = new URL(request.url);
|
|
|
|
|
const imageId = url.searchParams.get('id');
|
2025-12-05 14:21:52 -05:00
|
|
|
const signature = url.searchParams.get('sig');
|
2025-12-03 13:27:37 -05:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:21:52 -05:00
|
|
|
// Require valid signature for ID-based lookups to prevent enumeration
|
|
|
|
|
if (imageId && !verifyImageSignature(imageId, signature)) {
|
|
|
|
|
return new Response('Invalid or missing signature', { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 13:27:37 -05:00
|
|
|
try {
|
|
|
|
|
let image;
|
|
|
|
|
|
|
|
|
|
if (imageId) {
|
2025-12-05 14:21:52 -05:00
|
|
|
// Look up by image_id (signature already verified above)
|
2025-12-03 13:27:37 -05:00
|
|
|
const result = await sql`
|
|
|
|
|
SELECT image_data, content_type, source_url
|
|
|
|
|
FROM image_cache
|
|
|
|
|
WHERE image_id = ${imageId}
|
|
|
|
|
`;
|
|
|
|
|
image = result[0];
|
|
|
|
|
} else {
|
2025-12-05 14:21:52 -05:00
|
|
|
// Look up by source_url - no signature needed as URL itself is the identifier
|
2025-12-03 13:27:37 -05:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|