216 lines
7.4 KiB
TypeScript
216 lines
7.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.includes(origin);
|
|
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;
|
|
|
|
// Use atomic SQL function to avoid race conditions
|
|
const { data, error } = await supabase.rpc('check_rate_limit', {
|
|
p_scope: scope,
|
|
p_key: key,
|
|
p_max_count: maxCount,
|
|
p_window_seconds: windowSeconds,
|
|
p_block_seconds: blockSeconds,
|
|
});
|
|
|
|
if (error) {
|
|
console.error('Rate limit check error:', error);
|
|
return;
|
|
}
|
|
|
|
if (data && !data.allowed) {
|
|
throw new RateLimitError('Too many requests. Please try again later.', 429);
|
|
}
|
|
}
|
|
|
|
// ── UUID validation ──────────────────────────────────────────────────────
|
|
|
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
export function isValidUuid(value: unknown): value is string {
|
|
return typeof value === 'string' && UUID_REGEX.test(value.trim());
|
|
}
|
|
|
|
export function requireUuid(value: unknown, fieldName = 'id'): string {
|
|
if (!isValidUuid(value)) {
|
|
throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 });
|
|
}
|
|
return (value as string).trim();
|
|
}
|
|
|
|
// ── PII masking ──────────────────────────────────────────────────────────
|
|
|
|
export function maskCustomerName(name: string | null | undefined): string | null {
|
|
if (!name || typeof name !== 'string') return null;
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return null;
|
|
const firstChar = trimmed[0];
|
|
return `${firstChar}.`;
|
|
}
|
|
|
|
export function maskPhoneNumber(phone: string | null | undefined): string | null {
|
|
if (!phone || typeof phone !== 'string') return null;
|
|
const trimmed = phone.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.length < 4) return trimmed;
|
|
return `+7 *** ***-${trimmed.slice(-2)}`;
|
|
}
|
|
|
|
// ── Same-origin / CSRF check ─────────────────────────────────────────────
|
|
|
|
export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean {
|
|
const origin = request.headers.get('origin') || '';
|
|
if (!origin) return false;
|
|
return allowedOrigins.includes(origin);
|
|
}
|
|
|
|
// ── Internal request verification (HMAC or shared secret) ────────────────
|
|
|
|
interface VerifyInternalOptions {
|
|
rawBody?: Uint8Array | string;
|
|
secretEnvNames?: string[];
|
|
tokenEnvNames?: string[];
|
|
allowedClockSkewSeconds?: number;
|
|
}
|
|
|
|
export async function verifyInternalRequest(
|
|
request: Request,
|
|
_rawBody: Uint8Array | string | undefined,
|
|
options: VerifyInternalOptions = {},
|
|
): Promise<{ authenticated: boolean; authenticatedBy: string }> {
|
|
const {
|
|
secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'],
|
|
tokenEnvNames = ['INTEGRATION_API_KEY'],
|
|
} = options;
|
|
|
|
// Check for shared secret via header
|
|
const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || '';
|
|
for (const envName of secretEnvNames) {
|
|
const secret = Deno.env.get(envName);
|
|
if (secret && authHeader && authHeader === secret) {
|
|
return { authenticated: true, authenticatedBy: envName };
|
|
}
|
|
}
|
|
|
|
// Check for API token via header
|
|
const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || '';
|
|
for (const envName of tokenEnvNames) {
|
|
const token = Deno.env.get(envName);
|
|
if (token && tokenHeader && tokenHeader === token) {
|
|
return { authenticated: true, authenticatedBy: envName };
|
|
}
|
|
}
|
|
|
|
// No valid credentials found
|
|
throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 });
|
|
} |