166 lines
6.3 KiB
TypeScript
166 lines
6.3 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 MODRINTH_BASE = "https://api.modrinth.com/v2";
|
|
|
|
// Rate limiting: track requests per IP
|
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
const RATE_LIMIT = 30; // requests per window
|
|
const RATE_WINDOW = 60_000; // 1 minute
|
|
|
|
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;
|
|
}
|
|
|
|
// Input sanitization
|
|
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 });
|
|
}
|
|
|
|
// Only allow GET
|
|
if (req.method !== "GET") {
|
|
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
// Rate limit check
|
|
const clientIp = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
if (!checkRateLimit(clientIp)) {
|
|
return new Response(JSON.stringify({ error: "Too many requests. Please try again later." }), {
|
|
status: 429,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json", "Retry-After": "60" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const apiKey = Deno.env.get("MODRINTH_API_KEY");
|
|
const url = new URL(req.url);
|
|
const action = sanitizeString(url.searchParams.get("action"), 20);
|
|
|
|
const headers: Record<string, string> = {
|
|
"User-Agent": "FXCraft/1.0",
|
|
};
|
|
if (apiKey) {
|
|
headers["Authorization"] = apiKey;
|
|
}
|
|
|
|
let modrinthUrl = "";
|
|
|
|
switch (action) {
|
|
case "user_projects": {
|
|
const username = sanitizeString(url.searchParams.get("username"), 30) || "fxfelixzero";
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
return new Response(JSON.stringify({ error: "Invalid username" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
modrinthUrl = `${MODRINTH_BASE}/user/${username}/projects`;
|
|
break;
|
|
}
|
|
case "search": {
|
|
const query = sanitizeString(url.searchParams.get("query"), 200);
|
|
const facets = sanitizeString(url.searchParams.get("facets"), 500);
|
|
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, 100));
|
|
modrinthUrl = `${MODRINTH_BASE}/search?query=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`;
|
|
if (facets) {
|
|
modrinthUrl += `&facets=${encodeURIComponent(facets)}`;
|
|
}
|
|
break;
|
|
}
|
|
case "project": {
|
|
const id = sanitizeString(url.searchParams.get("id"), 50);
|
|
if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
return new Response(JSON.stringify({ error: "Invalid project id" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
modrinthUrl = `${MODRINTH_BASE}/project/${id}`;
|
|
break;
|
|
}
|
|
case "versions": {
|
|
const id = sanitizeString(url.searchParams.get("id"), 50);
|
|
if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
return new Response(JSON.stringify({ error: "Invalid project id" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
modrinthUrl = `${MODRINTH_BASE}/project/${id}/version`;
|
|
break;
|
|
}
|
|
default:
|
|
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
const response = await fetch(modrinthUrl, { headers });
|
|
let data = await response.json();
|
|
|
|
// Filter out felix-fx-i and remove modrinth links from descriptions
|
|
if (Array.isArray(data)) {
|
|
data = data.filter((mod: any) => mod.slug !== 'felix-fx-i' && mod.id !== 'felix-fx-i');
|
|
data = data.map((mod: any) => ({
|
|
...mod,
|
|
description: mod.description ? mod.description.replace(/https?:\/\/modrinth\.com\/[^\s\)\]\}]*/g, '').trim() : mod.description,
|
|
body: mod.body ? mod.body.replace(/https?:\/\/modrinth\.com\/[^\s\)\]\}]*/g, '').trim() : mod.body
|
|
}));
|
|
} else if (data && typeof data === 'object' && 'hits' in data && Array.isArray(data.hits)) {
|
|
data.hits = data.hits.filter((mod: any) => mod.slug !== 'felix-fx-i' && mod.id !== 'felix-fx-i');
|
|
data.hits = data.hits.map((mod: any) => ({
|
|
...mod,
|
|
description: mod.description ? mod.description.replace(/https?:\/\/modrinth\.com\/[^\s\)\]\}]*/g, '').trim() : mod.description
|
|
}));
|
|
} else if (data && typeof data === 'object') {
|
|
if (data.slug === 'felix-fx-i' || data.id === 'felix-fx-i') {
|
|
return new Response(JSON.stringify({ error: 'Project not found' }), {
|
|
status: 404,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
if (data.description) {
|
|
data.description = data.description.replace(/https?:\/\/modrinth\.com\/[^\s\)\]\}]*/g, '').trim();
|
|
}
|
|
if (data.body) {
|
|
data.body = data.body.replace(/https?:\/\/modrinth\.com\/[^\s\)\]\}]*/g, '').trim();
|
|
}
|
|
}
|
|
|
|
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" },
|
|
});
|
|
}
|
|
});
|