fix(ads): stop requesting ads on NSFW-gated pages

Gated pages on civitai.com were still running an auction behind the
"content has moved to civitai.red" card. Measured logged-out: 1 GAM
request for the adhesive unit plus ~1145 prebid/SSP calls, carrying the
gated URL as page_url. That is what lands NSFW URLs in GAM's policy
violation center — ~200 pages are currently flagged and serving no or
low-CPM ads.

Gated already suppressed side_1/side_2/incontent, which live inside its
children. The leak was the adhesive footer: it renders from AppLayout
outside {children}, below AdsProvider in the tree, and so is unreachable
by the gate.

Suppression keys off the content rating, not the gate verdict —
civitai.com serves PG and PG13, so a PG13 page stays monetized even while
an anonymous viewer sees a login gate. isAdGatedContent is also
viewer-independent: one auction from an owner, mod, or crawler is enough
to put a URL in the policy center.

Applied at the AdsProvider context boundary so every ad unit inherits it,
while the Snigel loader and the .red adblock probe keep using the ungated
local — the loader is inert without a slot and drives the CMP handshake
that sets ready. Never applies to .red, which serves direct ads with no
GAM auction.

SSR is the load-bearing half: AdUnitRenderable renders during SSR, so a
gated page ships a reserved-height ad slot that paints before hydration.
Pages declare gating in getServerSideProps; createServerSideProps
resolves it into the adsGated prop _app hands to AdsProvider. useAdGate
covers client-side navigation, where a layout effect lands before paint.

3D models gained useSSG plus a model3d fetch; it previously SSR'd a
loader with no Gated mounted.

