begin js(x) to ts(x)
This commit is contained in:
133
src/pages/api/discord/cached-image.ts
Normal file
133
src/pages/api/discord/cached-image.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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<Response> {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user