Skip to content

feat(media): moderate every uploaded image and video - #253

Merged
aquie00t merged 3 commits into
mainfrom
feature/media-moderation
Sep 1, 2026
Merged

feat(media): moderate every uploaded image and video#253
aquie00t merged 3 commits into
mainfrom
feature/media-moderation

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Uploaded images and videos reach the platform with no content check at all. This adds automated moderation to every upload endpoint — post and comment media, article covers, avatars and banners — and to the paths that publish what was uploaded.

Images are scanned inside the upload request, so a refused file never reaches storage and never gets a URL. Videos cannot be judged that way — the provider has to fetch and sample them — so they are stored withheld from the read path and resolved by a cron worker. Verdicts are tiered: explicit sexual content, gore, self-harm and hate imagery are refused outright, while suggestive content, weapons and depicted violence only mark the media sensitive so the client blurs it. That split is deliberate — this platform is full of game screenshots, and a filter that deletes those is one people route around.

Content whose media has no verdict yet is still served; only its mediaUrls are withheld, and mediaPending says why. The text was never in question, and a video usually clears within a minute.

Sightengine is the provider: one API covers both stills and video and accepts video by URL, which the alternatives do not — they read video only from their own cloud's object storage, and this platform stores media in R2. MODERATION_ENABLED=false swaps in a stand-in that approves everything, which is what the test environment and a laptop without credentials use. It is never a fallback: an upload that could not be checked is refused, because otherwise every provider outage becomes the way past the filter.

Note that this branch also carries a27aa64, the earlier feed fix, which was already on the base branch.

Root cause

There was no moderation anywhere, but the more specific problem is that adding it at the upload endpoint would not have been enough.

POST /media trusted the client's Content-Type and filename — both attacker-controlled, so a request could claim image/png while carrying an SVG, and clip.png.html read as an image to an extension check. UpdateAvatarUseCase and UpdateBannerUseCase had the same weakness. Only the article cover endpoint read the bytes, and that check is now the shared one.

The larger hole was in publishing rather than uploading. createPostBodySchema.mediaUrls accepted any format: "uri" string and CreatePostUseCase stored it unexamined, so a client could skip the upload endpoint entirely and put whatever URLs it liked into a post body. Scanning at upload time governs only what the upload endpoint writes; without a check at publish time the whole pipeline would have been decorative. Every stored file therefore gets a media_assets row, and post and comment creation resolve each submitted URL back to one — refusing it unless this author uploaded it, through the matching channel, and nothing else has already claimed it. The attach itself is the atomic claim, guarded on owner_id IS NULL, so two requests carrying the same key cannot both win.

The worker is written around the ways a video could otherwise be hidden forever. It re-reads an asset after scanning rather than trusting the snapshot it claimed, because an upload is routinely claimed before the post using it is submitted. Claims carry a lease, so a process killed mid-scan releases the asset instead of stranding it in SCANNING, which nothing else selects. A failure handler that fails cannot abandon the rest of its batch. And an owner deleted mid-scan is a no-op rather than an error, so a verdict already paid for is not discarded and re-derived into a false rejection.

Tests

  • Unit — 1118 passing. New coverage for the shared upload path (a refused image never reaches storage; a provider error fails closed; a video is stored pending), byte-level format detection including MP4-family brand disambiguation and SVG rejection, the score-to-verdict thresholds as a pure function, the ownership resolver's five refusal cases, and the worker — stale-owner recovery, batch isolation, retry budget, and the reject-and-notify path.
  • Integration — 180 passing. PrismaMediaAssetRepository against Postgres, including the concurrent claim (two workers never receive the same asset), lease reclaim of a stranded row, the attach race guard, and detach.
  • E2E. Covers the guards that need neither storage nor a provider: format rejection before anything is written, and refusal of media URLs the author did not upload, on both posts and comments. The happy path needs a live R2 connection and stays out of scope, as it already does for the other upload endpoints.

