-
Notifications
You must be signed in to change notification settings - Fork 0
Moderation
Sensitive-content moderation built ON the shipped vote-consensus system (Printing-Tags) — extended, not forked. Four pieces: Discord-authenticated moderators, a sensitive-tag class whose resolution requires a privileged co-sign, a card report button feeding an audit trail, and a moderator-only approval queue. One deliberately small consequence: cards resolved as NSFW stay out of search by default, with a visible opt-in toggle.
Ordinary voting is untouched. Anyone can still vote on any tag, anonymously,
exactly as before — including the sensitive ones. What changes is what a
crowd's consensus is allowed to do: for a tag marked sensitive, a consensus
that clears every normal threshold parks as pending_approval instead of
resolving, until a privileged vote (a moderator's or an admin's) backs the
winning side. Approval isn't a special action — it's an ordinary vote that
happens to be privileged, so the pending → resolved transition runs through
the exact same consensus pass, Card.tags merge, and Elasticsearch reindex
as any other resolution.
Membership of the MODERATORS_GROUP_NAME Django auth group (default
"Moderators") — that's the entire definition, checked at resolution time
(cardpicker/moderation.py), so revoking a moderator retroactively
de-privileges every vote they ever cast. Logging in with Discord grants
nothing by itself.
The grant mechanism is deliberately pluggable — everything consumes the group
through is_moderator / get_moderator_user_ids. The sketched follow-up for
a federation-wide moderator roster: add the guilds.members.read OAuth scope
and sync group membership from a role (e.g. @Moderator) in the project's
Discord server at login, so every instance independently verifies the same
authority and granting/revoking is one Discord role assignment. The portable
identity is the Discord user id, which allauth already stores in
SocialAccount.uid — no schema work needed when that lands. Note the
complementary path: cross-instance moderation effects travel via federated
verdict exchange (Federation-v1, v1.1), which needs no cross-instance
logins at all.
- Configured entirely from env:
DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET. Absent (e.g. dev) = the provider app isn't installed,/accounts/discord/login/404s, whoami reportsdiscordEnabled: false, nothing crashes. App-in-settings credentials (SOCIALACCOUNT_PROVIDERS) — noSocialAppDB row, nodjango.contrib.sites. -
GET 2/whoami/→{authenticated, username, moderator, discordEnabled, loginUrl, logoutUrl}. Frontend gating is presentation only; the moderation endpoint enforces the group server-side. - Login/logout links carry
?next=<frontend URL>and round-trip back;accounts/adapter.py::FrontendRedirectAccountAdaptervalidatesnextagainst the hosts inCORS_ALLOWED_ORIGINS, so the redirect allowlist can never drift from the CORS one.ACCOUNT_LOGOUT_ON_GET=Trueis a deliberate tradeoff: logout works as a plain link (needed for the round-trip), and the worst a hostile page can do is force a logout — a nuisance, not an escalation. - Cross-origin session: the cookie must ride from the frontend origin to
api.proxyprints.ca, so production needsSESSION_COOKIE_SAMESITE=None,SESSION_COOKIE_SECURE=True,CORS_ALLOW_CREDENTIALS=True(defaults suit same-site localhost dev). Only fetches that opt intocredentials:'include'are affected — whoami, reportCard, and every moderationQueue/moderationDrives*/ moderationRemoveCard/moderationRemoveDrive call (plus the moderation queue's approve/reject votes); every anonymous surface is byte-identical.
Every API endpoint here is @csrf_exempt because the primary clients are
anonymous cross-origin browsers with no Django CSRF cookie to round-trip —
votes are keyed by a client-generated anonymous_id, not a session. That was
safe while nothing read request.user. The moderator session changes that:
a SameSite=None cookie plus csrf_exempt POSTs would let any website forge
privileged votes from a logged-in moderator's browser.
cardpicker/security.py::reject_untrusted_origin closes exactly that hole:
browsers unconditionally attach an Origin header to cross-origin POSTs and
a page cannot forge it, so POSTs whose Origin is present but not in
CORS_ALLOWED_ORIGINS (∪ the backend's own origin) are rejected with 403.
Applied to every session-consuming POST (the three vote submit views,
reportCard, moderationQueue, moderationDrives, moderationDriveCards,
moderationRemoveCard, moderationRemoveDrive). Non-browser clients send no Origin at all
and keep exactly today's trust level; GET endpoints (whoami) change no state
and need nothing.
-
AbstractWeightedVote.user— nullable FK set (alongsideanonymous_id, never instead of it) when the submitting request carried an authenticated session. One additive migration covers all three vote tables. -
VoteTuple.is_privilegedmirrors theis_human_backedpattern: computed by the tag-consensus wrappers as "source==admin OR vote.user currently in the moderators group". Privileged votes weighVOTE_PRIVILEGED_WEIGHT(default = the admin weight, 5) via max() with their source weight — a lone moderator clears the threshold like a lone admin. -
resolve_weighted_consensus(..., require_privileged=True): a winner that clears weight/share/human-backed but has no privileged vote in the winning group returns thePENDING_PRIVILEGEDsentinel instead of the key. In-group, not merely present-among-the-votes: a moderator voting against the crowd must not count as the co-sign that lets the crowd's outcome through (their heavier vote usually flips or contests the result through the normal math anyway). Persisted aspending_approvalinCard.tag_vote_statuses;Card.tagsuntouched, so pending has zero search consequences. - Wired only for
Tag.moderation_class == "sensitive"and through both resolution paths: the interactive one (resolve_tag/resolve_and_persist_tag_votes) and the batched re-scan overlay (get_resolved_tag_overlay) — skipping the second would let a scheduledupdate_databasere-scan apply the very change the interactive path held for approval. Standard tags are byte-identical (the gate parameter defaults off; no pre-existing test file was modified — the untouched consensus suites are the regression proof). - Privileged weight is wired for tag consensus only this stage; printing and
artist votes record
user(the field lives on the shared abstract base) but their weight math is untouched. Extending them later needs no migration.
Tag.moderation_class (standard | sensitive, default standard).
manage.py seed_sensitive_tags seeds four (command, not data migration —
same rationale as the other taxonomies, see Printing-Tags):
| name | display name | report reason |
|---|---|---|
NSFW |
NSFW | nsfw |
low-res |
Low quality | low_quality |
incorrect-info |
Incorrect card info | wrong_card |
appropriate-bleed |
Appropriate Bleed | — (no chip) |
appropriate-bleed is deliberately the positive framing ("verified to
include the full bleed margin required for printing") rather than a negative
"missing-bleed": upstream drives require appropriate bleed on every card, so
the state worth verifying is the positive one — an untagged card reads as
"not yet verified", and a definitive "lacks bleed" verdict is still
expressible as the tag resolving REJECT through the same vote mechanics.
It's sensitive because that verification is exactly a moderator co-sign: the
crowd votes it via the card modal's tag picker, consensus parks as
pending_approval, and a moderator confirms it in the queue. No report-button
chip (it's a verification workflow, not a complaint) and no search consequence;
drive-level checks can select verified cards via the existing includesTags
filter.
NSFW deliberately reuses the pre-existing cardpicker.constants.NSFW name:
filename-bracket tagging ([NSFW]) and the frontend's default
excludesTags: ["NSFW"] already speak that exact string, and a lowercase twin
would split mature-content state across two names. Once seeded, the real row
replaces the synthetic pseudo-tag in the tag matcher (real rows win the
lowercased key). Seeding upgrades a pre-existing standard row to sensitive but
never clobbers a manually edited display_name. Names are immutable
federation contracts.
AI-Generated (public issue #261) was briefly upgraded to sensitive
(2026-07-21, this same day) from its pre-existing standard row (seeded by
cardpicker.default_tags.DEFAULT_TAGS for a different, orthogonal reason —
filename-bracket tagging, e.g. [Midjourney], applies it directly at
import time, exactly like [NSFW] above, untouched by any of this) —
then reverted back to standard the same day by owner decision, once
cardpicker.local_detect_ai_art (a calculator that scans already-stored OCR
evidence — artist credit line, legal line, collector line — for known
AI-generator marker strings: Midjourney, DALL-E, Stable Diffusion, SDXL,
Gemini, Imagen, Adobe Firefly, Leonardo AI, NightCafe, Bing Image Creator,
"AI art"/"AI generated", plus a 2026-07-22 expansion (ChatGPT, Playground AI,
Niji Journey, and a proactive roster of popular generators including major
Chinese-market products — Doubao, Jimeng, Dreamina, Seedream, Kling, Kolors,
Hunyuan, Tongyi Wanxiang/Wanxiang, ERNIE-ViLG, Wenxin Yige, Hailuo, CogView,
LiblibAI, Meitu, Wujie AI, Qwen, Recraft, Ideogram, Grok Imagine, Flux.1) —
deliberately excluding generator-SITE/tool names
like CardConjurer, which indicate a rendering tool usable with ordinary
human art, not AI provenance — and casts votes for it under its own machine
identity ai-art-detector-v1, VoteSource.OCR) went live and the owner
weighed in on the open question that upgrade had been guessing at (verbatim):
"ordinary human votes is fine for AI I think. or at least not moderator
eyes. they will go contested if there is not an immediate human consensus
that is the system working as intended." So AI-Generated now behaves like
any other standard tag: the shared human-backed gate is unchanged (a lone
machine vote still can never resolve any tag at all, regardless of
moderation_class), but an ordinary confident crowd consensus resolves it
without a moderator co-sign, and a genuinely contested crowd stays
contested — which is the intended outcome, not a gap. A future
privileged-co-sign requirement specifically for this tag remains a
possible follow-up idea, not built now. cardpicker.sensitive_tags. FORMERLY_SENSITIVE_TAG_NAMES (currently just {"AI-Generated"}) lets
seed_sensitive_tags sync this downgrade on any instance that already ran
the brief sensitive-era seed and has the row stuck at sensitive — running
manage.py seed_sensitive_tags again reports it as downgraded, alongside
the usual created/updated counts. Positive-detection only, unchanged:
a missing marker proves nothing, so this calculator never casts a negative
vote, only APPLY.
Two knock-ons worth knowing: the seeded tags become visible/votable in the
card modal's tag grid for everyone (intended — votes accumulate as pending),
and filename-bracket [NSFW] tagging at scan time remains ungated (the gate
governs vote-driven changes; the overlay exclusion means a crowd can't
remove a filename NSFW without a moderator either).
Flag button on the card detail modal → chips: NSFW / Low quality / Wrong card
info / Broken image / Other (+free text ≤280 chars). POST 2/reportCard/
always writes a CardReport audit row (card, anonymous_id, nullable user,
reason, text, created_at — ModelAdmin filterable by reason/date, searchable by
card); the three tag-mapped reasons also cast a positive CardTagVote through
the same write path as 2/submitTagVote/ (extracted helper — the two entry
points cannot drift), in one transaction. Unseeded tag = report still lands,
vote skipped. Broken image / Other are report-row-only. Rate limit:
CARD_REPORT_RATE (default 10/d) per anonymous_id, polite 429 in the UI.
whatsthat.tsx (ModerationTab.tsx) grows a Moderation tab alongside the
ordinary Question Feed tab, rendered only when whoami says moderator
(presentation; every endpoint below 403s non-moderators server-side via
require_moderator). It has two independent sub-tabs — Reports and
Drives — switched with a plain Tab.Container, the same idiom the
pre-redesign printing/artist/tag tab switcher used.
Report review used to be injected into the single-question feed itself as a
moderator-only "tier 3" (between contested and fresh-unresolved), which meant
any pending report displaced a moderator's ordinary tagging work for as long
as it stayed pending. Reverted — cardpicker/question_feed.py's
get_next_question_feed_item never serves a pending_approval pair any
more, for any role; see that module's docstring for the full history. Report
review now lives only in the Moderation tab, so the two are always
switchable, never one hijacking the other.
POST 2/moderationQueue/ serves pending_approval pairs most-reported first
(count of matching-reason CardReports; oldest first report breaks ties;
organically-pending pairs last), each item: card image + tag + report count +
up to three newest free-text excerpts + Approve / Reject / Skip. Approve
and Reject are ordinary 2/submitTagVote/ calls (polarity +1/−1) sent with
credentials, so the vote records the moderator's user and the pair resolves —
or not — through the normal pass. Pending pairs are excluded from the public
tag queue.
A browse-and-manage view over Source rows, for spotting and removing a bad
or spammy drive (or an individual card within an otherwise-fine one) —
unrelated to report review; nothing here requires a CardReport to exist.
-
POST 2/moderationDrives/lists every Source, newest-first (ordered by-pk—Sourcehas no creation timestamp of its own; one existed briefly in 2021 and was removed in favour of per-Carddates, see migration0004_auto_20210214_1126— pk insertion order is a reliable enough proxy for "recently added" without a new migration), each with its card/cardback/token counts. -
POST 2/moderationDriveCards/drills into one drive's individual cards (paginated) so a specific card can be targeted. -
POST 2/moderationRemoveCard/andPOST 2/moderationRemoveDrive/permanently delete a card or an entire drive (cascading onto every card it contributed viaCard.source'son_delete=CASCADE) — irreversible, no soft-delete, confirmed client-side viawindow.confirmsince there's no undo. Both remove from Elasticsearch first (ELASTICSEARCH_DSL_AUTOSYNC = Falsein settings.py means deletes are never automatic — a card-delete reindexes viaCardSearch().update([card], action="delete"), a drive- delete bulk-removes by the indexedsource_pkfield in onedelete_by_queryrather than one ES call per card) before the Postgres delete; Postgres stays authoritative even if the ES side fails (same swallow-and-log rationale asreindex_card_safelyin documents.py).
Cuts the no-match/review-queue human cost by grouping likely-duplicate review-queue cards so a moderator confirms a whole batch as no-match in one action instead of one-at-a-time. Backend only as of this section - no frontend UI yet (a follow-up task consumes the API contract below).
Population clustered: cards currently carrying the slow-path "to-review" routing marker
(CardScanLog(anonymous_id=local_calculate_verdicts.SLOW_PATH_ANONYMOUS_ID, skip_reason="to-review")) that are still printing_tag_status=UNRESOLVED - a card that
resolves (through this feature or independently) drops out of the next computation.
Clustering signals - CONSERVATIVE, exact-match only (cardpicker/review_clusters.py), per
issue #262's own read-only measurement (2026-07-21, 16,928-card review queue): exact
Card.content_phash union exact ImageEvidence.symbol_phash union exact normalized legal-line
text (lowercase, alphanumeric-only, with a minimum-length + alphanumeric-density guardrail so
short OCR noise like "4" never becomes a grouping signal) - plain union-find over these three
EXACT relations, cross-signal-type transitivity intentional (A-B via content_phash, B-C via
legal text still merges A/B/C). The measurement proved Hamming-distance near-duplicate
clustering fails (single-linkage chaining welded a 1,582-card cluster whose true max pairwise
distance was 32) - this is deliberately not implemented, on record so it isn't re-derived.
Only clusters with >= 2 members are ever surfaced (a singleton carries no shared signal and
isn't a useful batch-confirm target).
Cache/compute: computed on demand, cached whole for 5 minutes (Django's default per-process cache - safe only because this app runs a single gunicorn worker, see that module's own docstring for the full reasoning and the 200k-scale caveat). The confirm action below always bypasses this cache for its own membership check and invalidates it afterwards.
API (moderator-gated, same require_moderator/reject_untrusted_origin stack as the
Drives/Reports endpoints above):
-
POST 2/reviewClusters/- paginated list, sorted by size descending, each item: cluster id (the lowest-card-id member's ownCard.identifier), size, which signal(s) bind it (with the shared value/normalized text), and member summaries (identifier/name/small-thumbnail-url only- never pixel data, same "we index, we do not store images" posture as everywhere else).
-
POST 2/reviewClusterDetail/- single-cluster drill-down, served from the same cache. -
POST 2/confirmReviewCluster/- given a cluster id + the EXACT member identifier list the frontend actually showed the moderator (never re-expanded server-side to whatever the cluster currently contains - any submitted identifier that isn't a current member of a fresh recompute rejects the whole request), casts the acting moderator's own human no-match vote per card through the exact same path2/submitPrintingTag/already uses (CardPrintingTag(is_no_match=True, source=VoteSource.USER)+printing_consensus.resolve_and_persist_printing) - no shortcut, normal consensus rules (a single moderator vote alone does not resolve a card any more than a single crowd vote would;PRINTING_TAG_MIN_VOTESstill applies). Idempotent per (moderator, card) via a deterministicanonymous_id(review-cluster-confirm-<user pk>) rather than a client-generated one, so a retried/repeated confirm replaces that moderator's own prior vote instead of duplicating it. Invalidates the list/detail cache on success and logs the action (logger.info- no dedicated audit-log model exists in this codebase yet, same convention every other moderator write action here already follows).
Already true mechanically (default excludesTags: ["NSFW"]); this stage makes
it visible: a Show Mature Content toggle in search filter settings that
adds/removes the NSFW entry in excludesTags — the same state the tag filter
edits, one source of truth. No other consequences this stage (nothing hides
for low-res / incorrect-info yet).
-
SSL cert must exist before nginx is first started.
nginx.confhardcodesssl_certificate /etc/nginx/certs/origin.pem/origin.keyfor the (single)listen 443server block with no HTTP-only fallback — if those files aren't present atdocker/nginx/certs/when thenginxcontainer starts, nginx fails to load its config and the entire site goes down, not just OAuth. On a brand-new server, provision the Cloudflare origin cert and drop it indocker/nginx/certs/(gitignored, never committed — see "Never commit") before the firstdocker compose up/rebuild ofnginx. Steps 1–7 below assume this is already done. - Discord developer portal: create an application, add redirect URI
https://api.proxyprints.ca/accounts/discord/login/callback/. - Env (
docker/.env):DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,SESSION_COOKIE_SAMESITE=None,SESSION_COOKIE_SECURE=True,CORS_ALLOW_CREDENTIALS=True. Optional:VOTE_PRIVILEGED_WEIGHT,CARD_REPORT_RATE,MODERATORS_GROUP_NAME.docker/.envalone does not inject these into the containers — Compose only passes through vars explicitly listed in a service'senvironment:block. All five are already wired into bothdjangoandworkerindocker-compose.prod.ymlas of 2026-07-15 (found missing during the first live setup — a fresh checkout has this for free now, but a new OAuth/session var still needs a matching line added there). -
pip install -r requirements.txt(addsdjango-allauth[socialaccount]), rebuild/restart django + worker containers (and nginx — see the stale-upstream gotcha in Infrastructure). -
manage.py migrate— cardpicker 0057–0059 (all additive) plus allauth's ownaccount/socialaccountmigrations. (If Django's checks ever demand the sites framework, adddjango.contrib.sites+SITE_ID = 1— not needed with allauth 65.x app-in-settings config.) -
manage.py seed_sensitive_tags(idempotent; without it, reports still land but cast no votes and nothing can go pending). - Django admin: create the
Moderatorsgroup (/admin/auth/group/add/). After each moderator's first Discord login, add their auto-created user to the group. - Live OAuth round-trip test: from proxyprints.ca, click "Moderator login
(Discord)" on the What's That Card? page → authorize → land back on the
page → whoami shows
moderator: true→ the Moderation tab appears → cast one Approve on a test pending pair and verify the card's tags + ES search update; reverse the test votes afterwards (the cast-verify-reverse discipline from Printing-Tags). If this 404s, or Discord rejects the redirect, see "nginx routing and proxy headers for/accounts/" below before re-checking Discord portal config — both live bugs on the first production attempt were server-side plumbing, not Discord config.
Two more gaps found live during the first production OAuth attempt
(2026-07-15), both now baked into the checked-in docker/nginx/nginx.conf
and MPCAutofill/MPCAutofill/settings.py so a fresh checkout gets them automatically —
documented here so the reasoning survives if either file is ever touched:
-
Missing
/accounts/proxy route.nginx.confonly hadlocationblocks for/2/,/3/,/admin/,/static/; every allauth URL (/accounts/discord/login/, the OAuth callback, logout) fell through to the static-filelocation /block and 404'd even withDISCORD_AUTH_ENABLED=Trueserver-side. Fixed by an explicitlocation /accounts/ { proxy_pass http://django-api; }block. -
redirect_uribuilt from the wrong host/scheme. nginx'sproxy_passdefaults theHostheader it forwards to$proxy_host(the upstream container's own name,django-api) rather than the original client'sHost, and Django never learns the original request was HTTPS without being told. Together this made django-allauth constructhttp://django-api/accounts/discord/login/callback/as the OAuthredirect_uri— an internal-only, plain-HTTP URL Discord's registered callback can never match, so Discord silently rejected the login. Fixed withproxy_set_header Host $host;andproxy_set_header X-Forwarded-Proto $scheme;at the nginx server-block level (inherited by everylocationbelow it), paired withSECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")insettings.py— without the Django-side setting,request.is_secure()ignores the forwarded header and still readsFalse. Verified by manually walking the flow withcurl(GET the login page for a CSRF token, POST it, inspect the 302Locationheader) rather than a full browser round-trip.
- Backend:
cardpicker/vote_consensus.py(the gate),cardpicker/tag_consensus.py,cardpicker/moderation.py,cardpicker/security.py,cardpicker/sensitive_tags.py(+ management command),cardpicker/models.py(user FK,TagModerationClass,CardReport,TagVoteStatus.PENDING_APPROVAL),cardpicker/question_feed.py(report review deliberately absent - see "Moderation tab" above),cardpicker/views.py(whoami / reportCard / moderationQueue / moderationDrives / moderationDriveCards / moderationRemoveCard / moderationRemoveDrive / reviewClusters / reviewClusterDetail / confirmReviewCluster),cardpicker/review_clusters.py(issue #262's clustering + cache),accounts/adapter.py,MPCAutofill/MPCAutofill/settings.py. - Frontend:
features/reporting/ReportCardPanel.tsx,features/moderation/AuthWidget.tsx(Discord-branded login button) +ModerationTab.tsx(Reports/Drives sub-tab switcher) +ReportsPanel.tsx-
DrivesPanel.tsx,features/filters/MatureContentFilter.tsx,pages/whatsthat.tsx(Question Feed/Moderation tab switcher, moderator- only),store/api.ts(whoami query + credentialed fetches).
-
- Tests:
cardpicker/tests/test_moderation_gate.py,test_moderation_views.py,test_sensitive_tags.py,test_question_feed.py(asserts pending-approval pairs never surface in the ordinary feed);test_review_clusters.py(clustering unit tests),test_review_cluster_views.py(API auth/pagination/batch-confirm tests);frontend/src/features/reporting/ReportCardPanel.test.tsx;frontend/tests/{ReportCard,ModerationQueue,ModerationTab,MatureContentToggle}.spec.ts.
- Discord guild-role sync for a federation-wide moderator roster (see "Who is a moderator").
- Federation export/import of moderation verdicts is v1.1
- Review-cluster frontend UI (issue #262) - the backend/API above is code-only as of this section; the Moderation tab has no cluster-browsing/batch-confirm surface yet. (Federation-v1) — explicitly out of scope here.
- No consequences yet for resolved
low-res/incorrect-info. - The rate limiter shares the existing single-gunicorn-worker in-process-cache
caveat (documented as a code comment on
post_submit_printing_tag,cardpicker/views.py:860-863— not currently written up in any doc).
Verified, not a gap: live Discord OAuth working end-to-end in production
2026-07-15 (server-side plumbing via curl simulation, then a real
moderator's browser round-trip) — see "nginx routing and proxy headers for
/accounts/" above.
Understanding the system
- Overview
- Documentation-Process
- Theory
- Identification-Pipeline
- Pipeline-Fidelity-Gate
- Federation-v1
- Vote-System
- Readiness-Audit
- License-Provenance
- Upstreaming-Conventions
- Drift-Log
- Upstream-Wiki-Drift
- Printing-Tags
- Catalog-Completion-Plan
- Moderation
- Card-DOM-API
- PDF-Generator
- Print-Export-Page
- Google-Drive-Connect
- Grid-Selector
- Image-CDN
- Local-File-Source
Using it
Operating it
Folded into other pages