Content moderation

View on GitHub

Content moderation

Every image a user uploads is screened before it's accepted — today that's two upload points, profile avatars (UserController::uploadAvatar()) and markdown image attachments pasted or dropped into an editor (AttachmentController::store(), see ../rich-text-editor/02-add-image-paste-and-drop-uploads.mdRich text editorAdd image paste & drop uploadsWalkthrough of the pipeline that lets an issue description accept an image pasted with Ctrl+V or dragged onto the page — every layer of it, in the order yo…) — via NsfwDetectionService, backed by an external image-classification service (nsfwjs, running as its own Docker service — see ../architecture/04-docker-doppler-and-deployment.mdArchitecture & project technicalsDocker, Doppler, and deploymentThe root README.md already walks through using the Docker/Doppler setup end to end (commands, which mode you're in, troubleshooting MissingAppKeyException)…). This category documents how the check itself works and how to add it to a new upload point.

Guides, in the order you'd actually need them

  1. Add moderation to a new upload pointContent moderationAdd moderation to a new upload pointWorked example: adding a project cover image upload — a feature that doesn't exist yet — with the exact same NSFW screening UserController::uploadAvatar()… — worked example adding NSFW screening to a not-yet-built project cover-image upload, following UserController::uploadAvatar()'s pattern exactly.
  2. Configure and tune moderationContent moderationConfigure and tune moderationFile: config/services.php — the three config values, the fail-open-when-disabled vs. fail-closed-on-error distinction, and how to change which classifications actually block an upload.

The architecture in one paragraph

NsfwDetectionService::classify() POSTs the uploaded file's raw bytes to the nsfwjs service's /classify endpoint and returns its raw prediction array (a list of { className, probability } pairs — nsfwjs's own five classes: Neutral/Drawing/Hentai/Porn/Sexy). isUnsafe() is the only place that turns those raw predictions into a yes/no decision: Porn/Hentai above the configurable NSFW_THRESHOLD (default 0.70), or Sexy above a separate, hardcoded 0.90 — deliberately less sensitive than the configurable threshold, since Sexy alone (unlike Porn/Hentai) covers a lot of entirely benign photography. validate() is the single method every call site actually calls — it short-circuits to true (upload allowed) instantly, with no HTTP call at all, when NSFW_DETECTION_ENABLED is false, but propagates an exception if the service is reachable-but-erroring while enabled, which every call site is expected to catch and turn into a user-facing "try again later" error rather than silently letting the upload through — see guide 2 for exactly why fail-open (disabled) and fail-closed (errored) are both deliberate, for different reasons.