This commit is contained in:
2025-12-02 10:05:43 -05:00
parent 6660b9ffd0
commit c3f0197115
11 changed files with 666 additions and 125 deletions

View File

@@ -1,24 +1,12 @@
const rateLimitMap = new Map();
function checkRateLimit(userId, limit = 5, windowMs = 1000) {
const now = Date.now();
const key = userId;
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetTime) {
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
return true;
}
if (entry.count >= limit) {
return false;
}
entry.count++;
return true;
}
import { getSubsiteByHost } from '../../utils/subsites.js';
import {
checkRateLimit,
recordRequest,
getCookieId,
generateNonce,
createNonceCookie,
getClientIp,
} from '../../utils/rateLimit.js';
export async function GET({ request }) {
const host = request.headers.get('host');
@@ -28,27 +16,51 @@ export async function GET({ request }) {
return new Response('Not found', { status: 404 });
}
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
const hadCookie = !!userId;
if (!userId) {
userId = crypto.randomUUID();
// Rate limit check (5 requests per second, flood protection at 30/10s)
const rateCheck = checkRateLimit(request, {
limit: 5,
windowMs: 1000,
burstLimit: 30,
burstWindowMs: 10_000,
});
let cookieId = getCookieId(request);
const hadCookie = !!cookieId;
if (!cookieId) {
cookieId = generateNonce();
}
if (!checkRateLimit(userId)) {
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
if (!rateCheck.allowed) {
const errorMsg = rateCheck.isFlooding
? { error: 'Too many requests - please slow down' }
: { error: 'Rate limit exceeded' };
const response = new Response(JSON.stringify(errorMsg), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '1',
},
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
console.log(`[search] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`);
return response;
}
// Record the request for rate limiting
recordRequest(request, 1000);
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
if (!TMDB_API_KEY) {
console.error('TMDB_API_KEY not set');
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify([]), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
@@ -57,17 +69,23 @@ export async function GET({ request }) {
const q = url.searchParams.get('q');
if (!q || typeof q !== 'string' || !q.trim()) {
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
if (q.length > 100) {
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
@@ -96,16 +114,21 @@ export async function GET({ request }) {
overview: item.overview,
poster_path: item.poster_path,
}));
const response = new Response(JSON.stringify(filtered), { headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify(filtered), {
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
} catch (error) {
console.error('Error fetching suggestions:', error);
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify([]), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}

View File

@@ -1,34 +1,12 @@
const rateLimitMap = new Map();
function checkRateLimit(userId, limit = 1, windowMs = 15000) { // 1 per 15 seconds for submits
const now = Date.now();
const key = userId;
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetTime) {
return true; // Allow, will be recorded later
}
if (entry.count >= limit) {
return false;
}
return true; // Allow, will be recorded later
}
function recordRequest(userId, windowMs = 15000) {
const now = Date.now();
const key = userId;
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetTime) {
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
} else {
entry.count++;
}
}
import { getSubsiteByHost } from '../../utils/subsites.js';
import {
checkRateLimit,
recordRequest as recordRateLimitRequest,
getCookieId,
generateNonce,
createNonceCookie,
getClientIp,
} from '../../utils/rateLimit.js';
export async function POST({ request }) {
const host = request.headers.get('host');
@@ -37,17 +15,35 @@ export async function POST({ request }) {
return new Response('Not found', { status: 404 });
}
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
const hadCookie = !!userId;
if (!userId) {
userId = crypto.randomUUID();
// Rate limit check (1 request per 15 seconds, flood protection at 10/30s)
const rateCheck = checkRateLimit(request, {
limit: 1,
windowMs: 15_000,
burstLimit: 10,
burstWindowMs: 30_000,
});
let cookieId = getCookieId(request);
const hadCookie = !!cookieId;
if (!cookieId) {
cookieId = generateNonce();
}
if (!checkRateLimit(userId)) {
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
if (!rateCheck.allowed) {
const errorMsg = rateCheck.isFlooding
? { error: 'Too many requests - please slow down' }
: { error: 'Rate limit exceeded. Please wait before submitting again.' };
const response = new Response(JSON.stringify(errorMsg), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '15',
},
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
console.log(`[submit] rate limited: ip=${rateCheck.ip} flooding=${rateCheck.isFlooding}`);
return response;
}
@@ -55,9 +51,12 @@ export async function POST({ request }) {
if (!DISCORD_WEBHOOK_URL) {
console.error('DISCORD_WEBHOOK_URL not set');
const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
@@ -67,41 +66,56 @@ export async function POST({ request }) {
// Input validation
if (!title || typeof title !== 'string' || !title.trim()) {
const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
if (title.length > 200) {
const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
if (!['movie', 'tv'].includes(type)) {
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) {
const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
if (requester && (typeof requester !== 'string' || requester.length > 500)) {
const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}
@@ -216,18 +230,24 @@ export async function POST({ request }) {
throw new Error(`Discord webhook error: ${apiResponse.status}`);
}
recordRequest(userId);
// Record the request for rate limiting after successful submission
recordRateLimitRequest(request, 15_000);
const response = new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
} catch (error) {
console.error('Webhook submission error:', error);
const response = new Response(JSON.stringify({ error: 'Failed to submit' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
const response = new Response(JSON.stringify({ error: 'Failed to submit' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
if (!hadCookie) {
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
}
return response;
}