130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
};
|
|
|
|
const CF_BASE = "https://api.curseforge.com/v1";
|
|
const MINECRAFT_GAME_ID = 432;
|
|
|
|
// Rate limiting
|
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
const RATE_LIMIT = 30;
|
|
const RATE_WINDOW = 60_000;
|
|
|
|
function checkRateLimit(ip: string): boolean {
|
|
const now = Date.now();
|
|
const entry = rateLimitMap.get(ip);
|
|
if (!entry || now > entry.resetAt) {
|
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW });
|
|
return true;
|
|
}
|
|
entry.count++;
|
|
return entry.count <= RATE_LIMIT;
|
|
}
|
|
|
|
function sanitizeString(str: string | null, maxLen = 100): string {
|
|
if (!str) return "";
|
|
return str.slice(0, maxLen).replace(/[<>{}]/g, "").trim();
|
|
}
|
|
|
|
serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
if (req.method !== "GET") {
|
|
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
if (!checkRateLimit(clientIp)) {
|
|
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
status: 429,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json", "Retry-After": "60" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const apiKey = Deno.env.get("CURSEFORGE_API_KEY");
|
|
if (!apiKey) {
|
|
return new Response(JSON.stringify({ error: "CurseForge API key not configured" }), {
|
|
status: 500,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const action = sanitizeString(url.searchParams.get("action"), 20);
|
|
|
|
const headers: Record<string, string> = {
|
|
"Accept": "application/json",
|
|
"x-api-key": apiKey,
|
|
};
|
|
|
|
let cfUrl = "";
|
|
|
|
switch (action) {
|
|
case "search": {
|
|
const query = sanitizeString(url.searchParams.get("query"), 200);
|
|
const offset = Math.max(0, Math.min(parseInt(url.searchParams.get("offset") || "0") || 0, 1000));
|
|
const limit = Math.max(1, Math.min(parseInt(url.searchParams.get("limit") || "20") || 20, 50));
|
|
const classId = sanitizeString(url.searchParams.get("classId"), 10);
|
|
|
|
cfUrl = `${CF_BASE}/mods/search?gameId=${MINECRAFT_GAME_ID}&searchFilter=${encodeURIComponent(query)}&index=${offset}&pageSize=${limit}&sortField=2&sortOrder=desc`;
|
|
if (classId) {
|
|
cfUrl += `&classId=${classId}`;
|
|
}
|
|
break;
|
|
}
|
|
case "mod": {
|
|
const id = sanitizeString(url.searchParams.get("id"), 20);
|
|
if (!id || !/^\d+$/.test(id)) {
|
|
return new Response(JSON.stringify({ error: "Invalid mod id" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
cfUrl = `${CF_BASE}/mods/${id}`;
|
|
break;
|
|
}
|
|
case "files": {
|
|
const id = sanitizeString(url.searchParams.get("id"), 20);
|
|
if (!id || !/^\d+$/.test(id)) {
|
|
return new Response(JSON.stringify({ error: "Invalid mod id" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
cfUrl = `${CF_BASE}/mods/${id}/files?pageSize=10`;
|
|
break;
|
|
}
|
|
default:
|
|
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const response = await fetch(cfUrl, { headers });
|
|
const data = await response.json();
|
|
|
|
return new Response(JSON.stringify(data), {
|
|
status: response.status,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
|
status: 500,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
});
|