import { createClient } from 'npm:@supabase/supabase-js@2'; const ALLOWED_ORIGINS = [ 'https://supa.supersamsev.ru', 'https://dost.supersamsev.ru', 'http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000', 'https://supasevdev.mkn8n.ru', ]; export function createServiceClient() { const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''; return createClient(supabaseUrl, serviceRoleKey); } export function getClientIp(request: Request): string { const xff = request.headers.get('x-forwarded-for'); if (xff) return xff.split(',')[0].trim(); return request.headers.get('x-real-ip') || 'unknown'; } export function getCorsHeaders(request: Request, _access: 'public' | 'private') { const origin = request.headers.get('origin') || ''; if (!origin) { return { 'Access-Control-Allow-Origin': ALLOWED_ORIGINS[0], 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', 'Access-Control-Max-Age': '86400', }; } const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o)); if (!allowed) return null; return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization,apikey,x-application-name,x-client-info', 'Access-Control-Max-Age': '86400', }; } export function preflightResponse(request: Request, access: 'public' | 'private') { const corsHeaders = getCorsHeaders(request, access); if (!corsHeaders) { return new Response('Origin not allowed', { status: 403 }); } return new Response(null, { status: 204, headers: corsHeaders }); } export function jsonResponse(body: unknown, status = 200, corsHeaders?: Record) { const headers: Record = { 'Content-Type': 'application/json' }; if (corsHeaders) Object.assign(headers, corsHeaders); return new Response(JSON.stringify(body), { status, headers }); } export async function hashText(text: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(text); const hashBuffer = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } interface JsonBodyResult { body: T; } export async function readJsonBody(request: Request, options?: { maxBytes?: number }): Promise> { const maxBytes = options?.maxBytes ?? 1024 * 1024; const reader = request.body?.getReader(); if (!reader) throw new Error('No body'); const chunks: Uint8Array[] = []; let totalBytes = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.length; if (totalBytes > maxBytes) { reader.cancel(); throw Object.assign(new Error('Request body too large'), { status: 413 }); } chunks.push(value); } const combined = new Uint8Array(totalBytes); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } const text = new TextDecoder().decode(combined); const body = JSON.parse(text) as T; return { body }; } interface RateLimitOptions { scope: string; key: string; maxCount: number; windowSeconds: number; blockSeconds: number; } class RateLimitError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { const { scope, key, maxCount, windowSeconds, blockSeconds } = options; const tableName = 'rate_limits'; const now = new Date(); const { data: blocked } = await supabase .from(tableName) .select('blocked_until') .eq('scope', scope) .eq('rate_key', key) .gt('blocked_until', now.toISOString()) .limit(1); if (blocked && blocked.length > 0) { throw new RateLimitError('Too many requests. Please try again later.', 429); } const windowStart = new Date(now.getTime() - windowSeconds * 1000); const { data: recent, error } = await supabase .from(tableName) .select('id, count') .eq('scope', scope) .eq('rate_key', key) .gte('window_start', windowStart.toISOString()); if (error) { console.error('Rate limit check error:', error); return; } const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0; if (totalCount >= maxCount) { const blockedUntil = new Date(now.getTime() + blockSeconds * 1000); await supabase .from(tableName) .update({ blocked_until: blockedUntil.toISOString() }) .eq('scope', scope) .eq('rate_key', key) .gte('window_start', windowStart.toISOString()); throw new RateLimitError('Too many requests. Please try again later.', 429); } const existingRow = recent?.[0]; if (existingRow) { await supabase .from(tableName) .update({ count: (existingRow as { count: number }).count + 1 }) .eq('id', (existingRow as { id: string }).id); } else { await supabase.from(tableName).insert({ scope, rate_key: key, window_start: now.toISOString(), count: 1, blocked_until: null, }); } }