Files
civitai__civitai/.env-example
T
Justin Maier de344a868f feat(hubs): give a shared hub link a preview card (#4439)
* feat(hubs): give a shared hub link a preview card

A hub link sent in a DM produced nothing. Chat unfurls by fetching the URL with
no session and reading its OpenGraph tags, and the hub page gave it neither: an
anonymous fetch of a Public hub returned 404, and the page's <Meta> rendered only
a title, client-side, so it never reached the HTML an unfurler reads.

Three changes:

- `/api/og?type=hub&id=N` renders the same card every other entity uses. No cover
  image: a hub has none of its own, and the first image of its feed is not a safe
  substitute, since this card is served unauthenticated and would show that image
  to everyone the link reaches whatever their own browsing level is.
- The hub page renders its meta from SERVER props, above every early return. The
  hub the body uses arrives through a client query, so nothing read off it is in
  the HTML.
- `hubRouteIsDark` spares a Public hub the `user-hubs` flag, so the route answers
  200 with meta instead of 404. It buys the meta only: the body still needs the
  flag, because the hub and its feed both come through flag-gated tRPC reads.

Only a Public hub resolves a card. A private one and an id that never existed get
the same generic Civitai fallback the endpoint already serves for a missing
entity, so the card cannot be used to read a private hub's name.

Verified against a dev server: card 200 image/png in 2.1s, public hub page 200
carrying og:title, og:description and og:image, private hub page 404, private hub
card the generic fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hubs): make revocation reach the preview card, and test the SSR wiring

Five-lane review of the previous commit.

**Revocation.** A hub's card took the endpoint's 7-day edge cache, so turning
sharing back off left its name, description and owner being served from the CDN
for a week — while the share dialog tells the owner every link they handed out
stops working. Hub is now the first entry in a REVOCABLE_TYPES set that takes a
5-minute cache instead. No other entity type can have its visibility withdrawn
this way.

**Source count.** The card counted every source; `toHubDetail` strips disabled
ones for everyone but the owner, so a hub advertised itself as larger than the
page it opened. Counts enabled sources only.

**Meta.** The success branch preferred the SSR snapshot over the loaded hub, so a
rename or a new description did not show until a full reload — the client copy is
the fresher one there, and the server copy is only the answer in the two early
returns. The description is now stripped and truncated to 150 the way every
sibling page does it, since it is user-authored and goes out to whatever unfurls
the link, and the page now sets `canonical` like its siblings.

**One description reader**, not three. Two of the three were added by the previous
commit, and the two new ones are the pair that publish it off-site.

Tests. A new SSR file covers the seam no other file reached: moving the flag gate
back above the lookup is invisible to every predicate test and silently kills
every hub link preview. Verified by mutation — the gate-order revert and dropping
the props each redden it. Both Prisma selects are now pinned by argument
assertions, because a mocked call ignores `select` and dropping `availability`
would have left the suite green while every public hub 404'd in production.

Also records where the DM unfurl allowlist actually lives, which was a question
asked and answered in review with no artifact left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(hubs): record that Public means fully public

The comment on `hubViewerWhere` said Public meant "anyone holding the link, not
listed", on the reasoning that no discovery surface exists. The preview card makes
that false whatever the UI does: `UserHub.id` is a dense autoincrement and
`/api/og?type=hub&id=N` answers unauthenticated, so every public hub is walkable.

Justin's call, 2026-08-27: Public is fully public. Written down where the next
reviewer will find it, because the file currently argues the opposite and the
obvious "fix" is to add a check that was deliberately not wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(hubs): address a hub by an encoded id, not its row number

`UserHub.id` is a dense autoincrement, and both the hub page and its link-preview
card answer unauthenticated — so every public hub was walkable by counting. Justin
accepted that Public means fully public, but not that it should be trivially
crawlable, and chose encoding over a random key column: one fewer value to store
and one fewer index.

`/hubs/<key>` and `/api/og?type=hub&id=<key>` now take a sqids-encoded id, salted
by HUB_ID_SALT. A bare integer decodes to null on purpose — accepting the old
format back would leave enumeration exactly as open as it was, so pre-encoding
links 404. Cheap now at five public hubs and no external links; expensive later.

🔴 The salt is SERVER-side and has to stay there. As a NEXT_PUBLIC_ var it would
ship in the JS bundle and the encoding would be decorative. So the client never
encodes: `toHubDetail` puts `key` on every hub it returns, `hubUrl` builds the
path from that, and `userHub.getById` is addressed by key so a component holding
only the URL can still resolve the hub. Everything internal stays an int.

