172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
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<string, string>) {
|
|
const headers: Record<string, string> = { '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<string> {
|
|
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<T> {
|
|
body: T;
|
|
}
|
|
|
|
export async function readJsonBody<T>(request: Request, options?: { maxBytes?: number }): Promise<JsonBodyResult<T>> {
|
|
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<typeof createClient>, 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,
|
|
});
|
|
}
|
|
} |