Files
codey.lol/src/pages/api/discord/cached-image.js

133 lines
3.9 KiB
JavaScript
Raw Normal View History

/**
* 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.js';
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;
}
}
export async function GET({ request }) {
// 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 });
}
}