diff --git a/.env.example b/.env.example index 349a45d..15896d5 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ FIREBASE_DATABASE_URL= GCS_BUCKET_NAME= GROQ_KEY= DATABASE_URL= -JWT_SECRET= \ No newline at end of file +JWT_SECRET= +FACE_VALIDATION_URL= \ No newline at end of file diff --git a/package.json b/package.json index 7769ac6..c11aa17 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@google-cloud/storage": "^7.14.0", + "axios": "^1.7.8", "bcryptjs": "^2.4.3", "compression": "^1.7.5", "cors": "^2.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77caa89..aac5c93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@google-cloud/storage': specifier: ^7.14.0 version: 7.14.0(encoding@0.1.13) + axios: + specifier: ^1.7.8 + version: 1.7.8 bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -1359,6 +1362,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios@1.7.8: + resolution: {integrity: sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -2139,6 +2145,15 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -5019,6 +5034,14 @@ snapshots: asynckit@0.4.0: {} + axios@1.7.8: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.6.7: {} balanced-match@1.0.2: {} @@ -5946,6 +5969,8 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.9: {} + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 diff --git a/src/config.ts b/src/config.ts index fa21380..6d1b7b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,3 +60,6 @@ export const bucket = process.env.NODE_ENV === "test" ? admin.storage().bucket(GCS_BUCKET_NAME) // use the firebase storage emulator : new Storage().bucket(GCS_BUCKET_NAME); + +export const FACE_VALIDATION_URL = process.env.FACE_VALIDATION_URL; +export const FACE_VALIDATION_ENABLED = !!FACE_VALIDATION_URL; diff --git a/src/container.ts b/src/container.ts index f8479f6..f8080e2 100644 --- a/src/container.ts +++ b/src/container.ts @@ -8,6 +8,8 @@ import { FCMRepository } from "./common/fcm.repository"; import { TutoriesRepository } from "./module/tutories/tutories.repository"; import { OrderRepository } from "./module/order/order.repository"; import { ReviewRepository } from "./module/review/review.repository"; +import { FaceValidationService } from "./module/face-validation/face-validation.interface"; +import { createFaceValidationService } from "./module/face-validation/face-validation.factory"; interface Container { authRepository: AuthRepository; @@ -19,6 +21,7 @@ interface Container { chatRepository: ChatRepository; fcmRepository: FCMRepository; reviewRepository: ReviewRepository; + faceValidationService: FaceValidationService; } let containerInstance: Container | null = null; @@ -35,6 +38,7 @@ export const setupContainer = (): Container => { chatRepository: new ChatRepository(db), fcmRepository: new FCMRepository(db), reviewRepository: new ReviewRepository(db), + faceValidationService: createFaceValidationService(), }; } diff --git a/src/module/face-validation/face-validation.factory.ts b/src/module/face-validation/face-validation.factory.ts new file mode 100644 index 0000000..f0ac989 --- /dev/null +++ b/src/module/face-validation/face-validation.factory.ts @@ -0,0 +1,34 @@ +import { FACE_VALIDATION_ENABLED, FACE_VALIDATION_URL } from "@/config"; +import { + RemoteFaceValidationService, + NoOpFaceValidationService, +} from "./face-validation.service"; +import { logger } from "@middleware/logging.middleware"; +import axios from "axios"; + +export const createFaceValidationService = () => { + if (!FACE_VALIDATION_ENABLED) { + logger.info("Face validation is disabled, using NoOp service"); + return new NoOpFaceValidationService(); + } + + const service = new RemoteFaceValidationService(FACE_VALIDATION_URL!); + + try { + axios + .get(`${FACE_VALIDATION_URL}/health`, { + timeout: 5000, + }) + .catch(() => { + throw new Error("Health check failed"); + }); + + logger.info("Face validation service is healthy and ready"); + return service; + } catch (error) { + logger.warn( + `Face validation service is unavailable: ${error}, falling back to NoOp service`, + ); + return new NoOpFaceValidationService(); + } +}; diff --git a/src/module/face-validation/face-validation.interface.ts b/src/module/face-validation/face-validation.interface.ts new file mode 100644 index 0000000..e553469 --- /dev/null +++ b/src/module/face-validation/face-validation.interface.ts @@ -0,0 +1,10 @@ +export interface FaceValidationResponse { + is_valid: boolean; + face_count: number; + message: string; +} + +export interface FaceValidationService { + validateFace(imageBuffer: Buffer): Promise; + checkHealth(): Promise; +} diff --git a/src/module/face-validation/face-validation.service.ts b/src/module/face-validation/face-validation.service.ts new file mode 100644 index 0000000..b47ab71 --- /dev/null +++ b/src/module/face-validation/face-validation.service.ts @@ -0,0 +1,80 @@ +import axios from "axios"; +import { + FaceValidationService, + FaceValidationResponse, +} from "./face-validation.interface"; +import { logger } from "@middleware/logging.middleware"; + +export class RemoteFaceValidationService implements FaceValidationService { + constructor(private readonly baseUrl: string) {} + + async validateFace(imageBuffer: Buffer): Promise { + try { + const imageBase64 = imageBuffer.toString("base64"); + const response = await axios.post<{ + is_valid: boolean; + face_count: number; + message: string; + }>( + `${this.baseUrl}/validate-face`, + { image: imageBase64 }, + { + headers: { "Content-Type": "application/json" }, + timeout: 5000, + }, + ); + + return { + is_valid: response.data.is_valid, + face_count: response.data.face_count, + message: response.data.message, + }; + } catch (error) { + logger.error("Face validation service error:", error); + throw new Error("Face validation service unavailable"); + } + } + + checkHealthSync(): boolean { + try { + const xhr = new XMLHttpRequest(); + xhr.open("GET", `${this.baseUrl}/health`, false); // Make the request synchronous + xhr.timeout = 5000; + xhr.send(null); + + return xhr.status === 200; + } catch (error) { + logger.warn("Health check failed:", error); + return false; + } + } + + async checkHealth(): Promise { + try { + const response = await axios.get(`${this.baseUrl}/health`, { + timeout: 5000, + }); + return response.data.status === "healthy"; + } catch (error) { + return false; + } + } +} + +export class NoOpFaceValidationService implements FaceValidationService { + async validateFace(_imageBuffer: Buffer): Promise { + return { + is_valid: true, + face_count: 0, + message: "Face validation is not enabled", + }; + } + + checkHealthSync(): boolean { + return true; + } + + async checkHealth(): Promise { + return true; + } +} diff --git a/src/module/tutor/tutor.controller.ts b/src/module/tutor/tutor.controller.ts index 98c843e..6039821 100644 --- a/src/module/tutor/tutor.controller.ts +++ b/src/module/tutor/tutor.controller.ts @@ -8,11 +8,13 @@ import { TutorService } from "@/module/tutor/tutor.service"; import { RequestHandler } from "express"; import { z } from "zod"; import { container } from "@/container"; +import { ValidationError } from "./tutor.error"; const tutorService = new TutorService({ tutorRepository: container.tutorRepository, downscaleImage, bucket, + faceValidation: container.faceValidationService, }); type UpdateTutorProfileSchema = z.infer; @@ -50,8 +52,15 @@ export const updateProfilePicture: RequestHandler = async (req, res) => { data: { url }, }); } catch (error) { - logger.error(`Failed to upload profile picture: ${error}`); + if (error instanceof ValidationError) { + res.status(400).json({ + status: "fail", + message: error.message, + }); + return; + } + logger.error(`Failed to upload profile picture: ${error}`); res.status(500).json({ status: "error", message: "Failed to upload profile picture", diff --git a/src/module/tutor/tutor.error.ts b/src/module/tutor/tutor.error.ts new file mode 100644 index 0000000..edd9656 --- /dev/null +++ b/src/module/tutor/tutor.error.ts @@ -0,0 +1,10 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + + static isFaceValidationError(error: unknown): error is ValidationError { + return error instanceof ValidationError; + } +} diff --git a/src/module/tutor/tutor.service.ts b/src/module/tutor/tutor.service.ts index 3e118d8..48284e3 100644 --- a/src/module/tutor/tutor.service.ts +++ b/src/module/tutor/tutor.service.ts @@ -4,26 +4,32 @@ import { z } from "zod"; import { TutorRepository } from "./tutor.repository"; import { AuthRepository } from "../auth/auth.repository"; import { hash } from "bcryptjs"; +import { FaceValidationService } from "@/module/face-validation/face-validation.interface"; +import { ValidationError } from "./tutor.error"; export interface TutorServiceDependencies { tutorRepository: TutorRepository; bucket: Bucket; downscaleImage: (imageBuffer: Buffer) => Promise; + faceValidation: FaceValidationService; } export class TutorService { private tutorRepository: TutorRepository; private bucket: Bucket; private downscaleImage: (imageBuffer: Buffer) => Promise; + private faceValidation: FaceValidationService; constructor({ tutorRepository, bucket, downscaleImage, + faceValidation, }: TutorServiceDependencies) { this.tutorRepository = tutorRepository; this.bucket = bucket; this.downscaleImage = downscaleImage; + this.faceValidation = faceValidation; } async updateProfile( @@ -33,16 +39,29 @@ export class TutorService { await this.tutorRepository.updateTutorProfile(userId, data); } - async updateProfilePicture(file: Express.Multer.File, userId: string) { + async updateProfilePicture( + file: Express.Multer.File, + userId: string, + ): Promise { const name = `profile-pictures/${userId}.jpg`; try { const image = await this.downscaleImage(file.buffer); + + const valResult = await this.faceValidation.validateFace(file.buffer); + if (!valResult.is_valid) { + throw new ValidationError( + valResult.message || "Face validation failed", + ); + } const bucketFile = this.bucket.file(name); await bucketFile.save(image, { public: true }); return bucketFile.publicUrl(); } catch (error) { + if (error instanceof ValidationError) { + throw error; + } throw new Error(`Failed to update profile picture: ${error}`); } }