This is obfuscation, not authorisation — every read still applies
`hubViewerWhere`, so a decoded id buys nothing a guessed one would not.

Verified against a dev server: /hubs/izMK3WCh 200, /hubs/14 404,
/api/og?type=hub&id=izMK3WCh 200 image/png, the same by int 400, and a model card
still 200 (the og id is now resolved per type). Page carries og:image and
canonical built from the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hubs): make the id encoding actually resist enumeration

Second review round found the encoding did not deliver its one claimed property.
Two independent breaks, both confirmed by measurement.

**An int-addressed sibling handed out the keys.** `follow`/`unfollow` took a raw
`hubId` and were scoped by the same `hubViewerWhere` as the read, while
`getFollowed` returns each hub's `key` — so any signed-in caller could follow
public hub 1..N and read the keys straight back, defeating the encoding without
touching the salt and without brute force. Both verbs are keyed now, and the
refusal is asserted with the lookup never running.

**The salt was worth ~24 bits regardless of its length.** The first permutation
folded the salt into a 32-bit seed and drove Fisher-Yates from an LCG. Measured
over 2M seeds, that reaches ~1.7e7 alphabets at ~530k derivations/sec — all of
them enumerable in under a minute from the alphabet constant, which is committed
in this public repo. It is now a keyed hash: the full salt is the HMAC key and the
digest is the sort rank, so there is no fold, no modulo bias and no hand-rolled
arithmetic. `permuteAlphabet` is exported and takes the salt as an argument,
because the salt is read at module load and the permutation otherwise only ever
runs where no test can see it.

An unset salt in production now throws on the first encode rather than serving a
decorative one. Deliberately not enforced in the env schema: `~/env/server`
validates at import with no build-time escape, so a required var would have to be
present for `next build` in every image and preview pipeline. Nothing encodes a
hub id during a build.

Tests, each with the mutation it catches: golden vectors pinning three exact keys
(a dependency bump silently invalidates every shared link and every property test
stays green); `permuteAlphabet` is a real permutation Sqids will accept, is stable,
uses the whole salt, and is identity only when empty; `getUserHubByKey` refuses
ints and junk without reading anything; `/api/og?type=hub` refuses an int, takes
the 5-minute cache, and still resolves ints for the six older types; and the
ordering assertion now fails in both monotone directions.

Also drops a phantom `key` column from the SSR fixture — `UserHub` has none, the
service computes it, and supplying one let a passthrough mutant stay green — and
routes `getHubCardData` through `hubViewerWhere` instead of hand-writing the
Public check on the one read that publishes off-site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(hubs): pin the SALTED codec, and stop a missing salt 500ing every read

Third review round, on the round that fixed the second.

**The golden vectors could not see the half this branch added.** `HUB_ID_SALT` is
empty under test, so `permuteAlphabet` early-returns and never runs — flipping its
rank comparator changes every production URL and left all three vectors byte for
byte identical, with the file's own comment claiming they pinned it. The permuted
alphabet and three salted keys are now pinned beside them, computed against the
repo's `sqids` version, and the comment says which half each set covers. Verified:
the comparator flip now fails, where before it passed the whole file.

**A missing salt made every hub READ a 500.** `decodeHubId` round-trips through
`encodeHubId`, which carries the production assertion — so an unset var did not
just refuse to mint a key, it threw on `getAll`, `getFollowed`, `getById` and the
route's own `getServerSideProps`, whose contract is a 404. Decode now uses an
unguarded internal encode; only minting asserts. And `/api/og`'s decode moved
inside the handler's `try`, where it was above it and turned the same throw into
an unhandled 500 rather than the 400 the surrounding code is shaped for.

**The invariant in the header comment was false.** It claimed no other procedure
both accepts an int and returns hub data. `image.getInfinite`'s `hubId` does, on a
public rung: counting still reveals whether hub N exists, whether it is Public and
what its feed contains — not its name, owner or key. That surface is NOT closed
here, and the comment now says so rather than implying otherwise. It also records
that `toHubDetail` hands every client `id` beside `key`, so the salt is not the
attacker's only obstacle and this is cheap-enumeration resistance, not a
confidentiality boundary.

