From bb9422d1f032349b7852fdaeb39b698abbc1470a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:52:38 +0000 Subject: [PATCH 1/2] Added auto search keywords Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com> --- supabase/functions/fetch-mods/index.ts | 73 +++++++++++++++---- supabase/functions/translate-search/index.ts | 77 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 supabase/functions/translate-search/index.ts diff --git a/supabase/functions/fetch-mods/index.ts b/supabase/functions/fetch-mods/index.ts index b9e12d0..6b8f146 100644 --- a/supabase/functions/fetch-mods/index.ts +++ b/supabase/functions/fetch-mods/index.ts @@ -2,20 +2,59 @@ 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", + "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", }; const MODRINTH_BASE = "https://api.modrinth.com/v2"; +// Rate limiting: track requests per IP +const rateLimitMap = new Map(); +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 = url.searchParams.get("action"); + const action = sanitizeString(url.searchParams.get("action"), 20); const headers: Record = { "User-Agent": "FXCraft/1.0", @@ -28,15 +67,21 @@ serve(async (req) => { switch (action) { case "user_projects": { - const username = url.searchParams.get("username") || "fxfelixzero"; + 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 = url.searchParams.get("query") || ""; - const facets = url.searchParams.get("facets") || ""; - const offset = url.searchParams.get("offset") || "0"; - const limit = url.searchParams.get("limit") || "20"; + 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)}`; @@ -44,9 +89,9 @@ serve(async (req) => { break; } case "project": { - const id = url.searchParams.get("id"); - if (!id) { - return new Response(JSON.stringify({ error: "Missing project id" }), { + 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" }, }); @@ -55,9 +100,9 @@ serve(async (req) => { break; } case "versions": { - const id = url.searchParams.get("id"); - if (!id) { - return new Response(JSON.stringify({ error: "Missing project id" }), { + 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" }, }); @@ -80,7 +125,7 @@ serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (error) { - return new Response(JSON.stringify({ error: error.message }), { + return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); diff --git a/supabase/functions/translate-search/index.ts b/supabase/functions/translate-search/index.ts new file mode 100644 index 0000000..d7f77bc --- /dev/null +++ b/supabase/functions/translate-search/index.ts @@ -0,0 +1,77 @@ +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", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const { query } = await req.json(); + + if (!query || typeof query !== "string" || query.trim().length === 0 || query.length > 200) { + return new Response(JSON.stringify({ error: "Invalid query" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); + if (!LOVABLE_API_KEY) { + return new Response(JSON.stringify({ error: "AI not configured" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${LOVABLE_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "google/gemini-2.5-flash-lite", + messages: [ + { + role: "system", + content: "You are a translator. Translate the user's Minecraft-related search query to English. Return ONLY the translated text, nothing else. If it's already in English, return it as-is. Keep Minecraft mod names unchanged.", + }, + { role: "user", content: query.trim() }, + ], + }), + }); + + if (!response.ok) { + if (response.status === 429) { + return new Response(JSON.stringify({ error: "Rate limited" }), { + status: 429, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const t = await response.text(); + console.error("AI error:", response.status, t); + return new Response(JSON.stringify({ error: "Translation failed" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const data = await response.json(); + const translated = data.choices?.[0]?.message?.content?.trim() || query; + + return new Response(JSON.stringify({ translated, original: query.trim() }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("translate error:", error); + return new Response(JSON.stringify({ error: "Translation failed" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); From a03c38c1ed8895e0a228e13db228448a64b9db37 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:53:20 +0000 Subject: [PATCH 2/2] Added search translation edge fn Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com> --- src/lib/api.ts | 46 ++++++++++++++- src/pages/Index.tsx | 33 +++++++++-- src/pages/SearchPage.tsx | 120 +++++++++++++++++++++++++++++++++++---- 3 files changed, 183 insertions(+), 16 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 61a5c62..970ee99 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,15 +2,29 @@ import { supabase } from "@/integrations/supabase/client"; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +// Anti-spam: debounce tracking +let lastSearchTime = 0; +const SEARCH_COOLDOWN = 500; // ms between searches + async function callFetchMods(params: Record) { + const now = Date.now(); + if (now - lastSearchTime < SEARCH_COOLDOWN) { + await new Promise((r) => setTimeout(r, SEARCH_COOLDOWN - (now - lastSearchTime))); + } + lastSearchTime = Date.now(); + const searchParams = new URLSearchParams(params); const url = `${SUPABASE_URL}/functions/v1/fetch-mods?${searchParams.toString()}`; const response = await fetch(url, { headers: { - "Authorization": `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`, + Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`, }, }); + if (response.status === 429) { + throw new Error("تم تجاوز الحد المسموح، حاول مرة أخرى بعد قليل"); + } + if (!response.ok) { throw new Error(`API error: ${response.status}`); } @@ -23,13 +37,41 @@ export async function getUserProjects(username = "fxfelixzero") { } export async function searchMods(query: string, offset = 0, limit = 20) { - return callFetchMods({ action: "search", query, offset: String(offset), limit: String(limit) }); + // Sanitize client-side too + const cleanQuery = query.slice(0, 200).replace(/[<>{}]/g, "").trim(); + if (!cleanQuery) return { hits: [], total_hits: 0 }; + return callFetchMods({ action: "search", query: cleanQuery, offset: String(offset), limit: String(limit) }); } export async function getProject(id: string) { + if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid project id"); return callFetchMods({ action: "project", id }); } export async function getProjectVersions(id: string) { + if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid project id"); return callFetchMods({ action: "versions", id }); } + +export async function translateQuery(query: string): Promise<{ translated: string; original: string }> { + const cleanQuery = query.slice(0, 200).trim(); + if (!cleanQuery) return { translated: query, original: query }; + + const { data, error } = await supabase.functions.invoke("translate-search", { + body: { query: cleanQuery }, + }); + + if (error) { + console.error("Translation error:", error); + return { translated: query, original: query }; + } + + return data; +} + +// Random search keywords for homepage +const RANDOM_KEYWORDS = ["world", "mod", "skin", "map", "adventure", "survival", "building", "tools", "armor", "biome"]; + +export function getRandomKeyword(): string { + return RANDOM_KEYWORDS[Math.floor(Math.random() * RANDOM_KEYWORDS.length)]; +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index f2d6d0b..1fd45da 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,16 +1,36 @@ import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import Navbar from "@/components/Navbar"; import HeroSection from "@/components/HeroSection"; import ModGrid from "@/components/ModGrid"; import Footer from "@/components/Footer"; -import { getUserProjects } from "@/lib/api"; +import { getUserProjects, searchMods, getRandomKeyword } from "@/lib/api"; const Index = () => { - const { data: mods, isLoading } = useQuery({ + const randomKeyword = useMemo(() => getRandomKeyword(), []); + + const { data: userMods, isLoading: loadingUser } = useQuery({ queryKey: ["user-projects"], queryFn: () => getUserProjects(), }); + const { data: discoverData, isLoading: loadingDiscover } = useQuery({ + queryKey: ["discover", randomKeyword], + queryFn: () => searchMods(randomKeyword, 0, 12), + }); + + const discoverMods = discoverData?.hits?.map((hit: any) => ({ + id: hit.project_id, + slug: hit.slug, + title: hit.title, + description: hit.description, + icon_url: hit.icon_url, + downloads: hit.downloads, + followers: hit.follows, + categories: hit.categories || [], + project_type: hit.project_type, + })) || []; + return (
@@ -19,8 +39,13 @@ const Index = () => {
+
diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index da5b9bc..512adf6 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import Navbar from "@/components/Navbar"; @@ -6,25 +6,82 @@ import ModGrid from "@/components/ModGrid"; import Footer from "@/components/Footer"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Search } from "lucide-react"; -import { searchMods } from "@/lib/api"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Search, Languages, Loader2 } from "lucide-react"; +import { searchMods, translateQuery } from "@/lib/api"; +import { toast } from "sonner"; const SearchPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const initialQuery = searchParams.get("q") || ""; const [query, setQuery] = useState(initialQuery); + const [autoTranslate, setAutoTranslate] = useState(true); + const [translatedText, setTranslatedText] = useState(null); + const [isTranslating, setIsTranslating] = useState(false); + + // Spam prevention: track rapid submissions + const [lastSubmit, setLastSubmit] = useState(0); const { data, isLoading } = useQuery({ - queryKey: ["search", initialQuery], - queryFn: () => searchMods(initialQuery), + queryKey: ["search", initialQuery, translatedText], + queryFn: async () => { + const searchTerm = translatedText || initialQuery; + const result = await searchMods(searchTerm); + + // If no results and autoTranslate is on, try translating + if (autoTranslate && !translatedText && result.total_hits === 0 && initialQuery) { + setIsTranslating(true); + try { + const { translated } = await translateQuery(initialQuery); + if (translated !== initialQuery) { + setTranslatedText(translated); + const translatedResult = await searchMods(translated); + setIsTranslating(false); + return translatedResult; + } + } catch { + toast.error("فشل في ترجمة البحث"); + } + setIsTranslating(false); + } + + return result; + }, enabled: !!initialQuery, }); - const handleSearch = (e: React.FormEvent) => { + const handleSearch = useCallback((e: React.FormEvent) => { e.preventDefault(); - if (query.trim()) { - setSearchParams({ q: query.trim() }); + const trimmed = query.trim().slice(0, 200); + if (!trimmed) return; + + // Anti-spam: minimum 1s between searches + const now = Date.now(); + if (now - lastSubmit < 1000) { + toast.error("انتظر قليلاً قبل البحث مرة أخرى"); + return; } + setLastSubmit(now); + setTranslatedText(null); + setSearchParams({ q: trimmed }); + }, [query, lastSubmit, setSearchParams]); + + const handleManualTranslate = async () => { + if (!initialQuery) return; + setIsTranslating(true); + try { + const { translated } = await translateQuery(initialQuery); + if (translated !== initialQuery) { + setTranslatedText(translated); + toast.success(`تمت الترجمة: "${translated}"`); + } else { + toast.info("النص بالفعل باللغة الإنجليزية"); + } + } catch { + toast.error("فشل في ترجمة البحث"); + } + setIsTranslating(false); }; const mods = data?.hits?.map((hit: any) => ({ @@ -46,13 +103,14 @@ const SearchPage = () => {

البحث عن إضافات

-
+
setQuery(e.target.value)} placeholder="ابحث عن مود، حزمة موارد، خريطة..." className="bg-secondary pr-10" + maxLength={200} />
@@ -61,13 +119,55 @@ const SearchPage = () => {
+ {/* Translation controls */} +
+
+ + +
+ {initialQuery && ( + + )} +
+ {initialQuery ? ( <>

نتائج البحث عن: "{initialQuery}" + {translatedText && ( + + → تمت الترجمة إلى: "{translatedText}" + + )} {data && ` (${data.total_hits || 0} نتيجة)`}

- + {isTranslating ? ( +
+ + جاري الترجمة والبحث... +
+ ) : ( + + )} ) : (