mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
de344a868f
* 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>
219 lines
7.4 KiB
Plaintext
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=
|