supersam/supabase/functions/_shared/security.ts

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,
});
}
}