Added search translation edge fn
Created Supabase Edge Function `translate-search` to translate Arabic/Minecraft search queries to English via Lovable AI, with CORS support, input validation, rate-limit handling (429), and safe error responses. X-Lovable-Edit-ID: edt-39929b81-8f6f-4c33-b987-30e3da8b0155 Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com>
This commit is contained in:
commit
045ab7ef80
@ -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<string, string>) {
|
||||
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)];
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
@ -19,8 +39,13 @@ const Index = () => {
|
||||
<div className="container mx-auto px-4">
|
||||
<ModGrid
|
||||
title="إضافاتنا"
|
||||
mods={mods || []}
|
||||
isLoading={isLoading}
|
||||
mods={userMods || []}
|
||||
isLoading={loadingUser}
|
||||
/>
|
||||
<ModGrid
|
||||
title="اكتشف إضافات جديدة 🔥"
|
||||
mods={discoverMods}
|
||||
isLoading={loadingDiscover}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -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<string | null>(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 = () => {
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="mb-6 text-3xl font-black">البحث عن إضافات</h1>
|
||||
|
||||
<form onSubmit={handleSearch} className="mb-8 flex gap-3">
|
||||
<form onSubmit={handleSearch} className="mb-4 flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
|
||||
className="bg-secondary pr-10"
|
||||
maxLength={200}
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
@ -61,13 +119,55 @@ const SearchPage = () => {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Translation controls */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="auto-translate"
|
||||
checked={autoTranslate}
|
||||
onCheckedChange={setAutoTranslate}
|
||||
/>
|
||||
<Label htmlFor="auto-translate" className="text-sm text-muted-foreground">
|
||||
ترجمة تلقائية عند عدم وجود نتائج
|
||||
</Label>
|
||||
</div>
|
||||
{initialQuery && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualTranslate}
|
||||
disabled={isTranslating}
|
||||
className="gap-1"
|
||||
>
|
||||
{isTranslating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Languages className="h-3 w-3" />
|
||||
)}
|
||||
ترجم للإنجليزية
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{initialQuery ? (
|
||||
<>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
نتائج البحث عن: <span className="font-bold text-foreground">"{initialQuery}"</span>
|
||||
{translatedText && (
|
||||
<span className="mr-2 text-primary">
|
||||
→ تمت الترجمة إلى: "{translatedText}"
|
||||
</span>
|
||||
)}
|
||||
{data && ` (${data.total_hits || 0} نتيجة)`}
|
||||
</p>
|
||||
<ModGrid mods={mods} isLoading={isLoading} />
|
||||
{isTranslating ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
جاري الترجمة والبحث...
|
||||
</div>
|
||||
) : (
|
||||
<ModGrid mods={mods} isLoading={isLoading} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="py-20 text-center text-muted-foreground">
|
||||
|
||||
@ -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<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 = url.searchParams.get("action");
|
||||
const action = sanitizeString(url.searchParams.get("action"), 20);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"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" },
|
||||
});
|
||||
|
||||
77
supabase/functions/translate-search/index.ts
Normal file
77
supabase/functions/translate-search/index.ts
Normal file
@ -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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user