feat(media): moderate every uploaded image and video - #253
Merged
Conversation
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
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))
|
🎉 This PR is included in version 1.15.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mediaUrlsare withheld, andmediaPendingsays 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=falseswaps 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 /mediatrusted the client'sContent-Typeand filename — both attacker-controlled, so a request could claimimage/pngwhile carrying an SVG, andclip.png.htmlread as an image to an extension check.UpdateAvatarUseCaseandUpdateBannerUseCasehad 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.mediaUrlsaccepted anyformat: "uri"string andCreatePostUseCasestored 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 amedia_assetsrow, 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 onowner_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
PrismaMediaAssetRepositoryagainst Postgres, including the concurrent claim (two workers never receive the same asset), lease reclaim of a stranded row, the attach race guard, and detach.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 resetrepeatedly failed withP1002against the shared Neon test database. CI runs all three suites against a local Postgres.Deployment
MODERATION_ENABLED=truerequiresSIGHTENGINE_API_USERandSIGHTENGINE_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