283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
// verify-face/index.ts
|
|
// Follow this setup guide to integrate the Deno language server with your editor:
|
|
// https://deno.land/manual/getting_started/setup_your_environment
|
|
// This enables autocomplete, go to definition, etc.
|
|
|
|
// Setup type definitions for built-in Supabase Runtime APIs
|
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
|
|
import { RekognitionClient, CompareFacesCommand } from "npm:@aws-sdk/client-rekognition@^3.0.0";
|
|
import {
|
|
validateAWSCredentials,
|
|
validateFile,
|
|
createErrorResponse,
|
|
createSuccessResponse
|
|
} from "../shared/aws-utils.ts";
|
|
|
|
interface VerifyFaceResponse {
|
|
success: true;
|
|
matched: boolean;
|
|
similarity: number;
|
|
similarityThreshold: number;
|
|
faceMatches: any[];
|
|
unmatchedFaces: any[];
|
|
idCardImageSize?: number;
|
|
selfieImageSize?: number;
|
|
processingTime?: number;
|
|
confidence?: 'HIGH' | 'MEDIUM' | 'LOW';
|
|
}
|
|
|
|
// Configuration constants
|
|
const DEFAULT_SIMILARITY_THRESHOLD = 70;
|
|
const HIGH_CONFIDENCE_THRESHOLD = 85;
|
|
const MEDIUM_CONFIDENCE_THRESHOLD = 75;
|
|
const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15MB
|
|
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'];
|
|
|
|
// Logger utility for structured logs
|
|
const logger = {
|
|
info: (message: string, data?: any) => {
|
|
console.log(`[INFO] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : '');
|
|
},
|
|
warn: (message: string, data?: any) => {
|
|
console.warn(`[WARN] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : '');
|
|
},
|
|
error: (message: string, error?: any) => {
|
|
console.error(`[ERROR] [${new Date().toISOString()}] [verify-face] ${message}`, error ? error : '');
|
|
},
|
|
debug: (message: string, data?: any) => {
|
|
console.debug(`[DEBUG] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : '');
|
|
},
|
|
success: (message: string, data?: any) => {
|
|
console.log(`[SUCCESS] [${new Date().toISOString()}] [verify-face] ${message}`, data ? data : '');
|
|
}
|
|
};
|
|
|
|
async function fileToUint8Array(file: File): Promise<Uint8Array> {
|
|
const buffer = await file.arrayBuffer();
|
|
return new Uint8Array(buffer);
|
|
}
|
|
|
|
serve(async (req: Request): Promise<Response> => {
|
|
const startTime = Date.now();
|
|
const requestId = crypto.randomUUID();
|
|
|
|
logger.info(`Starting face verification request [ID: ${requestId}]`);
|
|
|
|
try {
|
|
// Validate HTTP method
|
|
if (req.method !== 'POST') {
|
|
logger.warn(`Invalid HTTP method ${req.method} [ID: ${requestId}]`);
|
|
return createErrorResponse('Method not allowed. Use POST', 405);
|
|
}
|
|
|
|
// Validate AWS credentials
|
|
logger.debug(`Validating AWS credentials [ID: ${requestId}]`);
|
|
const { credentials, error: credError } = validateAWSCredentials();
|
|
if (credError || !credentials) {
|
|
logger.error(`AWS credentials validation failed [ID: ${requestId}]`, credError);
|
|
return createErrorResponse(credError || 'AWS credentials not configured');
|
|
}
|
|
|
|
logger.debug(`AWS Region: ${credentials.region} [ID: ${requestId}]`);
|
|
|
|
// Initialize Rekognition client
|
|
logger.debug(`Initializing Rekognition client [ID: ${requestId}]`);
|
|
const rekognitionClient = new RekognitionClient({
|
|
region: credentials.region,
|
|
credentials: {
|
|
accessKeyId: credentials.accessKey,
|
|
secretAccessKey: credentials.secretKey
|
|
}
|
|
});
|
|
|
|
// Parse multipart form data
|
|
logger.debug(`Parsing form data [ID: ${requestId}]`);
|
|
let formData: FormData;
|
|
try {
|
|
formData = await req.formData();
|
|
} catch (error) {
|
|
logger.error(`Failed to parse form data [ID: ${requestId}]`, error);
|
|
return createErrorResponse('Invalid form data. Expected multipart/form-data', 400);
|
|
}
|
|
|
|
// Get and validate both images
|
|
const idCardImage = formData.get('idCard') as File | null;
|
|
const selfieImage = formData.get('selfie') as File | null;
|
|
|
|
// Validate IDCARD image
|
|
logger.debug(`Validating IDCARD image [ID: ${requestId}]`);
|
|
const idCardValidation = validateFile(idCardImage, 'IDCARD');
|
|
if (!idCardValidation.isValid) {
|
|
logger.warn(`IDCARD image validation failed [ID: ${requestId}]`, idCardValidation.error);
|
|
return createErrorResponse(idCardValidation.error || 'Invalid IDCARD image', 400);
|
|
}
|
|
|
|
// Validate selfie image
|
|
logger.debug(`Validating selfie image [ID: ${requestId}]`);
|
|
const selfieValidation = validateFile(selfieImage, 'selfie');
|
|
if (!selfieValidation.isValid) {
|
|
logger.warn(`Selfie image validation failed [ID: ${requestId}]`, selfieValidation.error);
|
|
return createErrorResponse(selfieValidation.error || 'Invalid selfie image', 400);
|
|
}
|
|
|
|
// Log image details
|
|
logger.info(`Processing images [ID: ${requestId}]`, {
|
|
idCard: {
|
|
name: idCardImage!.name,
|
|
size: `${(idCardImage!.size / 1024).toFixed(2)} KB`,
|
|
type: idCardImage!.type
|
|
},
|
|
selfie: {
|
|
name: selfieImage!.name,
|
|
size: `${(selfieImage!.size / 1024).toFixed(2)} KB`,
|
|
type: selfieImage!.type
|
|
}
|
|
});
|
|
|
|
// Get similarity threshold from form data or use default
|
|
const thresholdParam = formData.get('similarity_threshold');
|
|
let similarityThreshold = DEFAULT_SIMILARITY_THRESHOLD;
|
|
|
|
if (thresholdParam) {
|
|
const parsedThreshold = parseFloat(thresholdParam.toString());
|
|
if (!isNaN(parsedThreshold) && parsedThreshold >= 0 && parsedThreshold <= 100) {
|
|
similarityThreshold = parsedThreshold;
|
|
logger.debug(`Using custom similarity threshold: ${similarityThreshold}% [ID: ${requestId}]`);
|
|
} else {
|
|
logger.warn(`Invalid similarity threshold provided, using default: ${DEFAULT_SIMILARITY_THRESHOLD}% [ID: ${requestId}]`);
|
|
}
|
|
}
|
|
|
|
// Convert images to Uint8Array
|
|
logger.debug(`Converting images to binary format [ID: ${requestId}]`);
|
|
let idCardBytes: Uint8Array, selfieBytes: Uint8Array;
|
|
try {
|
|
[idCardBytes, selfieBytes] = await Promise.all([
|
|
fileToUint8Array(idCardImage!),
|
|
fileToUint8Array(selfieImage!)
|
|
]);
|
|
} catch (error) {
|
|
logger.error(`Failed to convert images to bytes [ID: ${requestId}]`, error);
|
|
return createErrorResponse('Failed to process image data', 500);
|
|
}
|
|
|
|
// Create CompareFaces command
|
|
logger.info(`Sending request to AWS Rekognition CompareFaces with threshold: ${similarityThreshold}% [ID: ${requestId}]`);
|
|
const compareFacesCommand = new CompareFacesCommand({
|
|
SourceImage: {
|
|
Bytes: idCardBytes
|
|
},
|
|
TargetImage: {
|
|
Bytes: selfieBytes
|
|
},
|
|
SimilarityThreshold: similarityThreshold
|
|
});
|
|
|
|
// Execute AWS Rekognition request
|
|
let rekognitionResponse: any;
|
|
try {
|
|
rekognitionResponse = await rekognitionClient.send(compareFacesCommand);
|
|
} catch (error) {
|
|
logger.error(`AWS Rekognition request failed [ID: ${requestId}]`, error);
|
|
|
|
// Handle specific AWS errors
|
|
if (error.name === 'InvalidImageFormatException') {
|
|
return createErrorResponse('Invalid image format. Please use JPEG or PNG format', 400);
|
|
} else if (error.name === 'ImageTooLargeException') {
|
|
return createErrorResponse('Image too large. Please reduce image size', 400);
|
|
} else if (error.name === 'InvalidParameterException') {
|
|
return createErrorResponse('Invalid parameters provided', 400);
|
|
}
|
|
|
|
return createErrorResponse('Failed to compare faces with AWS Rekognition', 500);
|
|
}
|
|
|
|
// Process results
|
|
const processingTime = Date.now() - startTime;
|
|
const matched = !!(rekognitionResponse.FaceMatches && rekognitionResponse.FaceMatches.length > 0);
|
|
|
|
let highestSimilarity = 0;
|
|
if (matched && rekognitionResponse.FaceMatches && rekognitionResponse.FaceMatches.length > 0) {
|
|
highestSimilarity = Math.max(...rekognitionResponse.FaceMatches.map((match: any) => match.Similarity || 0));
|
|
}
|
|
|
|
// Determine confidence level
|
|
let confidence: 'HIGH' | 'MEDIUM' | 'LOW';
|
|
if (highestSimilarity >= HIGH_CONFIDENCE_THRESHOLD) {
|
|
confidence = 'HIGH';
|
|
} else if (highestSimilarity >= MEDIUM_CONFIDENCE_THRESHOLD) {
|
|
confidence = 'MEDIUM';
|
|
} else {
|
|
confidence = 'LOW';
|
|
}
|
|
|
|
// Log results in a structured format
|
|
logger.success(`Face verification completed [ID: ${requestId}]`, {
|
|
matched: matched,
|
|
similarity: `${highestSimilarity.toFixed(2)}%`,
|
|
confidence: confidence,
|
|
processingTime: `${processingTime}ms`,
|
|
faceMatchesCount: rekognitionResponse.FaceMatches?.length || 0,
|
|
unmatchedFacesCount: rekognitionResponse.UnmatchedFaces?.length || 0
|
|
});
|
|
|
|
// Prepare response
|
|
const response: VerifyFaceResponse = {
|
|
success: true,
|
|
matched: matched,
|
|
similarity: parseFloat(highestSimilarity.toFixed(2)),
|
|
similarityThreshold: similarityThreshold,
|
|
faceMatches: rekognitionResponse.FaceMatches || [],
|
|
unmatchedFaces: rekognitionResponse.UnmatchedFaces || [],
|
|
idCardImageSize: idCardImage!.size,
|
|
selfieImageSize: selfieImage!.size,
|
|
processingTime,
|
|
confidence
|
|
};
|
|
|
|
return createSuccessResponse(response);
|
|
|
|
} catch (error) {
|
|
const processingTime = Date.now() - startTime;
|
|
logger.error(`Unexpected error in verify-face function [ID: ${requestId}]`, error);
|
|
logger.error(`Processing time before error: ${processingTime}ms [ID: ${requestId}]`);
|
|
|
|
return createErrorResponse(
|
|
"An unexpected error occurred while verifying faces",
|
|
500
|
|
);
|
|
}
|
|
});
|
|
|
|
/*
|
|
To invoke locally:
|
|
|
|
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
|
|
2. Make an HTTP request:
|
|
|
|
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/verify-face' \
|
|
--header 'Authorization: Bearer [YOUR_ANON_KEY]' \
|
|
--form 'idCard=@"path/to/idCard.jpg"' \
|
|
--form 'selfie=@"path/to/selfie.jpg"' \
|
|
--form 'similarity_threshold=75'
|
|
|
|
Example response:
|
|
{
|
|
"success": true,
|
|
"matched": true,
|
|
"similarity": 87.45,
|
|
"similarityThreshold": 75,
|
|
"faceMatches": [...],
|
|
"unmatchedFaces": [],
|
|
"idCardImageSize": 1048576,
|
|
"selfieImageSize": 2097152,
|
|
"processingTime": 1450,
|
|
"confidence": "HIGH"
|
|
}
|
|
|
|
Environment Variables Required:
|
|
- AWS_ACCESS_KEY_ID
|
|
- AWS_SECRET_ACCESS_KEY
|
|
- AWS_REGION (optional, defaults to us-east-1)
|
|
*/ |