diff --git a/storage-resize-images/CHANGELOG.md b/storage-resize-images/CHANGELOG.md index 8827b6a32..29ee8a64f 100644 --- a/storage-resize-images/CHANGELOG.md +++ b/storage-resize-images/CHANGELOG.md @@ -1,3 +1,15 @@ +## Version 0.3.5 + +fix - when a custom filter prompt is configured, the content filter no longer silently fails open on Gemini 2.5 Flash safety refusals. If Gemini's input-side safety declines to respond on borderline imagery (returning empty content rather than `finishReason="SAFETY"`), the resulting genkit schema-validation error is now treated as an implicit block instead of being retried 3 times and propagating as a generic filter error. Installs that only set `CONTENT_FILTER_LEVEL` (no custom prompt) were not affected by this path. + +fix - any genkit schema-validation failure on the moderation response (not only empty-content safety refusals) is now treated as a content block instead of being retried and surfaced as a generic error. + +fix - blocked images are now routed to the failed-image path before placeholder substitution, so the original blocked content is preserved rather than overwritten by the placeholder. + +fix - placeholder swap operates on a copy of the original file, so resizing a blocked image produces placeholder-derived outputs without mutating the stored original. + +fix - moderation requests now use the uploaded object's content type when constructing the data URL instead of guessing from the file extension. + ## Version 0.3.4 chore: bump dependencies diff --git a/storage-resize-images/extension.yaml b/storage-resize-images/extension.yaml index 8d3fc8f83..977a3444b 100644 --- a/storage-resize-images/extension.yaml +++ b/storage-resize-images/extension.yaml @@ -13,7 +13,7 @@ # limitations under the License. name: storage-resize-images -version: 0.3.4 +version: 0.3.5 specVersion: v1beta displayName: Resize Images diff --git a/storage-resize-images/functions/__tests__/content-filter.test.ts b/storage-resize-images/functions/__tests__/content-filter.test.ts index 1a92ee86b..9a0d08779 100644 --- a/storage-resize-images/functions/__tests__/content-filter.test.ts +++ b/storage-resize-images/functions/__tests__/content-filter.test.ts @@ -1,6 +1,7 @@ -import { checkImageContent } from "../src/content-filter"; // Update this import path +import { checkImageContent } from "../src/content-filter"; import * as path from "path"; import { HarmBlockThreshold } from "@google-cloud/vertexai"; +import { ValidationError } from "@genkit-ai/core/schema"; // Mock genkit module jest.mock("genkit", () => ({ @@ -18,14 +19,8 @@ jest.mock("@genkit-ai/vertexai", () => ({ gemini: jest.fn((version: string) => ({ name: `vertexai/${version}` })), })); -// Mock the sleep function to avoid actual waiting in tests -jest.mock("fs", () => ({ - readFileSync: jest.fn().mockReturnValue(Buffer.from("mockImageData")), -})); - -jest.mock("mime", () => ({ - lookup: jest.fn().mockReturnValue("image/png"), -})); +// Mock logs so we can assert on which filter-blocked log fired. +jest.mock("../src/logs"); describe("checkImageContent with mocks", () => { // Test image path - using the same path as in your original test suite @@ -33,6 +28,7 @@ describe("checkImageContent with mocks", () => { // Import mocked modules after they've been mocked const { genkit } = require("genkit"); + const log = require("../src/logs"); beforeEach(() => { // Reset all mocks before each test @@ -264,4 +260,121 @@ describe("checkImageContent with mocks", () => { expect(mockGenerate).toHaveBeenCalled(); expect(result).toBe(true); }); + + // Schema used by checkImageContent's custom-prompt path — the moderation + // call sets output: { schema: z.object({ response: z.string() }) }. The + // genkit-emitted JSON schema is replicated here so the real + // ValidationError ctor receives a faithful `schema` field. + const moderationSchema = { + type: "object", + properties: { response: { type: "string" } }, + required: ["response"], + additionalProperties: true, + $schema: "http://json-schema.org/draft-07/schema#", + }; + + it("should return false when genkit throws ValidationError with null-content data (Bug 1)", async () => { + // Reproduces one failure shape Gemini 2.5 Flash + genkit produces + // when input-side safety refuses: empty content → parseSchema(null). + // Instantiated via the real class so the test fails loudly if genkit + // ever changes the ValidationError contract. + const validationError = new ValidationError({ + data: null, + errors: [{ path: "(root)", message: "must be object" }], + schema: moderationSchema, + }); + + const mockGenerate = jest.fn().mockRejectedValue(validationError); + genkit.mockImplementation(() => ({ generate: mockGenerate })); + + const result = await checkImageContent( + imagePath, + HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + "Is this image inappropriate?", + "image/png" + ); + + expect(result).toBe(false); + expect(log.contentFilterBlocked).toHaveBeenCalled(); + // Deterministic refusal — must not burn retries. + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("should return false when genkit throws ValidationError with empty-object data (Bug 1 variant)", async () => { + // The other observed safety-refusal manifestation, confirmed against + // a real borderline image: extractJson() returns {} when the model + // emits non-JSON or empty refusal text, and the schema rejects it for + // missing the required `response` field. + const validationError = new ValidationError({ + data: {}, + errors: [ + { path: "(root)", message: "must have required property 'response'" }, + ], + schema: moderationSchema, + }); + + const mockGenerate = jest.fn().mockRejectedValue(validationError); + genkit.mockImplementation(() => ({ generate: mockGenerate })); + + const result = await checkImageContent( + imagePath, + HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + "Is this image inappropriate?", + "image/png" + ); + + expect(result).toBe(false); + expect(log.contentFilterBlocked).toHaveBeenCalled(); + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("should also block on type-mismatch ValidationError (any moderation schema failure → block)", async () => { + // We control the prompt and schema in this code path, so the only + // source of ValidationError is the model's response. The intentional + // policy is: any schema-validation failure means the model didn't + // produce a usable verdict, which on borderline imagery is almost + // always an input-side safety refusal. Failing open is worse than + // a false-positive block, so we treat the whole class as blocked. + const validationError = new ValidationError({ + data: { response: 42 }, + errors: [{ path: "response", message: "must be string" }], + schema: moderationSchema, + }); + + const mockGenerate = jest.fn().mockRejectedValue(validationError); + genkit.mockImplementation(() => ({ generate: mockGenerate })); + + const result = await checkImageContent( + imagePath, + HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + "prompt", + "image/png" + ); + + expect(result).toBe(false); + expect(log.contentFilterBlocked).toHaveBeenCalled(); + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("should still rethrow non-ValidationError errors after exhausting retries", async () => { + // Sanity check: errors WITHOUT the ValidationError shape (status + + // detail.errors) still go through the retry path and propagate. + const networkError = new Error("ECONNRESET"); + + const mockGenerate = jest.fn().mockRejectedValue(networkError); + genkit.mockImplementation(() => ({ generate: mockGenerate })); + + await expect( + checkImageContent( + imagePath, + HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + "prompt", + "image/png", + 3 + ) + ).rejects.toThrow("ECONNRESET"); + + expect(mockGenerate).toHaveBeenCalledTimes(3); + expect(log.contentFilterBlocked).not.toHaveBeenCalled(); + }, 30000); }); diff --git a/storage-resize-images/functions/__tests__/integration/content-filter.live.test.ts b/storage-resize-images/functions/__tests__/integration/content-filter.live.test.ts new file mode 100644 index 000000000..35f698f93 --- /dev/null +++ b/storage-resize-images/functions/__tests__/integration/content-filter.live.test.ts @@ -0,0 +1,131 @@ +/** + * Live integration tests for checkImageContent — these hit the real + * Vertex AI Gemini 2.5 Flash API. They are skipped by default and only + * run when RUN_LIVE_CONTENT_FILTER_TESTS=true. + * + * Run locally: + * gcloud auth application-default login + * GCLOUD_PROJECT= \ + * npm run test:live-content-filter --prefix storage-resize-images/functions + * + * The Bug 1 regression test additionally requires LIVE_BORDERLINE_IMAGE_PATH + * pointing to a borderline-NSFW image that triggers Gemini's input-side + * safety refusal. That image cannot be checked into a public repo, so + * developers/CI supply their own. Without the env var, that test is + * skipped (the suite still runs the safe-image and weapon-image cases). + */ + +import * as path from "path"; +import { checkImageContent } from "../../src/content-filter"; + +function guessContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + default: + return "application/octet-stream"; + } +} + +const runLive = process.env.RUN_LIVE_CONTENT_FILTER_TESTS === "true"; +const describeLive = runLive ? describe : describe.skip; + +describeLive( + "checkImageContent (live, hits Vertex AI Gemini 2.5 Flash)", + () => { + jest.setTimeout(60_000); // first-call cold start can be 20s+ + + // test-jpg.jpg is the word "test" rendered as text — has actual content + // for Gemini to evaluate. test-image.png is a tiny black square which + // can trip BLOCK_LOW_AND_ABOVE simply because the model has nothing + // to be confident about. + const safeImagePath = path.join(__dirname, "..", "test-jpg.jpg"); + const weaponImagePath = path.join(__dirname, "..", "gun-image.png"); + + const weaponPrompt = + "Does this image contain a weapon (e.g. firearm, knife, explosive)? " + + "Answer 'yes' if it does, otherwise 'no'."; + + test("safe everyday image passes with BLOCK_ONLY_HIGH", async () => { + // BLOCK_ONLY_HIGH is the most permissive non-disabled threshold; + // BLOCK_LOW_AND_ABOVE can over-trigger on synthetic test fixtures + // (the no-prompt path uses 1-output-token, so the model has very + // little room to indicate "this is fine"). + const result = await checkImageContent( + safeImagePath, + "BLOCK_ONLY_HIGH", + null, + "image/jpeg" + ); + expect(result).toBe(true); + }); + + test("weapon image + weapon-detection custom prompt is blocked", async () => { + const result = await checkImageContent( + weaponImagePath, + "BLOCK_LOW_AND_ABOVE", + weaponPrompt, + "image/png" + ); + expect(result).toBe(false); + }); + + test("safe image + weapon-detection custom prompt is NOT blocked", async () => { + // Guards against the model over-triggering on unrelated content. + const result = await checkImageContent( + safeImagePath, + "BLOCK_LOW_AND_ABOVE", + weaponPrompt, + "image/jpeg" + ); + expect(result).toBe(true); + }); + + const borderlinePath = process.env.LIVE_BORDERLINE_IMAGE_PATH; + const borderlineDescribe = borderlinePath ? describe : describe.skip; + const borderlineContentType = borderlinePath + ? guessContentType(borderlinePath) + : "application/octet-stream"; + + borderlineDescribe( + "Bug 1 regression — borderline image (LIVE_BORDERLINE_IMAGE_PATH set)", + () => { + const moderationPrompt = + "You are classifying images for a family-friendly platform. " + + "Decide whether this image depicts intimate, romantic, or " + + "suggestive content. Answer 'yes' if any is present, otherwise 'no'."; + + test("borderline image refused by Gemini safety returns false (not thrown)", async () => { + // Without the fix, Gemini returns null content → genkit throws + // ValidationError → 3 retries → propagates as filterErrored. + // With the fix, the null-content shape is recognised as a block. + const result = await checkImageContent( + borderlinePath as string, + "BLOCK_LOW_AND_ABOVE", + moderationPrompt, + borderlineContentType + ); + expect(result).toBe(false); + }); + + test("same image with BLOCK_NONE still passes (threshold is honoured)", async () => { + const result = await checkImageContent( + borderlinePath as string, + "BLOCK_NONE", + null, + borderlineContentType + ); + expect(result).toBe(true); + }); + } + ); + } +); diff --git a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts new file mode 100644 index 000000000..db255d02f --- /dev/null +++ b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts @@ -0,0 +1,221 @@ +import * as path from "path"; +import * as fs from "fs"; +import { config as loadEnv } from "dotenv"; + +const envLocalPath = path.resolve( + __dirname, + "../../../../_emulator/extensions/storage-resize-images.env.local" +); + +loadEnv({ path: envLocalPath, debug: true, override: true }); + +jest.mock("fs", () => ({ + ...jest.requireActual("fs"), + copyFileSync: jest.fn(), +})); + +jest.mock("../../src/filters", () => ({ + shouldResize: jest.fn(), +})); + +jest.mock("../../src/file-operations", () => ({ + downloadOriginalFile: jest.fn(), + handleFailedImage: jest.fn(), + deleteTempFile: jest.fn().mockResolvedValue(undefined), + deleteRemoteFile: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../src/content-filter", () => ({ + checkImageContent: jest.fn(), +})); + +jest.mock("../../src/placeholder", () => ({ + replacePlaceholder: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../src/resize-image", () => ({ + resizeImages: jest.fn(), +})); + +jest.mock("../../src/events", () => ({ + setupEventChannel: jest.fn(), + recordStartResizeEvent: jest.fn().mockResolvedValue(undefined), + recordSuccessEvent: jest.fn().mockResolvedValue(undefined), + recordErrorEvent: jest.fn().mockResolvedValue(undefined), + recordStartEvent: jest.fn().mockResolvedValue(undefined), + recordCompletionEvent: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../src/logs", () => ({ + init: jest.fn(), + start: jest.fn(), + failed: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + contentFilterErrored: jest.fn(), + contentFilterRejected: jest.fn(), + placeholderReplaceError: jest.fn(), +})); + +jest.mock("firebase-admin", () => ({ + initializeApp: jest.fn(), + storage: jest.fn(() => ({ + bucket: jest.fn(() => ({})), + })), +})); + +import { generateResizedImageHandler } from "../../src/index"; +import { shouldResize } from "../../src/filters"; +import { + downloadOriginalFile, + handleFailedImage, +} from "../../src/file-operations"; +import { checkImageContent } from "../../src/content-filter"; +import { replacePlaceholder } from "../../src/placeholder"; +import { resizeImages } from "../../src/resize-image"; +import * as logs from "../../src/logs"; +import exp from "constants"; + +describe("generateResizedImageHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + } as any; + + const parsedPathMatcher = expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", + }); + + test("routes blocked-by-filter images to the failed-image path with blockedByFilter=true", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (checkImageContent as jest.Mock).mockResolvedValue(false); + (resizeImages as jest.Mock).mockResolvedValue([ + { + status: "fulfilled", + value: { success: true }, + }, + ]); + + await generateResizedImageHandler(mockObject, false); + + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + true + ); + expect(handleFailedImage).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + "/tmp/test.jpg", + "/tmp/test.jpg-placeholder" + ); + expect(replacePlaceholder).toHaveBeenCalledWith( + "/tmp/test.jpg-placeholder", + {}, + null + ); + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg-placeholder", + parsedPathMatcher, + mockObject + ); + }); + + test("resizes when the content filter passes", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (checkImageContent as jest.Mock).mockResolvedValue(true); + (resizeImages as jest.Mock).mockResolvedValue([ + { + status: "fulfilled", + value: { success: true }, + }, + ]); + + await generateResizedImageHandler(mockObject, false); + + expect(replacePlaceholder).not.toHaveBeenCalled(); + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + parsedPathMatcher, + mockObject + ); + expect(handleFailedImage).not.toHaveBeenCalled(); + }); + + test("treats filter errors as failures and skips resizing", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (checkImageContent as jest.Mock).mockRejectedValue( + new Error("filter boom") + ); + + await generateResizedImageHandler(mockObject, false); + + expect(replacePlaceholder).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + false + ); + }); + + test("still routes blocked images to the failed path when placeholder swap errors", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (checkImageContent as jest.Mock).mockResolvedValue(false); + + const swapErr = new Error("swap boom"); + (replacePlaceholder as jest.Mock).mockRejectedValue(swapErr); + + await generateResizedImageHandler(mockObject, false); + + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + true + ); + expect(handleFailedImage).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + "/tmp/test.jpg", + "/tmp/test.jpg-placeholder" + ); + expect(replacePlaceholder).toHaveBeenCalledWith( + "/tmp/test.jpg-placeholder", + {}, + null + ); + expect(logs.placeholderReplaceError).toHaveBeenCalledWith(swapErr); + expect(logs.contentFilterErrored).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + }); +}); diff --git a/storage-resize-images/functions/package.json b/storage-resize-images/functions/package.json index de561468b..4f2086d2c 100644 --- a/storage-resize-images/functions/package.json +++ b/storage-resize-images/functions/package.json @@ -12,6 +12,7 @@ "compile": "tsc", "test": "jest", "test:vulnerability": "RUN_VULNERABILITY_TEST=true jest", + "test:live-content-filter": "NODE_OPTIONS=--experimental-vm-modules RUN_LIVE_CONTENT_FILTER_TESTS=true jest --testPathPattern=integration/content-filter.live", "generate-readme": "firebase ext:info .. --markdown > ../README.md" }, "dependencies": { diff --git a/storage-resize-images/functions/src/content-filter.ts b/storage-resize-images/functions/src/content-filter.ts index c16b0ae98..e5bf48482 100644 --- a/storage-resize-images/functions/src/content-filter.ts +++ b/storage-resize-images/functions/src/content-filter.ts @@ -2,36 +2,18 @@ import vertexAI, { gemini } from "@genkit-ai/vertexai"; import { genkit, z } from "genkit"; import type { SafetyThreshold } from "./config"; import * as fs from "fs"; -import * as path from "path"; -import { Bucket } from "@google-cloud/storage"; -import { ObjectMetadata } from "firebase-functions/v1/storage"; -import { - replaceWithConfiguredPlaceholder, - replaceWithDefaultPlaceholder, -} from "./util"; -// Import the logging functions from your log.ts module import * as log from "./logs"; import { globalRetryQueue } from "./global"; /** * Creates a data URL from an image file - * @param filePath Path to the image file + * @param imageBuffer Raw image file contents + * @param contentType MIME type for the image, for example "image/jpeg" * @returns Data URL string */ -function createImageDataUrl(filePath: string): string { - const imageBuffer = fs.readFileSync(filePath); +function createImageDataUrl(imageBuffer: Buffer, contentType: string): string { const base64Image = imageBuffer.toString("base64"); - const mimeType = getMimeType(filePath); - return `data:${mimeType};base64,${base64Image}`; -} - -/** - * Determines MIME type based on file extension - * @param filePath Path to the file - * @returns MIME type string - */ -function getMimeType(filePath: string): string { - return path.extname(filePath).toLowerCase(); + return `data:${contentType};base64,${base64Image}`; } /** @@ -43,20 +25,12 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -type SafetyCategory = - | "HARM_CATEGORY_UNSPECIFIED" - | "HARM_CATEGORY_HATE_SPEECH" - | "HARM_CATEGORY_DANGEROUS_CONTENT" - | "HARM_CATEGORY_HARASSMENT" - | "HARM_CATEGORY_SEXUALLY_EXPLICIT"; - -const HARM_CATEGORIES: ReadonlyArray = [ +const HARM_CATEGORIES = [ "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_DANGEROUS_CONTENT", - "HARM_CATEGORY_UNSPECIFIED", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_HARASSMENT", -]; +] as const; /** * Creates safety settings based on filter level @@ -71,22 +45,76 @@ function createSafetySettings(filterLevel: SafetyThreshold) { } /** - * Performs the actual content check with the AI model + * Detects genkit's ValidationError, thrown when Gemini's response can't + * be parsed against our moderation schema. Observed manifestations on + * Gemini 2.5 Flash safety refusals: data === null (empty content), data + * === {} (refusal text that extractJson() coerced to an empty object), + * and likely other variants future model versions may produce. + * + * In this code path we control both the prompt and the schema, so any + * ValidationError from `ai.generate` originates from the model's + * response — there is no other validation source. Retrying is useless + * (deterministic same-input → same-bad-response), and the alternative + * (propagate as filterErrored) silently fails open on borderline NSFW + * imagery, which is the original bug. So treat any schema-validation + * failure here as an implicit block. + * + * Two structural signals must hold: + * - status === "INVALID_ARGUMENT" (genkit's category for ValidationError) + * - detail.errors is a populated array (proves the error came from + * parseSchema specifically — see @genkit-ai/core schema.js, + * ValidationError ctor populates detail: { errors, schema }) + */ +function isModerationSchemaRefusal(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const e = error as { + status?: string; + detail?: { errors?: unknown }; + }; + if (e.status !== "INVALID_ARGUMENT") return false; + const errors = e.detail?.errors; + return Array.isArray(errors) && errors.length > 0; +} + +/** + * Detects Gemini's explicit safety block, surfaced by genkit as a thrown + * error whose response carries finishReason === "blocked". Narrowed from + * `unknown` for the same reason as {@link isModerationSchemaRefusal}: the + * catch variable has no static shape. + */ +function isSafetyBlockedResponse(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const e = error as { + detail?: { response?: { finishReason?: unknown } }; + }; + return e.detail?.response?.finishReason === "blocked"; +} + +/** + * Entry point for image moderation: short-circuits when disabled, otherwise runs a single + * Vertex/Gemini call per attempt with retries and queue-backed backoff on transient errors. + * * @param localOriginalFile Path to the local image file - * @param filterLevel The content filter level to apply + * @param filterLevel The content filter level to apply ('LOW', 'MEDIUM', 'HIGH', or null to disable) * @param prompt Optional custom prompt to use for content checking * @param contentType The content type of the image + * @param maxAttempts Maximum number of retry attempts in case of errors * @returns Promise - true if the image passes the filter, false otherwise */ -async function performContentCheck( +export async function checkImageContent( localOriginalFile: string, filterLevel: SafetyThreshold | null, prompt: string | null, - contentType: string + contentType: string, + maxAttempts = 3 ): Promise { - const dataUrl = createImageDataUrl(localOriginalFile); + if (filterLevel === null && prompt === null) { + return true; + } + + const imageBuffer = fs.readFileSync(localOriginalFile); + const dataUrl = createImageDataUrl(imageBuffer, contentType); - // Initialize Vertex AI client const ai = genkit({ plugins: [ vertexAI({ @@ -96,17 +124,19 @@ async function performContentCheck( ], }); - // Determine the effective safety settings and prompt to use - const effectiveFilterLevel: SafetyThreshold = - filterLevel === null ? "BLOCK_NONE" : filterLevel; - const effectivePrompt = - prompt !== null + /** One Gemini moderation call (no retries). */ + async function moderateImageOnce(): Promise { + const effectiveFilterLevel: SafetyThreshold = + filterLevel === null ? "BLOCK_NONE" : filterLevel; + + const hasCustomPrompt = prompt !== null; + + const effectivePrompt = hasCustomPrompt ? prompt + '\n\n Please respond in json with either { "response": "yes" } or { "response": "no" }' : "Is this image appropriate?"; - const effectiveOutput = - prompt !== null + const effectiveOutput = hasCustomPrompt ? { format: "json", schema: z.object({ @@ -115,80 +145,54 @@ async function performContentCheck( } : undefined; - // Determine max tokens based on whether we're using custom prompt - const maxOutputTokens = prompt !== null ? 100 : 1; + // 1 token is enough for the default yes/no answer; custom prompts emit a JSON object. + const maxOutputTokens = hasCustomPrompt ? 100 : 1; - try { - const result = await ai.generate({ - model: gemini("gemini-2.5-flash"), - messages: [ - { - role: "user", - content: [ - { - text: effectivePrompt, - }, - { - media: { - url: dataUrl, - contentType, + try { + const result = await ai.generate({ + model: gemini("gemini-2.5-flash"), + messages: [ + { + role: "user", + content: [ + { + text: effectivePrompt, + }, + { + media: { + url: dataUrl, + contentType, + }, }, - }, - ], + ], + }, + ], + output: effectiveOutput, + config: { + temperature: 0.1, + maxOutputTokens, + safetySettings: createSafetySettings(effectiveFilterLevel), }, - ], - output: effectiveOutput, - config: { - temperature: 0.1, - maxOutputTokens, - safetySettings: createSafetySettings(effectiveFilterLevel), - }, - }); + }); - if (result.output?.response === "yes" && prompt !== null) { - log.customFilterBlocked(); - return false; - } + if (result.output?.response === "yes" && hasCustomPrompt) { + log.customFilterBlocked(); + return false; + } - return true; - } catch (error) { - if (error.detail?.response?.finishReason === "blocked") { - log.contentFilterBlocked(); - return false; + return true; + } catch (error) { + if (isSafetyBlockedResponse(error) || isModerationSchemaRefusal(error)) { + log.contentFilterBlocked(); + return false; + } + throw error; } - throw error; } -} -/** - * Checks if an image content is appropriate based on the provided filter level and optional custom prompt - * @param localOriginalFile Path to the local image file - * @param filterLevel The content filter level to apply ('LOW', 'MEDIUM', 'HIGH', or null to disable) - * @param prompt Optional custom prompt to use for content checking - * @param contentType The content type of the image - * @param maxAttempts Maximum number of retry attempts in case of errors - * @returns Promise - true if the image passes the filter, false otherwise - */ -export async function checkImageContent( - localOriginalFile: string, - filterLevel: SafetyThreshold | null, - prompt: string | null, - contentType: string, - maxAttempts = 3 -): Promise { - if (filterLevel === null && prompt === null) { - return true; - } - - // Helper function that handles retries using the queue const attemptWithRetry = async (attemptNumber: number): Promise => { try { - return await performContentCheck( - localOriginalFile, - filterLevel, - prompt, - contentType - ); + return await moderateImageOnce(); } catch (error) { // If we have attempts left, schedule a retry via the queue if (attemptNumber < maxAttempts) { @@ -219,53 +223,3 @@ export async function checkImageContent( // Start the first attempt (not via queue) return await attemptWithRetry(1); } - -/** - * Processes content filtering and handles placeholder replacement if needed - */ -export async function processContentFilter( - localFile: string, - object: ObjectMetadata, - bucket: Bucket, - _verbose: boolean, - config: any -): Promise<{ passed: boolean; failed: boolean | null }> { - let filterResult = true; // Default to true (pass) - let failed = null; // No failures yet - - try { - filterResult = await checkImageContent( - localFile, - config.contentFilterLevel, - config.customFilterPrompt, - object.contentType - ); - } catch (err) { - log.contentFilterErrored(err); - failed = true; - } - - if (filterResult === false) { - log.contentFilterRejected(object.name); - - try { - if (config.placeholderImagePath) { - log.replacingWithConfiguredPlaceholder(config.placeholderImagePath); - await replaceWithConfiguredPlaceholder( - localFile, - bucket, - config.placeholderImagePath - ); - } else { - log.replacingWithDefaultPlaceholder(); - await replaceWithDefaultPlaceholder(localFile); - } - log.placeholderReplaceComplete(localFile); - } catch (err) { - log.placeholderReplaceError(err); - failed = true; - } - } - - return { passed: filterResult, failed }; -} diff --git a/storage-resize-images/functions/src/index.ts b/storage-resize-images/functions/src/index.ts index 6a4a380cb..4c4f74ce5 100644 --- a/storage-resize-images/functions/src/index.ts +++ b/storage-resize-images/functions/src/index.ts @@ -22,6 +22,7 @@ import * as path from "path"; import * as sharp from "sharp"; import { File } from "@google-cloud/storage"; import { ObjectMetadata } from "firebase-functions/v1/storage"; +import * as fs from "fs"; import { resizeImages } from "./resize-image"; import { config, deleteImage } from "./config"; @@ -29,7 +30,8 @@ import * as logs from "./logs"; import { shouldResize } from "./filters"; import * as events from "./events"; import { convertToObjectMetadata } from "./util"; -import { processContentFilter } from "./content-filter"; +import { checkImageContent } from "./content-filter"; +import { replacePlaceholder } from "./placeholder"; import { deleteRemoteFile, deleteTempFile, @@ -50,7 +52,7 @@ logs.init(config); * When an image is uploaded in the Storage bucket, we generate a resized image automatically using * the Sharp image converting library. */ -const generateResizedImageHandler = async ( +export const generateResizedImageHandler = async ( object: ObjectMetadata, verbose = true ): Promise => { @@ -67,9 +69,9 @@ const generateResizedImageHandler = async ( const bucket = admin.storage().bucket(object.bucket); const filePath = object.name; // File path in the bucket. const parsedPath = path.parse(filePath); - const objectMetadata = object; - let failed = null; + let localOriginalFile: string; + let localProcessingFile: string | undefined; let remoteOriginalFile: File; try { @@ -79,22 +81,61 @@ const generateResizedImageHandler = async ( verbose ); - // Check content filter and replace with placeholder if needed - const filterResult = await processContentFilter( - localOriginalFile, - object, - bucket, - verbose, - config - ); + let blockedByFilter = false; + let filterErrored = false; + let blockedImageStored = false; + + try { + // shouldResize() above already rejects objects without a valid + // image/* contentType, so it is guaranteed defined here. The handler + // is exported and unit-tested directly, so assert the invariant + // explicitly rather than passing a possibly-undefined value through. + const passed = await checkImageContent( + localOriginalFile, + config.contentFilterLevel, + config.customFilterPrompt, + object.contentType! + ); + if (!passed) { + blockedByFilter = true; + logs.contentFilterRejected(object.name); + + await handleFailedImage( + bucket, + localOriginalFile, + object, + parsedPath, + true + ); + blockedImageStored = true; + + localProcessingFile = `${localOriginalFile}-placeholder`; + fs.copyFileSync(localOriginalFile, localProcessingFile); + try { + await replacePlaceholder( + localProcessingFile, + bucket, + config.placeholderImagePath + ); + } catch (err) { + logs.placeholderReplaceError(err); + filterErrored = true; + } + } + } catch (err) { + logs.contentFilterErrored(err); + filterErrored = true; + } + + const fileToResize = localProcessingFile ?? localOriginalFile; - // Process image resizing if content filter didn't fail - if (filterResult.failed !== true) { + let resizeFailed = false; + if (!filterErrored) { const resizeResults = await resizeImages( bucket, - localOriginalFile, + fileToResize, parsedPath, - objectMetadata + object ); await events.recordSuccessEvent({ @@ -102,31 +143,29 @@ const generateResizedImageHandler = async ( data: { input: object, outputs: resizeResults, - contentFilterPassed: filterResult.passed, + contentFilterPassed: !blockedByFilter, }, }); - // Only update failed status if it's still null (not already failed from content filter) - failed = - filterResult.failed === null - ? resizeResults.some( - (result) => - result.status === "rejected" || result.value.success === false - ) - : filterResult.failed; - } else { - failed = true; + resizeFailed = resizeResults.some( + (result) => + result.status === "rejected" || result.value.success === false + ); } + const failed = filterErrored || resizeFailed; + if (failed) { logs.failed(); - await handleFailedImage( - bucket, - localOriginalFile, - object, - parsedPath, - filterResult.passed === false - ); + if (!blockedImageStored) { + await handleFailedImage( + bucket, + localOriginalFile, + object, + parsedPath, + blockedByFilter + ); + } } else { if (config.deleteOriginalFile === deleteImage.onSuccess) { await deleteRemoteFile(remoteOriginalFile, filePath); @@ -142,6 +181,10 @@ const generateResizedImageHandler = async ( await deleteTempFile(localOriginalFile, filePath, verbose); } + if (localProcessingFile) { + await deleteTempFile(localProcessingFile, filePath, verbose); + } + if ( config.deleteOriginalFile === deleteImage.always && remoteOriginalFile diff --git a/storage-resize-images/functions/src/placeholder.ts b/storage-resize-images/functions/src/placeholder.ts new file mode 100644 index 000000000..9e298b009 --- /dev/null +++ b/storage-resize-images/functions/src/placeholder.ts @@ -0,0 +1,31 @@ +import { Bucket } from "@google-cloud/storage"; + +import * as log from "./logs"; +import { + replaceWithConfiguredPlaceholder, + replaceWithDefaultPlaceholder, +} from "./util"; + +/** + * Swaps the local file with a placeholder image. Uses the configured + * placeholder at `placeholderImagePath` when provided, otherwise the bundled + * default. + */ +export async function replacePlaceholder( + localFile: string, + bucket: Bucket, + placeholderImagePath: string | null +): Promise { + if (placeholderImagePath) { + log.replacingWithConfiguredPlaceholder(placeholderImagePath); + await replaceWithConfiguredPlaceholder( + localFile, + bucket, + placeholderImagePath + ); + } else { + log.replacingWithDefaultPlaceholder(); + await replaceWithDefaultPlaceholder(localFile); + } + log.placeholderReplaceComplete(localFile); +}