diff --git a/src/__tests__/api/video-generate-route.test.ts b/src/__tests__/api/video-generate-route.test.ts new file mode 100644 index 00000000..1035aeae --- /dev/null +++ b/src/__tests__/api/video-generate-route.test.ts @@ -0,0 +1,74 @@ +import { POST } from "@/app/api/video/generate/route"; + +jest.mock("@clerk/nextjs/server", () => ({ + currentUser: jest.fn().mockResolvedValue({ + id: "user_123", + primaryEmailAddress: { emailAddress: "student@example.com" }, + }), +})); + +jest.mock("@/lib/ratelimit/api-rate-limit", () => ({ + enforceApiRateLimit: jest.fn().mockResolvedValue(null), +})); + +jest.mock("@/lib/auth/auth-utils", () => ({ + checkUserBlock: jest.fn().mockResolvedValue({ + isBlocked: false, + errorResponse: undefined, + dbUser: undefined, + }), +})); + +jest.mock("@/configs/db", () => ({ + db: { + insert: jest.fn(), + }, +})); + +jest.mock("@/configs/schema", () => ({ + videoJobsTable: {}, +})); + +jest.mock("@/inngest/client", () => ({ + inngest: { + send: jest.fn(), + }, +})); + +jest.mock("@/lib/ratelimit/ratelimit", () => ({ + videoLimiter: { + limit: jest.fn(), + }, + redisClient: { + set: jest.fn(), + del: jest.fn(), + }, +})); + +describe("video generate route", () => { + it("rejects malformed image content before queueing OCR work", async () => { + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("not an image", { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }), + ); + + const res = await POST( + new Request("http://localhost/api/video/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + imageUrl: "https://example.com/malformed.jpg", + }), + }), + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(res.status).toBe(422); + await expect(res.json()).resolves.toEqual({ + error: "Please upload a valid PNG, JPG, or WEBP image.", + code: "INVALID_IMAGE_PAYLOAD", + }); + }); +}); diff --git a/src/__tests__/lib/video-image-validation.test.ts b/src/__tests__/lib/video-image-validation.test.ts new file mode 100644 index 00000000..e4ab2691 --- /dev/null +++ b/src/__tests__/lib/video-image-validation.test.ts @@ -0,0 +1,45 @@ +import { + detectVideoImageMimeType, + validateVideoImageUrl, +} from "@/lib/video/image-validation"; + +describe("video image validation", () => { + it("detects PNG, JPEG, and WEBP magic bytes", () => { + expect( + detectVideoImageMimeType( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ), + ).toBe("image/png"); + expect(detectVideoImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0x00]))).toBe( + "image/jpeg", + ); + expect( + detectVideoImageMimeType( + new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ]), + ), + ).toBe("image/webp"); + }); + + it("rejects non-image payloads before OCR", async () => { + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("not an image", { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }), + ); + + const result = await validateVideoImageUrl("https://example.com/malformed.jpg"); + + expect(fetchSpy).toHaveBeenCalledWith("https://example.com/malformed.jpg", { + cache: "no-store", + }); + expect(result).toEqual({ + ok: false, + status: 422, + code: "INVALID_IMAGE_PAYLOAD", + error: "Please upload a valid PNG, JPG, or WEBP image.", + }); + }); +}); diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts index dd23394a..05acd97e 100644 --- a/src/app/api/video/generate/route.ts +++ b/src/app/api/video/generate/route.ts @@ -6,6 +6,7 @@ import { redisClient, videoLimiter } from "@/lib/ratelimit/ratelimit"; import { enforceApiRateLimit } from "@/lib/ratelimit/api-rate-limit"; import { parseAndValidateRequest } from "@/lib/validations/validate"; import { generateVideoSchema } from "@/lib/validations/video"; +import { validateVideoImageUrl } from "@/lib/video/image-validation"; import { db } from "@/configs/db"; import { videoJobsTable } from "@/configs/schema"; import { inngest } from "@/inngest/client"; @@ -42,6 +43,19 @@ export async function POST(req: Request) { const { errorResponse, data } = await parseAndValidateRequest(req, generateVideoSchema); if (errorResponse) return errorResponse; + if (data.imageUrl) { + const imageValidation = await validateVideoImageUrl(data.imageUrl); + if (!imageValidation.ok) { + return NextResponse.json( + { + error: imageValidation.error, + code: imageValidation.code, + }, + { status: imageValidation.status }, + ); + } + } + // One active generation per user. The background job releases this lock when it // finishes (success or failure); a 5-minute TTL guards against leaked locks. const lockKey = `video_lock:${user.id}`; diff --git a/src/lib/video/image-validation.ts b/src/lib/video/image-validation.ts new file mode 100644 index 00000000..07c91ebb --- /dev/null +++ b/src/lib/video/image-validation.ts @@ -0,0 +1,82 @@ +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]; +const WEBP_SIGNATURE_PREFIX = [0x52, 0x49, 0x46, 0x46]; +const WEBP_SIGNATURE_SUFFIX = [0x57, 0x45, 0x42, 0x50]; + +export const VIDEO_IMAGE_ALLOWED_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/webp', +] as const; + +export const VIDEO_IMAGE_ALLOWED_TYPES_LABEL = 'PNG, JPG, or WEBP'; + +export type VideoImageValidationResult = + | { ok: true; mimeType: string } + | { + ok: false; + status: 422 | 500; + code: string; + error: string; + }; + +export function isAllowedVideoImageMimeType(mimeType: string) { + return (VIDEO_IMAGE_ALLOWED_MIME_TYPES as readonly string[]).includes( + mimeType.toLowerCase(), + ); +} + +export function detectVideoImageMimeType(bytes: Uint8Array): string | null { + if (bytes.length >= PNG_SIGNATURE.length && PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) { + return 'image/png'; + } + + if (bytes.length >= JPEG_SIGNATURE.length && JPEG_SIGNATURE.every((byte, index) => bytes[index] === byte)) { + return 'image/jpeg'; + } + + if ( + bytes.length >= 12 && + WEBP_SIGNATURE_PREFIX.every((byte, index) => bytes[index] === byte) && + WEBP_SIGNATURE_SUFFIX.every((byte, index) => bytes[index + 8] === byte) + ) { + return 'image/webp'; + } + + return null; +} + +export async function validateVideoImageUrl(imageUrl: string): Promise { + try { + const response = await fetch(imageUrl, { cache: 'no-store' }); + if (!response.ok) { + return { + ok: false, + status: 422, + code: 'INVALID_IMAGE_PAYLOAD', + error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`, + }; + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const mimeType = detectVideoImageMimeType(bytes); + + if (!mimeType || !isAllowedVideoImageMimeType(mimeType)) { + return { + ok: false, + status: 422, + code: 'INVALID_IMAGE_PAYLOAD', + error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`, + }; + } + + return { ok: true, mimeType }; + } catch { + return { + ok: false, + status: 422, + code: 'INVALID_IMAGE_PAYLOAD', + error: `Please upload a valid ${VIDEO_IMAGE_ALLOWED_TYPES_LABEL} image.`, + }; + } +}