`og.hub.test.ts` now spreads the real service module instead of listing one export
— `og.tsx` reaches `user-hub.service` transitively through `image.service`, so the
factory was replacing it for that consumer too — and asserts the whole
`Cache-Control` value, since `toContain('max-age=300')` also matches `s-maxage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:01:23 -06:00

219 lines
7.4 KiB
Plaintext

# Since .env is gitignored, you can use .env-example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly
# The default values for Prisma, Redis, S3, and Email are set to work with the docker-compose setup
# Database
DATABASE_SSL=false
DATABASE_URL=postgresql://postgres:postgres@localhost:15432/civitai
DATABASE_REPLICA_URL=postgresql://postgres:postgres@localhost:15432/civitai
NOTIFICATION_DB_URL=postgresql://postgres:postgres@localhost:15434/postgres
NOTIFICATION_DB_REPLICA_URL=postgresql://postgres:postgres@localhost:15434/postgres
DATAPACKET_DATABASE_RO_URL=postgresql://postgres:postgres@localhost:15435/postgres
# Redis
REDIS_URL=redis://:redis@localhost:6379
REDIS_SYS_URL=redis://:redis@localhost:6378
# Optional: switch the `system` redis client to Sentinel mode. When set, REDIS_SYS_URL
# is still parsed for credentials but the connection is established via Sentinel.
# REDIS_SYS_SENTINELS=localhost:26379,localhost:26380,localhost:26381
# REDIS_SYS_SENTINEL_NAME=sysmaster # production uses "sysmaster"; match the master group your Sentinel CR declares
# Logging
LOGGING=prisma:error,prisma:warn,seed-metrics-search
# Next Auth
NEXTAUTH_SECRET=thisisnotasecret
NEXTAUTH_URL=http://localhost:3000
# Next Auth Discord Provider
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# Next Auth GitHub Provider
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Next Auth Google Provider
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Next Auth Reddit Provider
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
# Integrations
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
DISCORD_WEBHOOK_MOD_ALERTS=
# File uploading
S3_UPLOAD_KEY=REFER_TO_README
S3_UPLOAD_SECRET=REFER_TO_README
S3_UPLOAD_BUCKET=modelshare
S3_UPLOAD_REGION=us-east-1
S3_UPLOAD_ENDPOINT=http://127.0.0.1:9000
# Image uploading
S3_IMAGE_UPLOAD_KEY=
S3_IMAGE_UPLOAD_SECRET=
S3_IMAGE_UPLOAD_BUCKET=images
S3_IMAGE_UPLOAD_REGION=us-east-1
S3_IMAGE_UPLOAD_ENDPOINT=http://127.0.0.1:9000
S3_IMAGE_CACHE_BUCKET=cache
S3_IMAGE_UPLOAD_OVERRIDE=
# Client env vars
NEXT_PUBLIC_IMAGE_LOCATION=http://localhost:3000
NEXT_PUBLIC_CIVITAI_LINK=http://localhost:3000
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES=false
# Clickhouse
CLICKHOUSE_HOST=http://localhost:18123
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_TRACKER_URL=http://localhost:3000
# Email
EMAIL_HOST=localhost
EMAIL_PORT=1025
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=
# Endpoint Protection
JOB_TOKEN=thisisnotatoken
WEBHOOK_TOKEN=thisisnotatoken
# Permutes the alphabet that encodes a hub id into its public URL. Empty is fine
# locally; set it per environment so hub links are not portable between them.
HUB_ID_SALT=
# Base URL of the standalone moderator app (apps/moderator). Migrated /moderator/* routes redirect here
# via the moderator catchall page. Defaults to https://moderator.civitai.com if unset.
MODERATOR_APP_URL=https://moderator.civitai.com
# Site Configuration
UNAUTHENTICATED_DOWNLOAD=true
UNAUTHENTICATED_LIST_NSFW=false
SHOW_SFW_IN_NSFW=false
MAINTENANCE_MODE=false
RATE_LIMITING=true
TRPC_ORIGINS=
# Security
SCANNING_ENDPOINT=http://scan-me.civitai.com/enqueue
SCANNING_TOKEN=thisisnotatoken
# Delivery worker
DELIVERY_WORKER_ENDPOINT=https://delivery-worker.civitai.com/download
DELIVERY_WORKER_TOKEN=thisisnotatoken
# Payments
PADDLE_SECRET_KEY=thisisnotasecret
PADDLE_WEBHOOK_SECRET=thisisnotasecret
NEXT_PUBLIC_PADDLE_TOKEN=thisisnotatoken
NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER=Paddle
# Features
FEATURE_FLAG_EARLY_ACCESS_MODEL=public
# MeiliSearch
SEARCH_HOST=http://localhost:7700
SEARCH_API_KEY=meilisearch
NEXT_PUBLIC_SEARCH_HOST=http://localhost:7700
NEXT_PUBLIC_SEARCH_CLIENT_KEY=meilisearch
METRICS_SEARCH_HOST=http://localhost:7700
METRICS_SEARCH_API_KEY=meilisearch
# Debounce window (ms) for flushing model-metric-affected ids into the model
# search-index update queue. Widening it collapses more of a hot model's repeated
# metric changes into a single reindex (cost: metric/popularity staleness lags by
# up to the window). Fail-soft: a bad value falls back to the 45m default. Requires
# a restart/rollout to take effect. Default 45m.
# SEARCH_INDEX_MODEL_METRIC_FLUSH_INTERVAL_MS=2700000
# Per-call Meilisearch timeout in ms. Calls wrapped via withMeili() fail fast
# with MeiliCallTimeoutError once exceeded, instead of hanging until Traefik's
# 30s router timeout fires.
MEILI_CALL_TIMEOUT_MS=2500
# Per-pod cap on in-flight Meilisearch calls wrapped via withMeili(). Excess
# calls fail fast with MeiliCallTimeoutError instead of queueing forever.
MEILI_CALL_CONCURRENCY=50
# Per-backend circuit breaker. If MEILI_CIRCUIT_TRIP_THRESHOLD wrapped-call
# timeouts accumulate within MEILI_CIRCUIT_WINDOW_SECONDS on a backend, the
# circuit OPENs and all calls fail at 0ms for MEILI_CIRCUIT_COOLDOWN_SECONDS,
# then HALF_OPEN issues a single trial request. healthProbe is excluded.
MEILI_CIRCUIT_TRIP_THRESHOLD=10
MEILI_CIRCUIT_WINDOW_SECONDS=30
MEILI_CIRCUIT_COOLDOWN_SECONDS=30
# BaseURL
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Recaptcha
RECAPTCHA_PROJECT_ID=aSampleKey
NEXT_PUBLIC_RECAPTCHA_KEY=aSampleKey
# CF Turnstile
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITEKEY=1x00000000000000000000BB
CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
NEXT_PUBLIC_CF_INVISIBLE_TURNSTILE_SITEKEY=1x00000000000000000000BB
CF_INVISIBLE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
NEXT_PUBLIC_CF_MANAGED_TURNSTILE_SITEKEY=1x00000000000000000000AA
CF_MANAGED_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
ORCHESTRATOR_ENDPOINT=http://localhost
ORCHESTRATOR_ACCESS_TOKEN=asdf
BUZZ_ENDPOINT=http://localhost
SIGNALS_ENDPOINT=http://localhost
NEXT_PUBLIC_SIGNALS_ENDPOINT=http://localhost
NOW_PAYMENTS_API_URL=http://localhost
NOW_PAYMENTS_API_KEY=key
NOW_PAYMENTS_IPN_KEY=key
COINBASE_API_URL=http://localhost
COINBASE_API_KEY=key
COINBASE_WEBHOOK_SECRET=secret
EMERCHANTPAY_WPF_URL=
EMERCHANTPAY_USERNAME=
EMERCHANTPAY_PASSWORD=
# Shopify merch store — Blue Buzz reward loop
# SHOPIFY_SHOP_DOMAIN = the *.myshopify.com admin domain (e.g. ff1592-5.myshopify.com)
# ClickUp -> Known Issues board sync (signing secret returned when the webhook is created)
CLICKUP_WEBHOOK_SECRET=
SHOPIFY_SHOP_DOMAIN=
SHOPIFY_WEBHOOK_SECRET=
# Admin auth: client_credentials grant (preferred). Set ADMIN_TOKEN instead only for a static token.
SHOPIFY_CLIENT_ID=
SHOPIFY_CLIENT_SECRET=
SHOPIFY_ADMIN_TOKEN=
FLIPT_URL=""
FLIPT_FETCHER_SECRET=placeholder
IMAGE_SCANNER_NEW=false
# App Blocks — per-app generation spend/velocity ABSOLUTE CEILINGS (incident knobs).
# 🔴 These are UPPER BOUNDS, not the limit an app receives. Each app's actual
# ceilings come from its server-owned `spendTier` (+ any moderator per-app
# override); these clamp the tier table AND any override from above, so setting
# one TIGHTENS every app and can never loosen one. Unset = no extra clamp.
# Formerly BLOCK_APP_SPEND_CAP_BUZZ_PER_DAY / BLOCK_APP_SPEND_VELOCITY_MAX_GENS —
# those names are DEPRECATED but still honoured (with a startup warning).
# BLOCK_APP_SPEND_ABSOLUTE_MAX_BUZZ_PER_DAY=
# BLOCK_APP_SPEND_ABSOLUTE_MAX_GENS_PER_WINDOW=
# Window (seconds) the gens-per-window ceiling is measured over. Default 60.
# BLOCK_APP_SPEND_VELOCITY_WINDOW_SECONDS=