Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-07-11 14:00:26 +00:00
parent 174cd8a266
commit 60e7e43e94
4 changed files with 154 additions and 8 deletions
+74
View File
@@ -0,0 +1,74 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
/**
* Verifies the caller is an authenticated admin.
* Returns { ok: true } on success or { ok: false, response } with a
* ready-to-return Response on failure.
*/
export async function requireAdmin(
req: Request,
corsHeaders: Record<string, string>,
): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabaseUrl = Deno.env.get("SUPABASE_URL");
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!supabaseUrl || !anonKey || !serviceKey) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Server misconfigured" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const supabase = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const token = authHeader.replace("Bearer ", "");
const { data, error } = await supabase.auth.getClaims(token);
if (error || !data?.claims?.sub) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
const userId = data.claims.sub as string;
// Verify admin role via service-role client (bypasses RLS deterministically)
const admin = createClient(supabaseUrl, serviceKey);
const { data: role, error: roleErr } = await admin
.from("user_roles")
.select("role")
.eq("user_id", userId)
.eq("role", "admin")
.maybeSingle();
if (roleErr || !role) {
return {
ok: false,
response: new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
};
}
return { ok: true, userId };
}
@@ -1,4 +1,5 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -16,7 +17,11 @@ const STORSTOCKHOLM = [
serve(async (req) => { serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try { try {
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured"); if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
@@ -1,4 +1,5 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { requireAdmin } from "../_shared/admin-auth.ts";
const corsHeaders = { const corsHeaders = {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
@@ -22,7 +23,11 @@ interface CompanyResearchRequest {
serve(async (req) => { serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const auth = await requireAdmin(req, corsHeaders);
if (!auth.ok) return auth.response;
try { try {
const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY"); const FIRECRAWL_API_KEY = Deno.env.get("FIRECRAWL_API_KEY");
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY"); const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured"); if (!FIRECRAWL_API_KEY) throw new Error("FIRECRAWL_API_KEY is not configured");
+70 -8
View File
@@ -13,11 +13,62 @@ interface ContactRequest {
message: string; message: string;
} }
// HTML-escape any user-supplied string before embedding in email HTML
const esc = (s: string): string =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
// In-memory rate limit per client IP (best-effort; resets on cold start).
// Key: ip, Value: array of request timestamps (ms).
const rateBuckets = new Map<string, number[]>();
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX = 5; // max 5 messages/hour per IP
function getClientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || "unknown";
}
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = (rateBuckets.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS);
if (bucket.length >= RATE_MAX) {
rateBuckets.set(ip, bucket);
return false;
}
bucket.push(now);
rateBuckets.set(ip, bucket);
return true;
}
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); return new Response(null, { headers: corsHeaders });
} }
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const ip = getClientIp(req);
if (!checkRateLimit(ip)) {
return new Response(
JSON.stringify({ error: "För många förfrågningar. Försök igen senare." }),
{
status: 429,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
try { try {
const { name, email, company, message }: ContactRequest = await req.json(); const { name, email, company, message }: ContactRequest = await req.json();
@@ -33,7 +84,12 @@ const handler = async (req: Request): Promise<Response> => {
} }
// Validate field lengths // Validate field lengths
if (name.length > 100 || email.length > 255 || (company && company.length > 100) || message.length > 2000) { if (
name.length > 100 ||
email.length > 255 ||
(company && company.length > 100) ||
message.length > 2000
) {
throw new Error("Field length exceeded"); throw new Error("Field length exceeded");
} }
@@ -44,6 +100,12 @@ const handler = async (req: Request): Promise<Response> => {
throw new Error("RESEND_API_KEY not configured"); throw new Error("RESEND_API_KEY not configured");
} }
// Escape all user-supplied values before embedding in HTML
const safeName = esc(name);
const safeEmail = esc(email);
const safeCompany = company ? esc(company) : "";
const safeMessage = esc(message).replace(/\n/g, "<br />");
const res = await fetch("https://api.resend.com/emails", { const res = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
@@ -54,15 +116,15 @@ const handler = async (req: Request): Promise<Response> => {
from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>", from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>",
to: [recipientEmail], to: [recipientEmail],
reply_to: email, reply_to: email,
subject: `Kontaktförfrågan från ${name}`, subject: `Kontaktförfrågan från ${safeName}`,
html: ` html: `
<h2>Ny kontaktförfrågan</h2> <h2>Ny kontaktförfrågan</h2>
<p><strong>Namn:</strong> ${name}</p> <p><strong>Namn:</strong> ${safeName}</p>
<p><strong>E-post:</strong> ${email}</p> <p><strong>E-post:</strong> ${safeEmail}</p>
${company ? `<p><strong>Företag:</strong> ${company}</p>` : ""} ${safeCompany ? `<p><strong>Företag:</strong> ${safeCompany}</p>` : ""}
<hr /> <hr />
<p><strong>Meddelande:</strong></p> <p><strong>Meddelande:</strong></p>
<p>${message.replace(/\n/g, "<br />")}</p> <p>${safeMessage}</p>
`, `,
}), }),
}); });
@@ -83,11 +145,11 @@ const handler = async (req: Request): Promise<Response> => {
} catch (error: any) { } catch (error: any) {
console.error("Error in send-contact-email function:", error); console.error("Error in send-contact-email function:", error);
return new Response( return new Response(
JSON.stringify({ error: error.message }), JSON.stringify({ error: "Kunde inte skicka meddelandet." }),
{ {
status: 500, status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders }, headers: { "Content-Type": "application/json", ...corsHeaders },
} },
); );
} }
}; };