The last full local E2E run was 380/382, taken before the review fixes in this branch; the two failures were timeouts in the feed and quote-notification suites, unrelated to this change — both use text-only posts, and this pipeline performs no queries for those. Local E2E could not be re-run afterwards: prisma migrate reset repeatedly failed with P1002 against the shared Neon test database. CI runs all three suites against a local Postgres.

Deployment

MODERATION_ENABLED=true requires SIGHTENGINE_API_USER and SIGHTENGINE_API_SECRET; the service refuses to boot without them rather than answering 503 to every upload while nobody notices. Existing rows are unaffected — they predate the pipeline, carry no assets, and default to approved and not sensitive.


AI asistan: Claude Opus 5

aquie00t and others added 3 commits September 1, 2026 07:20
Reported from production: a Turkish reader on the community feed was handed
the two Turkish posts that exist and then told there was nothing left, while
hundreds of older posts sat behind them.

Two bugs compounding.

The ranked window is a bounded pool of recent posts, and on a quiet feed it
holds fewer than a page. The page ended there. The chronological tail was
only reached on the *next* request, which the reader never made because of
the second bug: hasMore was inferred from whether the page came back full.
A short page meant "end of feed", which is wrong at exactly the boundary
where the ranked window runs out mid-page - and that boundary is the normal
case whenever a reader's language is scarce.

A ranked page too short for the limit is now topped up from the tail in the
same request, skipping everything the snapshot holds so nothing repeats, and
counting in the snapshot's coordinates so the next page continues rather than
restarting the tail. hasMore is read straight off the cursor: the use case
knows when it has run out and says so by returning none, instead of the
controller guessing from a row count.

The tail also reports exhaustion honestly now. It used to keep offering a
cursor for any non-empty page, so the last page always promised one more.

The foreign-language quota drops to 0.2, which is the 80/20 split asked for.
Worth being precise about what it is: a ceiling on content the reader cannot
read, applied only while there is content in their own language to spend the
other slots on. It has never been a floor, and with two Turkish posts in the
corpus it cannot be - the feed serves those two first and then keeps going
rather than running dry, which is the behaviour that was actually wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP54iXPnpBLkTfg2te3hcn
Adds the media_assets table and the moderation provider behind it. A row
per stored file carries who uploaded it, through which endpoint, and what
the provider said - kept with the raw class scores, so thresholds can be
retuned against real traffic rather than against guesses.

Verdicts are tiered rather than binary. Explicit sexual content, gore,
self-harm and hate imagery are refused; suggestive content, weapons and
depicted violence only mark the media sensitive, because a developer
network is full of game screenshots and a filter that deletes those is one
people route around.

Videos cannot be judged inside a request, so they are claimed by a cron
worker with FOR UPDATE SKIP LOCKED and a lease: several API instances can
run the same schedule without paying twice for one verdict, and a process
killed mid-scan releases its claim instead of hiding a post's media
forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc
Post and comment media, article covers, avatars and banners now share one
upload path. It reads the format from the file's magic bytes instead of the
client's MIME type and filename, both of which the uploader controls: a
request can claim image/png while carrying an SVG, and a name like
clip.png.html reads as an image to an extension check. Images are scanned
before a byte reaches storage, so a refused file never gets a URL; videos
are stored withheld and left to the worker.

Content creation resolves every submitted media URL back to an asset row
and refuses it unless this author uploaded it, through the matching
channel, and nothing else has claimed it. This is what makes the rest of
the pipeline mean anything: scanning at upload time only governs the upload
endpoint, and a client is free to skip that endpoint and put its own URLs
straight into a post body.

The read path withholds media that has no verdict yet but keeps serving the
text - the words were never in question, and a video usually clears within
a minute - and reports isSensitive so a client can blur what moderation
judged borderline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc
@aquie00t
aquie00t merged commit 7d42386 into main Sep 1, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/media-moderation branch September 1, 2026 11:15
github-actions Bot pushed a commit that referenced this pull request Sep 1, 2026
# [1.15.0](v1.14.1...v1.15.0) (2026-09-01)

### Features

* **media:** moderate every uploaded image and video ([#253](#253)) ([7d42386](7d42386))
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.15.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant