Overview
Now that we run our own chat widget, every image a user sends flows through our backend before it reaches our messaging inbox (Front). That gives us one place to scan image attachments and stop explicit ones from ever reaching the team — so our messaging volunteers and staff are never exposed to them.
This is a backend change in bloom-backend, with a small optional frontend follow-up in bloom-frontend for the user-facing message. You do not need a Front account or any Front credentials — the whole feature is testable with mocks and a local smoke-test script (see Testing).
When a user uploads an image, we scan it locally before forwarding. If it's flagged as explicit, we block it: it's never forwarded, an alert is raised in Rollbar, the agent gets a note in the thread, and the user sees a clear message.
- Flags: Porn, Hentai (anime porn), and overtly sexual / lingerie / swimwear (the model's
Sexy class).
- Does NOT flag (and must not block): violence/gore, hate speech, drug use, self-harm or medical imagery — the model doesn't detect these and we don't want it to.
We scan on the backend, not the frontend: it's the single point every attachment passes through before Front (frontend checks can be bypassed by calling the API directly), and it's the only way to meet the privacy bar below.
Action Items
Guidance and Resources
Privacy bar (must hold): scanning runs entirely in-process, the image is never sent to a third party, and nothing is written to disk. We achieve this with nsfwjs + @tensorflow/tfjs-node, loading a model committed to the repo via file:// (no runtime network calls). MIT-licensed and free.
⚠️ @tensorflow/tfjs-node builds/downloads a native binding at install time (one-off). If yarn install struggles, check the tfjs-node install notes and make sure you're on the Node version in our package.json engines field (>=22.13.0). Flag it on the issue if you get stuck — we'd rather know.
1. Install + bundle the model
cd bloom-backend
yarn add nsfwjs @tensorflow/tfjs-node
Commit the MobileNetV2 model files (model.json + the groupN-shardNofN.bin weight shards) from the nsfwjs models repo at src/front-chat/assets/model/. The service loads them relative to __dirname, so they must be copied into dist/ on build — add them to nest-cli.json assets (match the existing assets shape):
"assets": [{ "include": "front-chat/assets/**/*", "outDir": "dist/src" }],
"watchAssets": true
2. src/front-chat/image-scanning.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as tf from '@tensorflow/tfjs-node';
import * as nsfwjs from 'nsfwjs';
import * as path from 'path';
import { Logger } from 'src/logger/logger';
const logger = new Logger('ImageScanningService');
const EXPLICIT_CLASSES = new Set(['Porn', 'Hentai', 'Sexy']); // model also returns Neutral / Drawing
const THRESHOLD = 0.6; // 60% confidence; tune later if we see false positives/negatives
@Injectable()
export class ImageScanningService implements OnModuleInit {
private model: nsfwjs.NSFWJS;
async onModuleInit() {
const modelPath = path.resolve(__dirname, 'assets', 'model');
this.model = await nsfwjs.load(`file://${modelPath}/`, { size: 224 }); // local, no network. Trailing slash required.
logger.log('Local NSFW model loaded');
}
async scanImage(buffer: Buffer): Promise<{ isSafe: boolean; reason?: string }> {
let tensor: tf.Tensor3D | undefined;
try {
tensor = tf.node.decodeImage(buffer, 3) as tf.Tensor3D;
const predictions = await this.model.classify(tensor);
for (const p of predictions) {
if (EXPLICIT_CLASSES.has(p.className) && p.probability > THRESHOLD) {
return { isSafe: false, reason: `${p.className} ${(p.probability * 100).toFixed(1)}%` };
}
}
return { isSafe: true };
} catch (error) {
// Fail-open: if the scanner breaks, let the image through so chat doesn't halt for everyone.
logger.error(`Image scanning failed: ${(error as Error)?.message}`);
return { isSafe: true, reason: 'Scanner error' };
} finally {
tensor?.dispose(); // Critical: tensors aren't garbage-collected — leaks memory otherwise.
}
}
}
Register ImageScanningService in front-chat.module.ts providers.
3. Scan + alert in sendChannelAttachment (front-chat.service.ts, right after the Cypress check, before building the FormData). Inject ImageScanningService, and add a typed error so the controller can return a distinct status:
export class ImageBlockedError extends Error {
constructor(reason: string) {
super(reason);
this.name = 'ImageBlockedError';
}
}
if (file.mimetype.startsWith('image/')) {
const { isSafe, reason } = await this.imageScanningService.scanImage(file.buffer);
if (!isSafe) {
// logger.error forwards to Rollbar (logger.warn does not) — this is our alert.
// PII is auto-redacted; never log the image itself, only id + classification.
logger.error(`Blocked explicit image attachment from user ${user.id} (${reason})`);
// Tell the agent something was blocked, without forwarding the image. Best-effort.
await this.sendChannelTextMessage(
user,
'This image has been blocked because it may contain something explicit or malicious. The user has been notified.',
).catch(() => {});
throw new ImageBlockedError(reason ?? 'Image blocked');
}
}
Decision for the reviewer: the original ticket asks whether we notify the user and/or agent. This notifies both — agent via a thread note, user via the 422 → frontend message — and raises a Rollbar alert so we can monitor false positives. Comment if we'd prefer to notify only one side.
4. Map to 422 in front-chat.controller.ts (wrap the sendChannelAttachment call; keeps it distinct from the existing 400/413 paths):
try {
const chatUser = await this.frontChatService.sendChannelAttachment(req.userEntity, file);
this.syncChatActivity(chatUser, req.userEntity.email);
} catch (err) {
if (err instanceof ImageBlockedError) {
throw new UnprocessableEntityException(
"This image has been blocked because it didn't pass our safety standards. " +
'If you think this is a mistake, please write a message to explain why.',
);
}
throw err;
}
Testing
Unit tests (mock the model — runs in CI). Add to the existing describe('sendChannelAttachment') in front-chat.service.spec.ts (~line 759; reuse its user/file fixtures). Mock so you control predictions without loading TensorFlow:
jest.mock('nsfwjs', () => ({ load: jest.fn().mockResolvedValue({ classify: jest.fn() }) }));
jest.mock('@tensorflow/tfjs-node', () => ({
node: { decodeImage: jest.fn(() => ({ dispose: jest.fn() })) },
}));
Cover, at minimum:
- Explicit blocked —
classify → [{ className: 'Porn', probability: 0.9 }]: rejects with ImageBlockedError and the multipart upload fetch is never called.
- Alert raised — same case asserts
logger.error (Rollbar) was called.
- Agent notified — spy on
sendChannelTextMessage.
- Safe forwarded —
[{ className: 'Neutral', probability: 0.95 }]: upload happens as today.
- Below threshold —
[{ className: 'Sexy', probability: 0.4 }]: forwarded.
- Non-image skipped — a
application/pdf / audio/webm file never hits scanImage.
- Fail-open —
scanImage throws → image still forwarded.
yarn test src/front-chat/front-chat.service.spec.ts
Local smoke test (prove the real model loads — runs on your machine). CI uses mocks, so confirm the bundled model wiring works. Put one or two ordinary safe images (a landscape, a pet) in src/front-chat/__fixtures__/ and:
// scripts/scan-smoke-test.ts — npx ts-node scripts/scan-smoke-test.ts
import * as fs from 'fs';
import { ImageScanningService } from '../src/front-chat/image-scanning.service';
(async () => {
const svc = new ImageScanningService();
await svc.onModuleInit();
console.log(await svc.scanImage(fs.readFileSync('src/front-chat/__fixtures__/landscape.jpg')));
// expect { isSafe: true } — proves the model loads from disk with no network access
})();
Please don't test with or commit explicit images — the mocked unit tests already prove the blocking path; the smoke test only proves the model wiring.
Optional frontend follow-up (bloom-frontend)
So blocked users see the safety message instead of the generic "Upload failed":
-
lib/hooks/useMessaging.ts, in sendAttachment, special-case 422:
if (!response.ok) {
throw new Error(response.status === 422 ? 'IMAGE_BLOCKED' : `UPLOAD_FAILED_${response.status}`);
}
-
components/messaging/MessageComposer.tsx, in the image catch:
onError(t((err as Error).message === 'IMAGE_BLOCKED' ? 'errors.imageBlocked' : 'errors.uploadFailed'));
-
Add the imageBlocked key under messaging.…errors in all six locale files in i18n/messages/messaging/ (register matched to each existing file):
Acceptance criteria
Overview
Now that we run our own chat widget, every image a user sends flows through our backend before it reaches our messaging inbox (Front). That gives us one place to scan image attachments and stop explicit ones from ever reaching the team — so our messaging volunteers and staff are never exposed to them.
This is a backend change in
bloom-backend, with a small optional frontend follow-up inbloom-frontendfor the user-facing message. You do not need a Front account or any Front credentials — the whole feature is testable with mocks and a local smoke-test script (see Testing).When a user uploads an image, we scan it locally before forwarding. If it's flagged as explicit, we block it: it's never forwarded, an alert is raised in Rollbar, the agent gets a note in the thread, and the user sees a clear message.
Sexyclass).We scan on the backend, not the frontend: it's the single point every attachment passes through before Front (frontend checks can be bypassed by calling the API directly), and it's the only way to meet the privacy bar below.
Action Items
nsfwjs+@tensorflow/tfjs-nodeand bundle a local NSFW model in the repo.ImageScanningServicethat classifies an image buffer in-process.sendChannelAttachment(src/front-chat/front-chat.service.ts), only forimage/*files.logger.error, post an agent-facing note in the thread, and surface a422to the client.HTTP 422infront-chat.controller.ts.nsfwjsmocked: block / allow / below-threshold / non-image / agent-notified / alert-raised / fail-open.bloom-frontend) Show the friendly blocked message + add theimageBlockedtranslation to all 6 locales (strings provided below).Guidance and Resources
Privacy bar (must hold): scanning runs entirely in-process, the image is never sent to a third party, and nothing is written to disk. We achieve this with
nsfwjs+@tensorflow/tfjs-node, loading a model committed to the repo viafile://(no runtime network calls). MIT-licensed and free.1. Install + bundle the model
cd bloom-backend yarn add nsfwjs @tensorflow/tfjs-nodeCommit the MobileNetV2 model files (
model.json+ thegroupN-shardNofN.binweight shards) from the nsfwjs models repo atsrc/front-chat/assets/model/. The service loads them relative to__dirname, so they must be copied intodist/on build — add them tonest-cli.jsonassets (match the existingassetsshape):2.
src/front-chat/image-scanning.service.tsRegister
ImageScanningServiceinfront-chat.module.tsproviders.3. Scan + alert in
sendChannelAttachment(front-chat.service.ts, right after the Cypress check, before building theFormData). InjectImageScanningService, and add a typed error so the controller can return a distinct status:4. Map to
422infront-chat.controller.ts(wrap thesendChannelAttachmentcall; keeps it distinct from the existing400/413paths):Testing
Unit tests (mock the model — runs in CI). Add to the existing
describe('sendChannelAttachment')infront-chat.service.spec.ts(~line 759; reuse itsuser/filefixtures). Mock so you control predictions without loading TensorFlow:Cover, at minimum:
classify→[{ className: 'Porn', probability: 0.9 }]: rejects withImageBlockedErrorand the multipart uploadfetchis never called.logger.error(Rollbar) was called.sendChannelTextMessage.[{ className: 'Neutral', probability: 0.95 }]: upload happens as today.[{ className: 'Sexy', probability: 0.4 }]: forwarded.application/pdf/audio/webmfile never hitsscanImage.scanImagethrows → image still forwarded.yarn test src/front-chat/front-chat.service.spec.tsLocal smoke test (prove the real model loads — runs on your machine). CI uses mocks, so confirm the bundled model wiring works. Put one or two ordinary safe images (a landscape, a pet) in
src/front-chat/__fixtures__/and:Please don't test with or commit explicit images — the mocked unit tests already prove the blocking path; the smoke test only proves the model wiring.
Optional frontend follow-up (
bloom-frontend)So blocked users see the safety message instead of the generic "Upload failed":
lib/hooks/useMessaging.ts, insendAttachment, special-case422:components/messaging/MessageComposer.tsx, in the imagecatch:Add the
imageBlockedkey undermessaging.…errorsin all six locale files ini18n/messages/messaging/(register matched to each existing file):Acceptance criteria
sendChannelAttachmentbefore forwarding; non-images untouched.Porn/Hentai/Sexyis not forwarded; agent gets a note; client gets422with the user copy.logger.error), logging only id + classification, never the image.file://).nsfwjsmocked.distvianest-cli.json.