Not verifiable locally (isDev forces adsEnabled false) — run
scripts/ad-request-check.mjs against a preview deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
briant
2026-07-27 14:52:52 -06:00
parent 44ebf38c0a
commit 1eea515b2d
15 changed files with 675 additions and 193 deletions
+286
View File
@@ -0,0 +1,286 @@
# Plan: let `Gated` suppress ads on civitai.com
Two workstreams from the ad provider's 2026-07 report of ~200 policy-flagged pages:
1. **Stop gated pages requesting ads** — pages already hidden behind the `.red` gate that
still fire an auction. Implemented; everything up to "Deferred" below.
2. **Audit model names and page content for ad-unsafe text** — pages that are SFW by *our*
rules but still trip GAM's classifier. See "Workstream 2" near the end.
## Problem
Gated NSFW pages on `.com` still make an ad request behind the "This content has a new
home" card. Measured headless + logged out on 2026-07-27:
| | `/models/1972981/sex-nudes-...` (gated) | `/models/1166008` (control) |
| --- | --- | --- |
| GAM ad requests (`gampad/ads`) | **1** | 1 |
| ad units requested | **`adhesive`** | `side_1`, `side_2`, `adhesive` |
| prebid / SSP auction calls | **1145** | 1443 |
| rendered ad iframes | **1** | 0 |
The request carries `page_url` = the gated `.com` URL. That is what puts the URL into GAM's
inventory and therefore into the policy violation center — our provider reports ~200 flagged
pages currently serving no ads or very low CPMs.
## Scope is narrower than it first looks
**`Gated` already does its job for in-page units.** `side_1`, `side_2`, and `incontent_*`
live inside `{children}` ([`Gated.tsx:218`](../src/components/Gated/Gated.tsx#L218) only
renders children for `state === 'page'`), so on a gated page they are never defined as GPT
slots and never auctioned. The control page defines three slots; the gated page defines one.
The sole leak is **`adhesive`**, rendered from
[`AppLayout.tsx:95`](../src/components/AppLayout/AppLayout.tsx#L95) — *outside* `{children}`,
so the gate cannot reach it:
```tsx
{children} // line 85 ← Gated lives in here
...
{footer && <AdhesiveFooter />} // line 95 ← not gated
```
Since a prebid auction only runs for a defined slot, removing the adhesive slot removes both
the GAM request and all 1145 SSP calls.
**We do not need to block `loader.js`.** The Snigel loader, `gpt.js`, and the adengine
bootstrap are inert on their own — they create no inventory without a slot request. Blocking
them would require resolving the gate before `_app` renders (i.e. server-side plumbing
through `getServerSideProps``pageProps`), which is a much larger change for no additional
policy benefit. See "Deferred" below for the one residual it would buy us.
## The rule: content rating, not gate state
The decision is about the **content**, not the viewer.
[`isAdGatedContent`](../src/shared/utils/ad-gating.ts) is the whole of it:
```ts
return !!nsfw || !hasSafeBrowsingLevel(contentNsfwLevel);
```
| Content | Ads |
| --- | --- |
| PG | serve |
| PG13 | serve |
| R / X / XXX | block |
| unrated (0) | block — rating unknown |
**civitai.com serves PG *and* PG13**, so a PG13 page is ad-safe even when an anonymous viewer
sees a login gate instead of the content. An earlier cut of this keyed off the `Gated` verdict
(`state !== 'page'`), which blocked ads on every PG13 page for logged-out users — most of the
site's ad traffic. Don't reintroduce that.
It's viewer-independent for a second reason: one auction is enough to put a URL in GAM's
policy violation center, so an owner, moderator, or crawler must not be able to monetize a URL
that's gated for everyone else. No `bypassRating`, no session, no `verifiedBot`. Server and
client evaluate the identical expression, so they cannot disagree and there's no hydration
mismatch.
## The wiring
**SSR** — pages that render `<Gated>` return a rating alongside their props:
```ts
return { props: { id }, gating: { contentNsfwLevel: model.nsfwLevel, nsfw: model.nsfw } };
```
`createServerSideProps` consumes `gating` (it never reaches Next.js), resolves it, and merges
`adsGated` into props. `_app` reads that and passes `<AdsProvider gated={adsGated}>`. The type
is `AdGatingDeclaration`, wired into the resolver signature so a typo'd key fails typecheck.
This has to be server-side: [`AdUnitRenderable`](../src/components/Ads/AdUnitRenderable.tsx#L14)
renders whenever `adsEnabled` is true, including during SSR, so the reserved-height ad slot
ships in the HTML and paints before hydration. An effect is too late — that's the flash.
**Client navigation**`Gated` calls `useAdGate(isAdGatedContent({ contentNsfwLevel, nsfw }))`.
No SSR HTML to flash there, so a layout effect lands before paint. `createServerSideProps`
skips `ssg` on client-nav data fetches, so the server value is SSR-only by design and these
two mechanisms are complements, not redundancy.
**Where it applies** — one term on the context value, not on the local `adsEnabled`:
```ts
adsEnabled: adsEnabled && !((gated || gateBlocked) && !useDirectAds),
```
Ad units all read `adsEnabled` from context, so this reaches every one of them — including the
adhesive footer that started this, which renders outside the page tree. Meanwhile the
`<Script>` blocks and the adblock probe keep using the ungated local:
- the Snigel loader still mounts on gated pages. It's inert without a slot, and it drives the
CMP handshake that sets `ready` — skipping it when a session starts on a gated page would
delay the first ad on every later page by a full round-trip.
- the `.red` adblock probe still runs. It hits our own ad server for detection rather than
requesting an ad; skipping it would leave `adsBlocked` unresolved for the rest of the
session, breaking the closeable-bar logic and the `SupportUs` fallback.
**Never applies to `.red`.** It serves direct ads through `CivitaiAdUnit`, already
`browsingLevel`-aware, with no GAM auction and no policy center. `!useDirectAds` enforces it
client-side, `!features.canViewNsfw` server-side.
### Client-side slot teardown
Mostly already handled: `AdUnitContent`'s cleanup calls `googletag.destroySlots()` on unmount
([lines 62-71](../src/components/Ads/AdUnitFactory.tsx#L62-L71)), so a unit that stops
rendering tears down its own slot. Verify that the adhesive unit actually unmounts (rather
than just hiding) when `adsEnabled` flips mid-session, and that no stale slot survives a
transition into a gated page.
### Known gap
Images and posts gate on `forcedBrowsingLevel || nsfwLevel`, where the forced level comes from
contest-collection details. That isn't prefetched for logged-out users, so the server and
client agree for the traffic that matters — but an authenticated user on a forced-level
contest collection can still see the old flash.
### Verification
Run `node scripts/ad-request-check.mjs [origin]`.
**This cannot be verified locally.** `adsEnabled` is hard-`false` when `isDev`
([`AdsProvider.tsx:125`](../src/components/Ads/AdsProvider.tsx#L125)), so a dev server never
requests ads at all and would pass the gated check for the wrong reason. Point the script at
a preview or production deploy.
Pass criteria, logged out:
- gated (R+) URL: `gampad/ads` count **0**, prebid/SSP calls **0**, defined GPT slots `[]`
- control URL: unchanged — 1 request, `side_1` + `side_2` + `adhesive` still defined
- **a PG13 URL, logged out: ads still serve.** This is the case most likely to regress
silently, since over-blocking looks like "working" on the gated check alone.
---
## Surfaces
Each declares `gating` in its resolver; `<Gated>` is unchanged apart from the `useAdGate` call.
| Surface | `<Gated>` call site | `gating` declared in |
| --- | --- | --- |
| Models | [`models/[id]/[[...slug]].tsx:824`](../src/pages/models/[id]/[[...slug]].tsx#L824) | same file |
| Images | [`ImageDetail2.tsx:319`](../src/components/Image/DetailV2/ImageDetail2.tsx#L319) | `images/[imageId].tsx` |
| Posts | [`PostDetail.tsx:169`](../src/components/Post/Detail/PostDetail.tsx#L169) | `posts/[postId]/[[...postSlug]].tsx` |
| Articles | [`articles/[id]/[[...slug]].tsx:315`](../src/pages/articles/[id]/[[...slug]].tsx#L315) | same file |
| Bounties | [`bounties/[id]/[[...slug]].tsx:202`](../src/pages/bounties/[id]/[[...slug]].tsx#L202) | same file |
| Challenges | [`challenges/[id]/[[...slug]].tsx:473`](../src/pages/challenges/[id]/[[...slug]].tsx#L473) | same file |
| 3D models | [`3d-models/[id]/[[...slug]].tsx:345`](../src/pages/3d-models/[id]/[[...slug]].tsx#L345) | same file |
| Collections | [`Collection.tsx:557`](../src/components/Collections/Collection.tsx#L557) | `collections/[collectionId]/index.tsx` |
The failure mode to watch is a `gating` declaration drifting from its `<Gated>` props. 3D
models gained `useSSG: true` plus a `model3d.getById` fetch — it previously SSR'd a loader
with no `<Gated>` mounted, shipping an ad slot every time.
---
## Open questions
**@ai:\* Should the gate become a real HTTP redirect instead of a 200 card?**
Everything above treats the `200 OK` + render-swap as fixed. If gated `.com` URLs returned
`301`/`308` to `.red`, the ad problem disappears as a side effect *and* the URLs leave GAM's
and Google's inventory entirely — which is the direct answer to the provider's point that
these URLs "still live on the .com domain."
That's the stronger fix, but it costs the interstitial (no explanation of the split, no
"same account / Buzz carries over" reassurance). Workstream 1 is now small enough that it's
worth shipping regardless — but this decision still stands on its own, since a 301 is the
only thing that gets these URLs out of Google's index as well as GAM's inventory.
---
## Workstream 2: audit model names and page content for ad-unsafe text
Workstream 1 only helps pages the gate already catches. Most of the provider's examples are
**not** gated — they are rated SFW by our rules and still got flagged:
| Flagged URL | Why it tripped |
| --- | --- |
| `/models/1166008/undressing-clothes-over-head` | title only — provider notes the image "might appear normal" |
| `/models/452459/krea2-gpt-grand-pussy-truth-or-mist2` | slug, plus description behind "Show more" |
| `/models/2107271/frontbend` | image classifier read the subject as a minor |
The lesson: **our NSFW rating and GAM ad-safety are different thresholds.** A model can be
correctly rated SFW for Civitai and still be unmonetizable. We currently have no
representation of the second thing.
### What already exists
[`entity-moderation.ts:201-204`](../src/server/jobs/entity-moderation.ts#L201-L204) already
queues and scans `Model.name` and `Model.description` through
[`text-moderation.service.ts`](../src/server/services/text-moderation.service.ts), with
policies managed via the XGuard scanner services. This is a tuning and coverage problem, not
a greenfield build — resist writing a parallel scanner.
### Checklist
#### Define the standard
- [ ] Get the full ~200 flagged URL list out of GAM from the provider — ground truth to
calibrate against, and the regression set for any classifier change
- [ ] Write down the ad-safety standard as distinct from our content policy (Google's
publisher policies for Adult / CSAE are the source, not our TOS)
- [ ] Decide the label vocabulary — at minimum `adUnsafe` as a boolean; better, a reason code
so CSAE-adjacent hits can be routed to human review rather than just demonetized
#### Close coverage gaps
- [ ] `ModelVersion.name` is unscanned — there's a `// TODO possibly add modelVersion` at
[`entity-moderation.ts:180`](../src/server/jobs/entity-moderation.ts#L180). Version
names render on the page and reach the `<title>`
- [ ] Tags and trigger words — both render on the page, neither is in the queue config
- [ ] Description behind "Show more" — confirm we scan the full field, not a truncated
preview (the `krea2` example was specifically flagged on hidden text)
- [ ] Page-level text GAM sees but we don't own: `<title>` composition, OG description,
and on-page comments
- [ ] Slug is derived from `name`, so it's covered transitively — but confirm historical
slugs don't survive a rename (a clean rename with a dirty legacy slug still serves)
#### Backfill
- [ ] One-off sweep over all published models visible on `.com`, scored against the
ad-safety standard — this is the bulk of the ~200 and won't be caught by any
go-forward queue
- [ ] Prioritize by ad impressions, not by model age — a flagged page with no traffic costs
nothing; `side_1` inventory since 2026-07-13 is where the damage is
- [ ] Reconcile results against the provider's list; anything they flagged that we score
clean is a calibration failure worth understanding before shipping
#### Wire the outcome
- [ ] `adUnsafe` should suppress ads on that page — reuse the gate store from Workstream 1
rather than inventing a second suppression path. This is the main architectural payoff
of doing them together
- [ ] Decide separately whether `adUnsafe` also implies `deIndex`. It shouldn't by default —
demonetizing is cheap and reversible, deindexing costs organic traffic
- [ ] Route CSAE-adjacent hits to human moderation queue, never auto-action
- [ ] Re-scan on edit — a model renamed after publish currently keeps its original verdict
#### Open
- [ ] Do we demonetize the page, or fix/rename the model? Renaming breaks inbound links and
is creator-hostile at scale; demonetizing is invisible and reversible. Probably
demonetize by default and reserve renames for the worst offenders — needs a call
- [ ] Is there an appeal path for creators whose model gets demonetized? Relevant if any
creator revenue is tied to page monetization
## Deferred: blocking the loader entirely
Suppressing the slot stops the ad request, but Snigel still registers a **pageview** for the
gated URL (loader + adengine still boot). That doesn't create GAM policy exposure, but it does
dilute our pageview-to-request ratio in Snigel's reporting — plausibly part of why the
provider sees "pages delivering no ads."
The `adsGated` prop already tells `_app` before render, so the loader could be withheld by
moving that term onto the local `adsEnabled`. Deliberately not done — it would cost the CMP
handshake on any session that starts at a gated URL (see "Where it applies"). Revisit only if
the provider says the pageview-to-request ratio matters.
## Out of scope (tracked separately)
- `robots.txt` blocks `/search/*` and `/*?query=`
([`robots.txt/index.tsx:18,61-62`](../src/pages/robots.txt/index.tsx#L18)) while we serve ads
there. Google's ad crawlers ignore the `*` group, so they need explicit `AdsBot-Google` /
`Mediapartners-Google` allow groups — otherwise GAM can't classify those pages and they sit
at a CPM floor.
- The `side_1` sticky skyscraper shipped 2026-07-13 (`41a8a6ace0`), matching the provider's
reported July 14 pageview spike. Not a bug, but it widened coverage across all model pages,
which is why this became visible now.
+75
View File
@@ -0,0 +1,75 @@
/**
* Verifies that gated pages make no ad requests.
*
* A gated page that runs an auction sends its URL to GAM, which lands NSFW URLs in the
* policy violation center even though the gate hides the content. See docs/ads-gating-plan.md.
*
* Usage:
* node scripts/ad-request-check.mjs [origin]
*
* Defaults to https://civitai.com. Ads are disabled when `isDev` is true, so this cannot be
* run against a local dev server — point it at a preview or production deploy.
*/
import { chromium } from 'playwright';
const origin = process.argv[2] ?? 'https://civitai.com';
const targets = [
{ label: 'GATED', path: '/models/1972981/sex-nudes-other-fun-stuff-snofs', expectAds: false },
{ label: 'CONTROL', path: '/models/1166008', expectAds: true },
];
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const browser = await chromium.launch({ headless: true });
let failed = false;
for (const { label, path, expectAds } of targets) {
const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const adRequests = [];
const auctionCalls = [];
page.on('request', (r) => {
const u = r.url();
if (/gampad\/ads|\/pagead\/ads/.test(u)) adRequests.push(u);
else if (/prebid|adnxs|rubiconproject|pubmatic|casalemedia|openx|criteo/i.test(u))
auctionCalls.push(u);
});
const url = `${origin}${path}`;
try {
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
} catch (e) {
console.log(` goto warning: ${e.message.slice(0, 80)}`);
}
// Auctions need the CMP round-trip plus the adSizes effect, and side rails need a scroll.
await page.waitForTimeout(14000);
await page.evaluate(() => window.scrollBy(0, 1200)).catch(() => {});
await page.waitForTimeout(6000);
const slots = await page.evaluate(() => {
try {
return window.googletag?.pubads?.().getSlots?.().map((s) => s.getAdUnitPath()) ?? [];
} catch {
return [];
}
});
const ok = expectAds
? adRequests.length > 0 && slots.length > 0
: adRequests.length === 0 && auctionCalls.length === 0 && slots.length === 0;
if (!ok) failed = true;
console.log(`\n${ok ? 'PASS' : 'FAIL'} ${label} ${url}`);
console.log(` GAM ad requests: ${adRequests.length} auction calls: ${auctionCalls.length}`);
console.log(` defined slots: ${JSON.stringify(slots)}`);
console.log(` expected: ${expectAds ? 'ads served' : 'no ad requests at all'}`);
await ctx.close();
}
await browser.close();
console.log(`\n${failed ? 'FAILED' : 'All checks passed'}`);
process.exit(failed ? 1 : 0);
+26 -2
View File
@@ -7,6 +7,7 @@ import { useThirdPartyConsent } from '~/components/Consent/consent.context';
import { useSignalContext } from '~/components/Signals/SignalsProvider';
import { isDev } from '~/env/other';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect';
import { useBrowsingSettings } from '~/providers/BrowserSettingsProvider';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
@@ -39,6 +40,7 @@ const useAdProviderStore = create<{
adsBlocked?: boolean;
consent: boolean;
browserBlocked: boolean;
gateBlocked: boolean;
}>(() => ({
ready: false,
// Tri-state: undefined = detection pending, false = confirmed serving (loader onLoad / direct-ad
@@ -49,8 +51,18 @@ const useAdProviderStore = create<{
adsBlocked: undefined,
consent: true,
browserBlocked: false,
gateBlocked: false,
}));
/** Covers client-side navigation; SSR uses the `gated` prop, which lands before first paint. */
export function useAdGate(blocked: boolean) {
useIsomorphicLayoutEffect(() => {
if (!blocked) return;
useAdProviderStore.setState({ gateBlocked: true });
return () => useAdProviderStore.setState({ gateBlocked: false });
}, [blocked]);
}
const blockedUrls: string[] = [
'/collections/6503138',
'/collections/7514194',
@@ -58,12 +70,21 @@ const blockedUrls: string[] = [
'/moderator',
];
export function AdsProvider({ children }: { children: React.ReactNode }) {
export function AdsProvider({
children,
gated = false,
}: {
children: React.ReactNode;
/** From `pageProps`. `Gated` mounts below this provider, so waiting for its effect would
* server-render an ad slot and flash it before hydration. */
gated?: boolean;
}) {
const router = useRouter();
const ready = useAdProviderStore((state) => state.ready);
const adsBlocked = useAdProviderStore((state) => state.adsBlocked);
const consent = useAdProviderStore((state) => state.consent);
const browserBlocked = useAdProviderStore((state) => state.browserBlocked);
const gateBlocked = useAdProviderStore((state) => state.gateBlocked);
const currentUser = useCurrentUser();
const features = useFeatureFlags();
const { allowed: consentAllowed } = useThirdPartyConsent();
@@ -150,7 +171,10 @@ export function AdsProvider({ children }: { children: React.ReactNode }) {
ready,
adsBlocked,
consent,
adsEnabled,
// Gated here rather than on the local `adsEnabled` so the Snigel loader still mounts
// (it's inert without a slot, and it drives the CMP handshake that sets `ready`), and
// so the adhesive footer — rendered outside the page tree — is covered too.
adsEnabled: adsEnabled && !((gated || gateBlocked) && !useDirectAds),
useDirectAds,
username: currentUser?.username,
isMember,
+5
View File
@@ -26,7 +26,9 @@ import type { MediaType } from '~/shared/utils/prisma/enums';
import { Meta, type MetaProps } from '~/components/Meta/Meta';
import { PageLoader } from '~/components/PageLoader/PageLoader';
import { requireLogin } from '~/components/Login/requireLogin';
import { useAdGate } from '~/components/Ads/AdsProvider';
import { useAppContext, useServerDomains } from '~/providers/AppProvider';
import { isAdGatedContent } from '~/shared/utils/ad-gating';
import { syncAccount } from '~/utils/sync-account';
import { outerCardStyle } from '~/components/Buzz/CryptoDeposit/crypto-deposit.constants';
@@ -162,6 +164,9 @@ export function Gated<TImage extends { nsfwLevel: number; url: string; type?: Me
const { state, isPaywalled } = useGated({ contentNsfwLevel, nsfw, bypassRating });
const { allowMatureContent } = useAppContext();
// Rating, not `state` — a PG13 page still serves ads while showing a login gate.
useAdGate(isAdGatedContent({ contentNsfwLevel, nsfw }));
// Whether the content is canonically SFW for the purpose of the deindex
// decision. Some entities (e.g. `Model`) carry a coarse `nsfw` boolean
// override — when truthy, force-treat the content as NSFW regardless of
+153 -159
View File
@@ -79,9 +79,7 @@ import { trpc } from '~/utils/trpc';
// Dynamic, ssr-disabled import — three.js needs WebGL which only exists in the browser.
const Model3DVariantViewer = dynamic(
() =>
import('~/components/Model3D/Viewer/Model3DVariantViewer').then(
(m) => m.Model3DVariantViewer
),
import('~/components/Model3D/Viewer/Model3DVariantViewer').then((m) => m.Model3DVariantViewer),
{
ssr: false,
loading: () => (
@@ -103,14 +101,23 @@ const querySchema = z.object({
});
export const getServerSideProps = createServerSideProps({
useSSG: true,
useSession: true,
resolver: async ({ ctx, features }) => {
resolver: async ({ ctx, ssg, features }) => {
// Gate at SSR — avoids a flash of <NotFound /> while FeatureFlagsProvider's
// user-features tRPC query is still in flight on the client.
if (!features?.model3dFeed) return { notFound: true };
const result = querySchema.safeParse(ctx.query);
if (!result.success) return { notFound: true };
return { props: removeEmpty(result.data) };
// Client-fetched previously; without this the page SSRs a loader with no <Gated> mounted,
// shipping an ad slot in the HTML.
const model3d = await ssg?.model3d.getById.fetch({ id: result.data.id }).catch(() => null);
return {
props: removeEmpty(result.data),
gating: model3d ? { contentNsfwLevel: model3d.nsfwLevel ?? 0 } : undefined,
};
},
});
@@ -186,8 +193,7 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
if (primaryFile && !selectedFileKey) setSelectedFileKey(getFileKey(primaryFile));
}, [primaryFile, selectedFileKey]);
const selectedFile =
files.find((f) => getFileKey(f) === selectedFileKey) ?? primaryFile ?? null;
const selectedFile = files.find((f) => getFileKey(f) === selectedFileKey) ?? primaryFile ?? null;
const handleDownload = () => {
if (!selectedFile?.downloadUrl) {
@@ -314,8 +320,7 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
// on walking/running play automatically via the viewer's AnimationMixer.
const viewableVariants: Model3DViewableVariant[] = files
.filter(
(f) =>
f.format.toLowerCase() === 'glb' && !(f.variant ?? 'primary').endsWith('-armature')
(f) => f.format.toLowerCase() === 'glb' && !(f.variant ?? 'primary').endsWith('-armature')
)
.map((f) => ({
key: getFileKey(f),
@@ -568,167 +573,159 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
</Card>
);
const detailsBlock = (hasGenerationData || reviewSummary) ? (
<Accordion
variant="separated"
multiple
defaultValue={['details']}
styles={(t) => ({
content: { padding: 0 },
label: { padding: 0 },
item: {
overflow: 'hidden',
borderColor:
colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[3],
boxShadow: t.shadows.sm,
},
control: {
padding: t.spacing.sm,
gap: t.spacing.md,
},
})}
>
<Accordion.Item value="details">
<Accordion.Control>
<Group justify="space-between">
Details
<Button
size="compact-xs"
variant="light"
leftSection={<IconWand size={12} />}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
openReviewModal();
}}
>
Write a review
</Button>
</Group>
</Accordion.Control>
<Accordion.Panel p={0}>
<Stack
gap={0}
style={{
backgroundColor:
colorScheme === 'dark' ? '#1f2023' : theme.colors.gray[0],
const detailsBlock =
hasGenerationData || reviewSummary ? (
<Accordion
variant="separated"
multiple
defaultValue={['details']}
styles={(t) => ({
content: { padding: 0 },
label: { padding: 0 },
item: {
overflow: 'hidden',
borderColor: colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[3],
boxShadow: t.shadows.sm,
},
control: {
padding: t.spacing.sm,
gap: t.spacing.md,
},
})}
>
<Accordion.Item value="details">
<Accordion.Control>
<Group justify="space-between">
Details
<Button
size="compact-xs"
variant="light"
leftSection={<IconWand size={12} />}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
openReviewModal();
}}
>
{/* Reviews row */}
Write a review
</Button>
</Group>
</Accordion.Control>
<Accordion.Panel p={0}>
<Stack
gap={0}
style={{
backgroundColor:
colorScheme === 'dark' ? '#1f2023' : theme.colors.gray[0],
}}
>
{/* Reviews row */}
<Group
justify="space-between"
px="md"
py={10}
style={{
borderBottom: `1px solid ${
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
}}
>
<Text size="sm" c="dimmed">
Reviews
</Text>
{recommendPct !== null ? (
<Anchor
component={Link}
href={`/3d-models/${id}/reviews`}
underline="hover"
>
<Group gap={6} wrap="nowrap" align="center">
{recommendPct >= 50 ? (
<IconThumbUp size={14} />
) : (
<IconThumbDown size={14} />
)}
<Text size="sm" fw={500}>
{sentimentLabel(recommendPct, ratingCount)}
</Text>
<Badge size="sm" variant="light" color="gray">
{recommendPct}% · {abbreviateNumber(ratingCount)}
</Badge>
</Group>
</Anchor>
) : (
<Anchor component={Link} href={`/3d-models/${id}/reviews`} size="sm">
No reviews yet
</Anchor>
)}
</Group>
{model3d.sourceImage && (
<Group
align="flex-start"
justify="space-between"
px="md"
py={10}
style={{
borderBottom: `1px solid ${
colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
}`,
}}
>
<Text size="sm" c="dimmed">
Reviews
Source image
</Text>
{recommendPct !== null ? (
<Anchor
component={Link}
href={`/3d-models/${id}/reviews`}
underline="hover"
>
<Group gap={6} wrap="nowrap" align="center">
{recommendPct >= 50 ? (
<IconThumbUp size={14} />
) : (
<IconThumbDown size={14} />
)}
<Text size="sm" fw={500}>
{sentimentLabel(recommendPct, ratingCount)}
</Text>
<Badge size="sm" variant="light" color="gray">
{recommendPct}% · {abbreviateNumber(ratingCount)}
</Badge>
</Group>
</Anchor>
) : (
<Anchor
component={Link}
href={`/3d-models/${id}/reviews`}
size="sm"
>
No reviews yet
</Anchor>
)}
<Link
href={`/images/${model3d.sourceImage.id}`}
className="block w-[120px] overflow-hidden rounded-md border border-solid border-dark-4"
>
<EdgeMedia
src={model3d.sourceImage.url}
name={model3d.sourceImage.name ?? undefined}
type={
(model3d.sourceImage.type as
| 'image'
| 'video'
| 'audio'
| undefined) ?? undefined
}
width={240}
anim={false}
className="size-full object-cover"
/>
</Link>
</Group>
)}
{model3d.sourceImage && (
<Group
align="flex-start"
justify="space-between"
px="md"
py={10}
style={{
borderBottom: `1px solid ${
colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
}}
>
<Text size="sm" c="dimmed">
Source image
</Text>
<Link
href={`/images/${model3d.sourceImage.id}`}
className="block w-[120px] overflow-hidden rounded-md border border-solid border-dark-4"
>
<EdgeMedia
src={model3d.sourceImage.url}
name={model3d.sourceImage.name ?? undefined}
type={
(model3d.sourceImage.type as
| 'image'
| 'video'
| 'audio'
| undefined) ?? undefined
}
width={240}
anim={false}
className="size-full object-cover"
/>
</Link>
</Group>
)}
{generationDetailItems.map(([label, value], i) => (
<Group
key={label}
justify="space-between"
px="md"
py={10}
style={{
borderBottom:
i === generationDetailItems.length - 1
? 'none'
: `1px solid ${
colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
}}
>
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" ta="right" style={{ wordBreak: 'break-word' }}>
{value}
</Text>
</Group>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null;
{generationDetailItems.map(([label, value], i) => (
<Group
key={label}
justify="space-between"
px="md"
py={10}
style={{
borderBottom:
i === generationDetailItems.length - 1
? 'none'
: `1px solid ${
colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
}}
>
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" ta="right" style={{ wordBreak: 'break-word' }}>
{value}
</Text>
</Group>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null;
const descriptionBlock = model3d.description ? (
<ContentClamp maxHeight={460}>
@@ -815,16 +812,13 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
</ContainerGrid2>
);
})()}
</Stack>
</Container>
{/* Community gallery — rendered OUTSIDE the size="xl" Container so the
masonry can claim the full page width and pack 67 cards across on
wide screens (matching the model-detail page bottom gallery). */}
<Box id="gallery" mt="md">
<Model3DGallery
model3d={{ id, userId: model3d.userId, minor: model3d.minor }}
/>
<Model3DGallery model3d={{ id, userId: model3d.userId, minor: model3d.minor }} />
</Box>
</Gated>
);
+3 -1
View File
@@ -132,6 +132,7 @@ type CustomAppProps = {
serverDomains: ServerDomains;
availableOAuthProviders: string[];
verifiedBot: VerifiedBot | null;
adsGated?: boolean;
}>;
function MyApp(props: CustomAppProps) {
@@ -160,6 +161,7 @@ function MyApp(props: CustomAppProps) {
serverDomains,
availableOAuthProviders,
verifiedBot = null,
adsGated = false,
...pageProps
},
} = props;
@@ -252,7 +254,7 @@ function MyApp(props: CustomAppProps) {
<ActivityReportingProvider>
<ReferralsProvider {...cookies.referrals}>
<FiltersProvider>
<AdsProvider>
<AdsProvider gated={adsGated}>
<HiddenPreferencesProvider>
<CivitaiLinkProvider>
<BrowserRouterProvider>
+8 -1
View File
@@ -94,6 +94,8 @@ export const getServerSideProps = createServerSideProps({
const result = querySchema.safeParse(ctx.query);
if (!result.success) return { notFound: true };
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
// Redirect old ?imageId= URLs to the clean article URL
if (ctx.query.imageId) {
const slug = result.data.slug?.join('/');
@@ -107,6 +109,11 @@ export const getServerSideProps = createServerSideProps({
// Fetch article to check slug and prefetch for client hydration
const article = await ssg.article.getById.fetch({ id: result.data.id }).catch(() => null);
if (article)
gating = {
contentNsfwLevel: article.nsfwLevel,
};
// Redirect to canonical slug URL if slug is missing or incorrect
if (article) {
const correctSlug = slugit(article.title);
@@ -138,7 +145,7 @@ export const getServerSideProps = createServerSideProps({
await ssg.hiddenPreferences.getHidden.prefetch();
}
return { props: removeEmpty(result.data) };
return { props: removeEmpty(result.data), gating };
},
});
+9 -1
View File
@@ -112,10 +112,18 @@ export const getServerSideProps = createServerSideProps({
const result = querySchema.safeParse(ctx.query);
if (!result.success) return { notFound: true };
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
if (ssg) {
// Fetch bounty to check slug and prefetch for client hydration
const bounty = await ssg.bounty.getById.fetch({ id: result.data.id }).catch(() => null);
if (bounty)
gating = {
contentNsfwLevel: bounty.nsfwLevel,
nsfw: bounty.nsfw,
};
// Redirect to canonical slug URL if slug is missing or incorrect
if (bounty) {
const correctSlug = slugit(bounty.name);
@@ -134,7 +142,7 @@ export const getServerSideProps = createServerSideProps({
await ssg.hiddenPreferences.getHidden.prefetch();
}
return { props: removeEmpty(result.data) };
return { props: removeEmpty(result.data), gating };
},
});
+24 -13
View File
@@ -192,10 +192,18 @@ export const getServerSideProps = createServerSideProps({
const result = querySchema.safeParse(ctx.query);
if (!result.success) return { notFound: true };
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
if (ssg) {
// Fetch challenge to check slug and prefetch for client hydration
const challenge = await ssg.challenge.getById.fetch({ id: result.data.id }).catch(() => null);
if (challenge)
gating = {
contentNsfwLevel: challenge.allowedNsfwLevel | (challenge.coverImage?.nsfwLevel ?? 0),
nsfw: challenge.source === ChallengeSource.User && challenge.buzzType === 'yellow',
};
if (challenge) {
const destination = getCanonicalSlugDestination({
basePath: '/challenges',
@@ -208,7 +216,7 @@ export const getServerSideProps = createServerSideProps({
}
}
return { props: removeEmpty(result.data) };
return { props: removeEmpty(result.data), gating };
},
});
@@ -438,10 +446,7 @@ function ChallengeDetailsPage({ id }: InferGetServerSidePropsType<typeof getServ
// Delete stays available after a moderator voids the challenge (Cancelled) so the owner can clear
// a dead challenge off their list; edit remains Scheduled-only (canManageOwn).
const canDeleteOwn =
features.userChallenges &&
isOwner &&
!currentUser?.isModerator &&
(isScheduled || isCancelled);
features.userChallenges && isOwner && !currentUser?.isModerator && (isScheduled || isCancelled);
const handleOwnerDelete = () => {
openConfirmModal({
@@ -928,15 +933,11 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
const challengeDetails: DescriptionTableProps['items'] = [
{
label: 'Starts',
value: (
<Text size="sm">{formatDate(challenge.startsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>
),
value: <Text size="sm">{formatDate(challenge.startsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>,
},
{
label: 'Ends',
value: (
<Text size="sm">{formatDate(challenge.endsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>
),
value: <Text size="sm">{formatDate(challenge.endsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>,
},
{
label: 'Max Entries',
@@ -1328,7 +1329,12 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
Generate
</Button>
{challenge.collectionId && (
<SubmitEntryButton isOwner={isOwner} onClick={handleOpenSubmitModal} label="Submit" fullWidth />
<SubmitEntryButton
isOwner={isOwner}
onClick={handleOpenSubmitModal}
label="Submit"
fullWidth
/>
)}
</Group>
</div>
@@ -1351,7 +1357,12 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
Generate
</Button>
{challenge.collectionId && (
<SubmitEntryButton isOwner={isOwner} onClick={handleOpenSubmitModal} label="Submit" fullWidth />
<SubmitEntryButton
isOwner={isOwner}
onClick={handleOpenSubmitModal}
label="Submit"
fullWidth
/>
)}
</>
) : challenge.status === ChallengeStatus.Completed ? (
+12 -2
View File
@@ -23,9 +23,11 @@ export const getServerSideProps = createServerSideProps({
if (!features?.collections) return { notFound: true };
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
if (ssg) {
await Promise.all([
ssg.collection.getById.prefetch({ id: collectionId }),
const [data] = await Promise.all([
ssg.collection.getById.fetch({ id: collectionId }).catch(() => null),
...(session
? [
ssg.collection.getAllUser.prefetch({
@@ -35,12 +37,20 @@ export const getServerSideProps = createServerSideProps({
]
: []),
]);
const collection = data?.collection;
if (collection)
gating = {
contentNsfwLevel: collection.metadata?.forcedBrowsingLevel || collection.nsfwLevel,
nsfw: collection.nsfw ?? undefined,
};
}
return {
props: {
collectionId: Number(ctx.query.collectionId),
},
gating,
};
},
});
+18 -8
View File
@@ -15,14 +15,24 @@ export const getServerSideProps = createServerSideProps({
const id = Number(params.imageId);
if (!isNumber(id)) return { notFound: true };
await Promise.all(
[
ssg?.image.get.prefetch({ id }),
ssg?.image.getGenerationData.prefetch({ id }),
session ? ssg?.image.getContestCollectionDetails.prefetch({ id }) : null,
ssg?.hiddenPreferences.getHidden.prefetch(),
].filter(Boolean)
);
const [image] = await Promise.all([
ssg?.image.get.fetch({ id }).catch(() => null),
ssg?.image.getGenerationData.prefetch({ id }),
session ? ssg?.image.getContestCollectionDetails.prefetch({ id }) : null,
ssg?.hiddenPreferences.getHidden.prefetch(),
]);
// ImageDetail2 gates on `forcedBrowsingLevel || nsfwLevel`; the forced level comes from
// contest-collection details, which aren't prefetched for logged-out users, so for the
// traffic that matters the client lands on the same level we do.
return {
props: {},
gating: image
? {
contentNsfwLevel: image.nsfwLevel,
}
: undefined,
};
},
});
+10 -4
View File
@@ -93,10 +93,7 @@ import { ReorderVersionsModal } from '~/components/Modals/ReorderVersionsModal';
import { ToggleLockModel } from '~/components/Model/Actions/ToggleLockModel';
import { ToggleLockModelComments } from '~/components/Model/Actions/ToggleLockModelComments';
import { HowToButton } from '~/components/Model/HowToUseModel/HowToUseModel';
import {
HIDDEN_METRIC_MESSAGE,
HiddenMetricNotice,
} from '~/components/Model/HiddenMetricNotice';
import { HIDDEN_METRIC_MESSAGE, HiddenMetricNotice } from '~/components/Model/HiddenMetricNotice';
import { ModelVersionList } from '~/components/Model/ModelVersionList/ModelVersionList';
import { useModelVersionPermission } from '~/components/Model/ModelVersions/model-version.utils';
import { ModelVersionDetails } from '~/components/Model/ModelVersions/ModelVersionDetails';
@@ -249,6 +246,8 @@ export const getServerSideProps = createServerSideProps({
// }
// }
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
if (ssg) {
// Fetch the model first so we can short-circuit on slug mismatch before
// doing any other prefetch work. Stale links from search results /
@@ -257,6 +256,12 @@ export const getServerSideProps = createServerSideProps({
.fetch({ id, excludeTrainingData: true })
.catch(() => null);
if (model)
gating = {
contentNsfwLevel: model.nsfwLevel,
nsfw: model.nsfw,
};
// Redirect to canonical slug URL if slug is missing or incorrect
if (model) {
const correctSlug = slugit(model.name);
@@ -374,6 +379,7 @@ export const getServerSideProps = createServerSideProps({
return {
props: { id },
gating,
};
},
});
+9 -1
View File
@@ -54,7 +54,15 @@ export const getServerSideProps = createServerSideProps({
await ssg?.post.getContestCollectionDetails.prefetch({ id: postId });
await ssg?.hiddenPreferences.getHidden.prefetch();
return { props: { postId } };
// PostDetail gates on `forcedBrowsingLevel || nsfwLevel`; the forced level comes from
// contest-collection details the client only resolves after hydration.
return {
props: { postId },
gating: {
contentNsfwLevel: post.nsfwLevel,
nsfw: post.nsfw,
},
};
} catch (error) {
console.error('Error fetching post detail:', error);
return { notFound: true };
+16 -1
View File
@@ -9,6 +9,7 @@ import {
} from '~/server/logging/trpc-serialize-log';
import { appRouter } from '~/server/routers';
import { isAdGatedContent } from '~/shared/utils/ad-gating';
import type { FeatureAccess } from '~/server/services/feature-flags.service';
import { getFeatureFlagsAsync } from '~/server/services/feature-flags.service';
import { getServerAuthSession } from '~/server/auth/get-server-auth-session';
@@ -104,9 +105,13 @@ export function createServerSideProps<P>({
? await result.props
: result.props;
// `.red` is exempt — it serves direct ads, no GAM auction, no policy center.
const adsGated = !!result.gating && !features.canViewNsfw && isAdGatedContent(result.gating);
return {
props: {
...(props ?? {}),
adsGated,
// Success-only: an errored prefetch would put a TRPCError instance in the
// dehydrated state, and the devalue write (TRPC_WRITE_DEVALUE) throws on
// non-POJOs — turning one failed prefetch into a page-wide SSR 500. Dropping
@@ -133,10 +138,20 @@ export function createServerSideProps<P>({
};
}
/**
* Content rating declared by pages that render `<Gated>`. Consumed here it never reaches
* Next.js and becomes the `adsGated` prop `_app` hands to `AdsProvider`.
*
* Rating only, no owner/mod/crawler exemptions: the verdict must be identical for every
* request to a URL, since one auction is enough to put it in GAM's policy violation center.
*/
export type AdGatingDeclaration = { contentNsfwLevel: number; nsfw?: boolean };
type GetPropsFnResult<P> = {
props: P | Promise<P>;
redirect: Redirect;
notFound: true;
gating: AdGatingDeclaration;
};
type CreateServerSidePropsProps<P> = {
@@ -148,7 +163,7 @@ type CreateServerSidePropsProps<P> = {
requireModerator?: boolean;
resolver?: (
context: CustomGetServerSidePropsContext
) => Promise<GetServerSidePropsResult<P> | void>;
) => Promise<(GetServerSidePropsResult<P> & { gating?: AdGatingDeclaration }) | void>;
};
type CustomGetServerSidePropsContext = {
+21
View File
@@ -0,0 +1,21 @@
import { hasSafeBrowsingLevel } from '~/shared/constants/browsingLevel.constants';
/**
* Whether ads must be suppressed for this content on civitai.com.
*
* Deliberately not the `Gated` verdict: .com serves PG and PG13, so a PG13 page is ad-safe
* even when the viewer sees a login gate instead of the content. Keying off gate state would
* kill ads on every PG13 page for logged-out users most of the site's ad traffic.
*
* Viewer-independent so the answer is identical for every request to a URL: one auction from
* an owner, mod, or crawler is enough to put the URL in GAM's policy violation center.
*/
export function isAdGatedContent({
contentNsfwLevel,
nsfw,
}: {
contentNsfwLevel: number;
nsfw?: boolean;
}) {
return !!nsfw || !hasSafeBrowsingLevel(contentNsfwLevel);
}