From 8e1bbef84bb0ad068eabd59508b609c194196614 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 17 Apr 2026 15:20:05 +0100 Subject: [PATCH 1/7] fix: images that fail content filter are added to failed directory --- .../unit/generateResizedImageHandler.test.ts | 213 ++++++++++++++++++ storage-resize-images/functions/src/index.ts | 5 +- 2 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts 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..09cb363d8 --- /dev/null +++ b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts @@ -0,0 +1,213 @@ +import * as path from "path"; +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("../../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", () => ({ + processContentFilter: jest.fn(), +})); + +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(), +})); + +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 { processContentFilter } from "../../src/content-filter"; +import { resizeImages } from "../../src/resize-image"; + +describe("generateResizedImageHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("goes down the failed-image path when content filter returns passed:false and failed:null", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (processContentFilter as jest.Mock).mockResolvedValue({ + passed: false, + failed: null, + }); + + const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + } as any; + + await generateResizedImageHandler(mockObject, false); + + expect(resizeImages).not.toHaveBeenCalled(); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", + }), + true + ); + }); + + test("resizes image when content filter returns passed:true and failed:false", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (processContentFilter as jest.Mock).mockResolvedValue({ + passed: true, + failed: false, + }); + (resizeImages as jest.Mock).mockResolvedValue([ + { + status: "fulfilled", + value: { success: true }, + }, + ]); + + const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + } as any; + + await generateResizedImageHandler(mockObject, false); + + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", + }), + mockObject + ); + expect(handleFailedImage).not.toHaveBeenCalled(); + }); + + test("resizes when passed:true even if failed is null", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (processContentFilter as jest.Mock).mockResolvedValue({ + passed: true, + failed: null, + }); + (resizeImages as jest.Mock).mockResolvedValue([ + { + status: "fulfilled", + value: { success: true }, + }, + ]); + + const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + } as any; + + await generateResizedImageHandler(mockObject, false); + + expect(resizeImages).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", + }), + mockObject + ); + expect(handleFailedImage).not.toHaveBeenCalled(); + }); + + test("does not resize when passed:false even if failed:false", async () => { + (shouldResize as jest.Mock).mockReturnValue(true); + (downloadOriginalFile as jest.Mock).mockResolvedValue([ + "/tmp/test.jpg", + {}, + ]); + (processContentFilter as jest.Mock).mockResolvedValue({ + passed: false, + failed: false, + }); + + const mockObject = { + bucket: "demo-bucket", + name: "images/test.jpg", + contentType: "image/jpeg", + } as any; + + await generateResizedImageHandler(mockObject, false); + + expect(resizeImages).not.toHaveBeenCalled(); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + expect.objectContaining({ + dir: "images", + base: "test.jpg", + name: "test", + ext: ".jpg", + }), + true + ); + }); +}); diff --git a/storage-resize-images/functions/src/index.ts b/storage-resize-images/functions/src/index.ts index 6a4a380cb..fd67e9171 100644 --- a/storage-resize-images/functions/src/index.ts +++ b/storage-resize-images/functions/src/index.ts @@ -50,7 +50,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 => { @@ -89,7 +89,8 @@ const generateResizedImageHandler = async ( ); // Process image resizing if content filter didn't fail - if (filterResult.failed !== true) { + if (filterResult.passed === true) { + // if (filterResult.failed !== true) { const resizeResults = await resizeImages( bucket, localOriginalFile, From 5030424782fa463b54fbb389223fe279b94d91d8 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 17 Apr 2026 15:43:27 +0100 Subject: [PATCH 2/7] refactor: clean up filtering logic 1 --- .../unit/generateResizedImageHandler.test.ts | 115 ++++-------------- .../functions/src/content-filter.ts | 64 +++++----- storage-resize-images/functions/src/index.ts | 50 ++++---- 3 files changed, 78 insertions(+), 151 deletions(-) diff --git a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts index 09cb363d8..e34b82f57 100644 --- a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts +++ b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts @@ -42,6 +42,7 @@ jest.mock("../../src/logs", () => ({ failed: jest.fn(), complete: jest.fn(), error: jest.fn(), + contentFilterErrored: jest.fn(), })); jest.mock("firebase-admin", () => ({ @@ -65,22 +66,26 @@ describe("generateResizedImageHandler", () => { jest.clearAllMocks(); }); - test("goes down the failed-image path when content filter returns passed:false and failed:null", async () => { + 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", {}, ]); - (processContentFilter as jest.Mock).mockResolvedValue({ - passed: false, - failed: null, - }); - - const mockObject = { - bucket: "demo-bucket", - name: "images/test.jpg", - contentType: "image/jpeg", - } as any; + (processContentFilter as jest.Mock).mockResolvedValue(false); await generateResizedImageHandler(mockObject, false); @@ -89,26 +94,18 @@ describe("generateResizedImageHandler", () => { expect.anything(), "/tmp/test.jpg", mockObject, - expect.objectContaining({ - dir: "images", - base: "test.jpg", - name: "test", - ext: ".jpg", - }), + parsedPathMatcher, true ); }); - test("resizes image when content filter returns passed:true and failed:false", async () => { + test("resizes when the content filter passes", async () => { (shouldResize as jest.Mock).mockReturnValue(true); (downloadOriginalFile as jest.Mock).mockResolvedValue([ "/tmp/test.jpg", {}, ]); - (processContentFilter as jest.Mock).mockResolvedValue({ - passed: true, - failed: false, - }); + (processContentFilter as jest.Mock).mockResolvedValue(true); (resizeImages as jest.Mock).mockResolvedValue([ { status: "fulfilled", @@ -116,83 +113,26 @@ describe("generateResizedImageHandler", () => { }, ]); - const mockObject = { - bucket: "demo-bucket", - name: "images/test.jpg", - contentType: "image/jpeg", - } as any; - await generateResizedImageHandler(mockObject, false); expect(resizeImages).toHaveBeenCalledWith( expect.anything(), "/tmp/test.jpg", - expect.objectContaining({ - dir: "images", - base: "test.jpg", - name: "test", - ext: ".jpg", - }), + parsedPathMatcher, mockObject ); expect(handleFailedImage).not.toHaveBeenCalled(); }); - test("resizes when passed:true even if failed is null", async () => { + test("treats filter errors as failures and skips resizing", async () => { (shouldResize as jest.Mock).mockReturnValue(true); (downloadOriginalFile as jest.Mock).mockResolvedValue([ "/tmp/test.jpg", {}, ]); - (processContentFilter as jest.Mock).mockResolvedValue({ - passed: true, - failed: null, - }); - (resizeImages as jest.Mock).mockResolvedValue([ - { - status: "fulfilled", - value: { success: true }, - }, - ]); - - const mockObject = { - bucket: "demo-bucket", - name: "images/test.jpg", - contentType: "image/jpeg", - } as any; - - await generateResizedImageHandler(mockObject, false); - - expect(resizeImages).toHaveBeenCalledWith( - expect.anything(), - "/tmp/test.jpg", - expect.objectContaining({ - dir: "images", - base: "test.jpg", - name: "test", - ext: ".jpg", - }), - mockObject + (processContentFilter as jest.Mock).mockRejectedValue( + new Error("filter boom") ); - expect(handleFailedImage).not.toHaveBeenCalled(); - }); - - test("does not resize when passed:false even if failed:false", async () => { - (shouldResize as jest.Mock).mockReturnValue(true); - (downloadOriginalFile as jest.Mock).mockResolvedValue([ - "/tmp/test.jpg", - {}, - ]); - (processContentFilter as jest.Mock).mockResolvedValue({ - passed: false, - failed: false, - }); - - const mockObject = { - bucket: "demo-bucket", - name: "images/test.jpg", - contentType: "image/jpeg", - } as any; await generateResizedImageHandler(mockObject, false); @@ -201,13 +141,8 @@ describe("generateResizedImageHandler", () => { expect.anything(), "/tmp/test.jpg", mockObject, - expect.objectContaining({ - dir: "images", - base: "test.jpg", - name: "test", - ext: ".jpg", - }), - true + parsedPathMatcher, + false ); }); }); diff --git a/storage-resize-images/functions/src/content-filter.ts b/storage-resize-images/functions/src/content-filter.ts index c16b0ae98..b7991f661 100644 --- a/storage-resize-images/functions/src/content-filter.ts +++ b/storage-resize-images/functions/src/content-filter.ts @@ -221,51 +221,45 @@ export async function checkImageContent( } /** - * Processes content filtering and handles placeholder replacement if needed + * Runs content filtering and, if the image is blocked, swaps the local file + * with a placeholder image. + * + * @returns `true` if the image passed the filter, `false` if it was blocked + * (the local file has been replaced with a placeholder). + * @throws If the filter call errors or the placeholder swap errors. Callers + * are responsible for treating these as failures. */ 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 +): Promise { + const passed = await checkImageContent( + localFile, + config.contentFilterLevel, + config.customFilterPrompt, + object.contentType + ); - try { - filterResult = await checkImageContent( - localFile, - config.contentFilterLevel, - config.customFilterPrompt, - object.contentType - ); - } catch (err) { - log.contentFilterErrored(err); - failed = true; + if (passed) { + return true; } - if (filterResult === false) { - log.contentFilterRejected(object.name); + 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; - } + if (config.placeholderImagePath) { + log.replacingWithConfiguredPlaceholder(config.placeholderImagePath); + await replaceWithConfiguredPlaceholder( + localFile, + bucket, + config.placeholderImagePath + ); + } else { + log.replacingWithDefaultPlaceholder(); + await replaceWithDefaultPlaceholder(localFile); } + log.placeholderReplaceComplete(localFile); - return { passed: filterResult, failed }; + return false; } diff --git a/storage-resize-images/functions/src/index.ts b/storage-resize-images/functions/src/index.ts index fd67e9171..fea066cdf 100644 --- a/storage-resize-images/functions/src/index.ts +++ b/storage-resize-images/functions/src/index.ts @@ -67,8 +67,6 @@ export 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 remoteOriginalFile: File; @@ -79,23 +77,27 @@ export 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; + try { + blockedByFilter = !(await processContentFilter( + localOriginalFile, + object, + bucket, + config + )); + } catch (err) { + logs.contentFilterErrored(err); + filterErrored = true; + } - // Process image resizing if content filter didn't fail - if (filterResult.passed === true) { - // if (filterResult.failed !== true) { + let resizeFailed = false; + if (!blockedByFilter && !filterErrored) { const resizeResults = await resizeImages( bucket, localOriginalFile, parsedPath, - objectMetadata + object ); await events.recordSuccessEvent({ @@ -103,22 +105,18 @@ export const generateResizedImageHandler = async ( data: { input: object, outputs: resizeResults, - contentFilterPassed: filterResult.passed, + contentFilterPassed: true, }, }); - // 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 = blockedByFilter || filterErrored || resizeFailed; + if (failed) { logs.failed(); await handleFailedImage( @@ -126,7 +124,7 @@ export const generateResizedImageHandler = async ( localOriginalFile, object, parsedPath, - filterResult.passed === false + blockedByFilter ); } else { if (config.deleteOriginalFile === deleteImage.onSuccess) { From aea885942846a112c92690adac51cc999774ea5c Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Fri, 17 Apr 2026 15:58:51 +0100 Subject: [PATCH 3/7] refactor: clean up filtering logic 2 --- .../unit/generateResizedImageHandler.test.ts | 45 ++++++++++++++-- .../functions/src/content-filter.ts | 51 ------------------- storage-resize-images/functions/src/index.ts | 26 +++++++--- .../functions/src/placeholder.ts | 31 +++++++++++ 4 files changed, 91 insertions(+), 62 deletions(-) create mode 100644 storage-resize-images/functions/src/placeholder.ts diff --git a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts index e34b82f57..2162990cc 100644 --- a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts +++ b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts @@ -20,7 +20,11 @@ jest.mock("../../src/file-operations", () => ({ })); jest.mock("../../src/content-filter", () => ({ - processContentFilter: jest.fn(), + checkImageContent: jest.fn(), +})); + +jest.mock("../../src/placeholder", () => ({ + replacePlaceholder: jest.fn().mockResolvedValue(undefined), })); jest.mock("../../src/resize-image", () => ({ @@ -43,6 +47,8 @@ jest.mock("../../src/logs", () => ({ complete: jest.fn(), error: jest.fn(), contentFilterErrored: jest.fn(), + contentFilterRejected: jest.fn(), + placeholderReplaceError: jest.fn(), })); jest.mock("firebase-admin", () => ({ @@ -58,8 +64,10 @@ import { downloadOriginalFile, handleFailedImage, } from "../../src/file-operations"; -import { processContentFilter } from "../../src/content-filter"; +import { checkImageContent } from "../../src/content-filter"; +import { replacePlaceholder } from "../../src/placeholder"; import { resizeImages } from "../../src/resize-image"; +import * as logs from "../../src/logs"; describe("generateResizedImageHandler", () => { beforeEach(() => { @@ -85,10 +93,11 @@ describe("generateResizedImageHandler", () => { "/tmp/test.jpg", {}, ]); - (processContentFilter as jest.Mock).mockResolvedValue(false); + (checkImageContent as jest.Mock).mockResolvedValue(false); await generateResizedImageHandler(mockObject, false); + expect(replacePlaceholder).toHaveBeenCalled(); expect(resizeImages).not.toHaveBeenCalled(); expect(handleFailedImage).toHaveBeenCalledWith( expect.anything(), @@ -105,7 +114,7 @@ describe("generateResizedImageHandler", () => { "/tmp/test.jpg", {}, ]); - (processContentFilter as jest.Mock).mockResolvedValue(true); + (checkImageContent as jest.Mock).mockResolvedValue(true); (resizeImages as jest.Mock).mockResolvedValue([ { status: "fulfilled", @@ -115,6 +124,7 @@ describe("generateResizedImageHandler", () => { await generateResizedImageHandler(mockObject, false); + expect(replacePlaceholder).not.toHaveBeenCalled(); expect(resizeImages).toHaveBeenCalledWith( expect.anything(), "/tmp/test.jpg", @@ -130,12 +140,13 @@ describe("generateResizedImageHandler", () => { "/tmp/test.jpg", {}, ]); - (processContentFilter as jest.Mock).mockRejectedValue( + (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(), @@ -145,4 +156,28 @@ describe("generateResizedImageHandler", () => { 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(logs.placeholderReplaceError).toHaveBeenCalledWith(swapErr); + expect(logs.contentFilterErrored).not.toHaveBeenCalled(); + expect(resizeImages).not.toHaveBeenCalled(); + expect(handleFailedImage).toHaveBeenCalledWith( + expect.anything(), + "/tmp/test.jpg", + mockObject, + parsedPathMatcher, + true + ); + }); }); diff --git a/storage-resize-images/functions/src/content-filter.ts b/storage-resize-images/functions/src/content-filter.ts index b7991f661..38155e61f 100644 --- a/storage-resize-images/functions/src/content-filter.ts +++ b/storage-resize-images/functions/src/content-filter.ts @@ -3,13 +3,6 @@ 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"; @@ -219,47 +212,3 @@ export async function checkImageContent( // Start the first attempt (not via queue) return await attemptWithRetry(1); } - -/** - * Runs content filtering and, if the image is blocked, swaps the local file - * with a placeholder image. - * - * @returns `true` if the image passed the filter, `false` if it was blocked - * (the local file has been replaced with a placeholder). - * @throws If the filter call errors or the placeholder swap errors. Callers - * are responsible for treating these as failures. - */ -export async function processContentFilter( - localFile: string, - object: ObjectMetadata, - bucket: Bucket, - config: any -): Promise { - const passed = await checkImageContent( - localFile, - config.contentFilterLevel, - config.customFilterPrompt, - object.contentType - ); - - if (passed) { - return true; - } - - log.contentFilterRejected(object.name); - - if (config.placeholderImagePath) { - log.replacingWithConfiguredPlaceholder(config.placeholderImagePath); - await replaceWithConfiguredPlaceholder( - localFile, - bucket, - config.placeholderImagePath - ); - } else { - log.replacingWithDefaultPlaceholder(); - await replaceWithDefaultPlaceholder(localFile); - } - log.placeholderReplaceComplete(localFile); - - return false; -} diff --git a/storage-resize-images/functions/src/index.ts b/storage-resize-images/functions/src/index.ts index fea066cdf..d1b43d1fb 100644 --- a/storage-resize-images/functions/src/index.ts +++ b/storage-resize-images/functions/src/index.ts @@ -29,7 +29,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, @@ -80,12 +81,25 @@ export const generateResizedImageHandler = async ( let blockedByFilter = false; let filterErrored = false; try { - blockedByFilter = !(await processContentFilter( + const passed = await checkImageContent( localOriginalFile, - object, - bucket, - config - )); + config.contentFilterLevel, + config.customFilterPrompt, + object.contentType + ); + if (!passed) { + blockedByFilter = true; + logs.contentFilterRejected(object.name); + try { + await replacePlaceholder( + localOriginalFile, + bucket, + config.placeholderImagePath + ); + } catch (err) { + logs.placeholderReplaceError(err); + } + } } catch (err) { logs.contentFilterErrored(err); filterErrored = true; 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); +} From 6eab6419457dd1fb7373ee9bcf8a416118c1e073 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 17 Apr 2026 16:47:50 +0100 Subject: [PATCH 4/7] refactor: cleanup content-filter --- .../functions/src/content-filter.ts | 158 ++++++++---------- 1 file changed, 70 insertions(+), 88 deletions(-) diff --git a/storage-resize-images/functions/src/content-filter.ts b/storage-resize-images/functions/src/content-filter.ts index 38155e61f..7aec000b6 100644 --- a/storage-resize-images/functions/src/content-filter.ts +++ b/storage-resize-images/functions/src/content-filter.ts @@ -64,42 +64,51 @@ function createSafetySettings(filterLevel: SafetyThreshold) { } /** - * Performs the actual content check with the AI model + * 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); - - // Initialize Vertex AI client - const ai = genkit({ - plugins: [ - vertexAI({ - location: process.env.LOCATION ?? "us-central1", - models: ["gemini-2.5-flash"], - }), - ], - }); - - // Determine the effective safety settings and prompt to use - const effectiveFilterLevel: SafetyThreshold = - filterLevel === null ? "BLOCK_NONE" : filterLevel; - const effectivePrompt = - prompt !== null + if (filterLevel === null && prompt === null) { + return true; + } + + /** One Gemini moderation call (no retries). */ + async function moderateImageOnce(): Promise { + const dataUrl = createImageDataUrl(localOriginalFile); + + const ai = genkit({ + plugins: [ + vertexAI({ + location: process.env.LOCATION ?? "us-central1", + models: ["gemini-2.5-flash"], + }), + ], + }); + + 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({ @@ -108,80 +117,53 @@ async function performContentCheck( } : undefined; - // Determine max tokens based on whether we're using custom prompt - const maxOutputTokens = prompt !== null ? 100 : 1; - - try { - const result = await ai.generate({ - model: gemini("gemini-2.5-flash"), - messages: [ - { - role: "user", - content: [ - { - text: effectivePrompt, - }, - { - media: { - url: dataUrl, - contentType, + 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, + }, + }, + ], + }, + ], + 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 (error.detail?.response?.finishReason === "blocked") { + 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) { From ffc38e14edc756c6523cff964728b10db3b396c8 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 21 Apr 2026 16:10:11 +0100 Subject: [PATCH 5/7] fix(storage-resize-images): resize placeholders without overwriting blocked originals --- storage-resize-images/functions/src/index.ts | 50 +++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/storage-resize-images/functions/src/index.ts b/storage-resize-images/functions/src/index.ts index d1b43d1fb..71418678c 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-extra"; import { resizeImages } from "./resize-image"; import { config, deleteImage } from "./config"; @@ -68,7 +69,9 @@ export const generateResizedImageHandler = async ( const bucket = admin.storage().bucket(object.bucket); const filePath = object.name; // File path in the bucket. const parsedPath = path.parse(filePath); + let localOriginalFile: string; + let localProcessingFile: string | undefined; let remoteOriginalFile: File; try { @@ -80,6 +83,8 @@ export const generateResizedImageHandler = async ( let blockedByFilter = false; let filterErrored = false; + let blockedImageStored = false; + try { const passed = await checkImageContent( localOriginalFile, @@ -90,14 +95,27 @@ export const generateResizedImageHandler = async ( 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( - localOriginalFile, + localProcessingFile, bucket, config.placeholderImagePath ); } catch (err) { logs.placeholderReplaceError(err); + filterErrored = true; } } } catch (err) { @@ -105,11 +123,13 @@ export const generateResizedImageHandler = async ( filterErrored = true; } + const fileToResize = localProcessingFile ?? localOriginalFile; + let resizeFailed = false; - if (!blockedByFilter && !filterErrored) { + if (!filterErrored) { const resizeResults = await resizeImages( bucket, - localOriginalFile, + fileToResize, parsedPath, object ); @@ -119,7 +139,7 @@ export const generateResizedImageHandler = async ( data: { input: object, outputs: resizeResults, - contentFilterPassed: true, + contentFilterPassed: !blockedByFilter, }, }); @@ -129,17 +149,19 @@ export const generateResizedImageHandler = async ( ); } - const failed = blockedByFilter || filterErrored || resizeFailed; + const failed = filterErrored || resizeFailed; if (failed) { logs.failed(); - await handleFailedImage( - bucket, - localOriginalFile, - object, - parsedPath, - blockedByFilter - ); + if (!blockedImageStored) { + await handleFailedImage( + bucket, + localOriginalFile, + object, + parsedPath, + blockedByFilter + ); + } } else { if (config.deleteOriginalFile === deleteImage.onSuccess) { await deleteRemoteFile(remoteOriginalFile, filePath); @@ -155,6 +177,10 @@ export const generateResizedImageHandler = async ( await deleteTempFile(localOriginalFile, filePath, verbose); } + if (localProcessingFile) { + await deleteTempFile(localProcessingFile, filePath, verbose); + } + if ( config.deleteOriginalFile === deleteImage.always && remoteOriginalFile From f62708a99594d3601502614aa95f74874e83bf3d Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 21 Apr 2026 16:24:02 +0100 Subject: [PATCH 6/7] fix(storage-resize-images): reuse moderation setup and correct data URLs --- .../functions/src/content-filter.ts | 56 +++++++------------ 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/storage-resize-images/functions/src/content-filter.ts b/storage-resize-images/functions/src/content-filter.ts index 7aec000b6..ab550aa02 100644 --- a/storage-resize-images/functions/src/content-filter.ts +++ b/storage-resize-images/functions/src/content-filter.ts @@ -2,29 +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 * 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}`; } /** @@ -36,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 @@ -60,7 +41,7 @@ function createSafetySettings(filterLevel: SafetyThreshold) { return HARM_CATEGORIES.map((category) => ({ category, threshold: filterLevel, - })); + })) as any; } /** @@ -85,19 +66,20 @@ export async function checkImageContent( return true; } - /** One Gemini moderation call (no retries). */ - async function moderateImageOnce(): Promise { - const dataUrl = createImageDataUrl(localOriginalFile); + const imageBuffer = fs.readFileSync(localOriginalFile); + const dataUrl = createImageDataUrl(imageBuffer, contentType); - const ai = genkit({ - plugins: [ - vertexAI({ - location: process.env.LOCATION ?? "us-central1", - models: ["gemini-2.5-flash"], - }), - ], - }); + const ai = genkit({ + plugins: [ + vertexAI({ + location: process.env.LOCATION ?? "us-central1", + models: ["gemini-2.5-flash"], + }), + ], + }); + /** One Gemini moderation call (no retries). */ + async function moderateImageOnce(): Promise { const effectiveFilterLevel: SafetyThreshold = filterLevel === null ? "BLOCK_NONE" : filterLevel; From 8757c0d6bc50d28889ea168d32b9c2a8db21a808 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 21 Apr 2026 16:45:05 +0100 Subject: [PATCH 7/7] tests: fix tests and use fs instead of fs-extra --- .../__tests__/content-filter.test.ts | 9 ---- .../unit/generateResizedImageHandler.test.ts | 48 +++++++++++++++++-- storage-resize-images/functions/src/index.ts | 2 +- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/storage-resize-images/functions/__tests__/content-filter.test.ts b/storage-resize-images/functions/__tests__/content-filter.test.ts index 1a92ee86b..f8abc7c34 100644 --- a/storage-resize-images/functions/__tests__/content-filter.test.ts +++ b/storage-resize-images/functions/__tests__/content-filter.test.ts @@ -18,15 +18,6 @@ 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"), -})); - describe("checkImageContent with mocks", () => { // Test image path - using the same path as in your original test suite const imagePath = path.join(__dirname, "gun-image.png"); diff --git a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts index 2162990cc..db255d02f 100644 --- a/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts +++ b/storage-resize-images/functions/__tests__/unit/generateResizedImageHandler.test.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as fs from "fs"; import { config as loadEnv } from "dotenv"; const envLocalPath = path.resolve( @@ -8,6 +9,11 @@ const envLocalPath = path.resolve( loadEnv({ path: envLocalPath, debug: true, override: true }); +jest.mock("fs", () => ({ + ...jest.requireActual("fs"), + copyFileSync: jest.fn(), +})); + jest.mock("../../src/filters", () => ({ shouldResize: jest.fn(), })); @@ -68,6 +74,7 @@ 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(() => { @@ -94,11 +101,15 @@ describe("generateResizedImageHandler", () => { {}, ]); (checkImageContent as jest.Mock).mockResolvedValue(false); + (resizeImages as jest.Mock).mockResolvedValue([ + { + status: "fulfilled", + value: { success: true }, + }, + ]); await generateResizedImageHandler(mockObject, false); - expect(replacePlaceholder).toHaveBeenCalled(); - expect(resizeImages).not.toHaveBeenCalled(); expect(handleFailedImage).toHaveBeenCalledWith( expect.anything(), "/tmp/test.jpg", @@ -106,6 +117,22 @@ describe("generateResizedImageHandler", () => { 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 () => { @@ -164,14 +191,12 @@ describe("generateResizedImageHandler", () => { {}, ]); (checkImageContent as jest.Mock).mockResolvedValue(false); + const swapErr = new Error("swap boom"); (replacePlaceholder as jest.Mock).mockRejectedValue(swapErr); await generateResizedImageHandler(mockObject, false); - expect(logs.placeholderReplaceError).toHaveBeenCalledWith(swapErr); - expect(logs.contentFilterErrored).not.toHaveBeenCalled(); - expect(resizeImages).not.toHaveBeenCalled(); expect(handleFailedImage).toHaveBeenCalledWith( expect.anything(), "/tmp/test.jpg", @@ -179,5 +204,18 @@ describe("generateResizedImageHandler", () => { 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/src/index.ts b/storage-resize-images/functions/src/index.ts index 71418678c..206947a0c 100644 --- a/storage-resize-images/functions/src/index.ts +++ b/storage-resize-images/functions/src/index.ts @@ -22,7 +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-extra"; +import * as fs from "fs"; import { resizeImages } from "./resize-image"; import { config, deleteImage } from "./config";