mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env vars (#4035)
* fix(env): read NEXT_PUBLIC_LOG_TRPC from its own variable, drop dead public env
`clientEnv` restates every key by hand because Next.js inlines only the
`process.env.NEXT_PUBLIC_*` references it can see literally. `NEXT_PUBLIC_LOG_TRPC`
named `NEXT_PUBLIC_LOG_TRP`, so it was permanently undefined and the schema default
turned that into `false` with nothing to read.
`NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION` has no consumers anywhere in the
workspace; removed from the schema, the Dockerfile ARG and `.env-example`. Same for
the `NEXT_PUBLIC_MAINTENANCE_MODE` ARG (no such var in either schema) and the
`NEXT_PUBLIC_UI_CATEGORY_VIEWS` / `NEXT_PUBLIC_ADS` lines in `.env-example`.
`NEXT_PUBLIC_IMAGE_LOCATION` keeps its `.default('')` deliberately. Requiring it
would fail builds that legitimately do not pass it, and the dangerous call sites
already refuse a relative URL.
The new test stamps a per-key sentinel into `process.env` and asserts each key read
its own name, so this class of typo fails naming the variable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(env): close the gaps the review found in the client-env guard
Three findings from the adversarial pass, each with the control run rather than
assumed.
The sentinel sweep exempted `NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER` — the one key
resolved from an expression, and so the one most able to carry the typo the guard
exists to catch. Dropping the trailing `R` from the read pinned every user to Stripe
with the suite still green. Replaced the blanket skip with assertions on the value,
plus a check that nothing is exempted which is not in the schema.
Making the `NEXT_PUBLIC_LOG_TRPC` read live changes parse behaviour for anything
already setting it: `z.stringbool()` rejects the empty string, and a failed parse
throws out of `~/env/client` at import. A value that was inert before this branch
would have hard-failed after it, so the schema now catches to `false`.
`NEXT_PUBLIC_BASE_URL` falls back to `NEXTAUTH_URL`, which has no `NEXT_PUBLIC_`
prefix and is therefore never inlined into the client bundle — an environment
setting only that one resolves server-side and is `undefined` in the browser. Left
in place rather than removed, since dropping it changes behaviour beyond this
ticket, but pinned by tests so it is visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(env): treat an empty NEXT_PUBLIC_LOG_TRPC as unset, not as a catch-all
`.catch(false)` swallowed every unparseable value, which is the same plausible-false
failure this branch exists to remove — just moved from the variable name to its
value — and made this the only stringbool in the file that hides a misconfiguration.
Only the empty case needs handling: the read named the wrong variable until this
branch, so a config carrying a valueless key had been inert and would otherwise
start throwing out of `~/env/client`. Anything else unparseable throws, as it does
for every other flag here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,11 +69,8 @@ S3_IMAGE_UPLOAD_OVERRIDE=
|
||||
|
||||
# Client env vars
|
||||
NEXT_PUBLIC_IMAGE_LOCATION=http://localhost:3000
|
||||
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION=https://publicstore.civitai.com/content_detection/model.json
|
||||
NEXT_PUBLIC_CIVITAI_LINK=http://localhost:3000
|
||||
NEXT_PUBLIC_UI_CATEGORY_VIEWS=false
|
||||
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES=false
|
||||
NEXT_PUBLIC_ADS=true
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_HOST=http://localhost:18123
|
||||
|
||||
@@ -31,8 +31,6 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
# inherited too, so they don't need re-running here.
|
||||
FROM deps AS builder
|
||||
ARG NEXT_PUBLIC_IMAGE_LOCATION
|
||||
ARG NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION
|
||||
ARG NEXT_PUBLIC_MAINTENANCE_MODE
|
||||
WORKDIR /app
|
||||
|
||||
# Overlay the full source. node_modules is dockerignored, so this never clobbers the node_modules inherited
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `clientEnv` restates every key by hand because Next.js inlines only the
|
||||
* `process.env.NEXT_PUBLIC_*` references it can see literally. A key that names a
|
||||
* variable other than its own is therefore permanently undefined, and the schema's
|
||||
* default hides it — which is how `NEXT_PUBLIC_LOG_TRPC` spent its life reading
|
||||
* `NEXT_PUBLIC_LOG_TRP`.
|
||||
*/
|
||||
|
||||
// Resolved from an expression rather than from a variable of the same name, so the
|
||||
// sentinel sweep cannot reach it. Each one is asserted on its own below instead.
|
||||
const NOT_A_PASSTHROUGH = ['NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER'];
|
||||
|
||||
async function importWith(vars: Record<string, string | undefined>) {
|
||||
vi.resetModules();
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (value === undefined) vi.stubEnv(key, undefined);
|
||||
else vi.stubEnv(key, value);
|
||||
}
|
||||
return import('../client-schema');
|
||||
}
|
||||
|
||||
describe('env/client-schema', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reads each key from the env var of the same name', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
const keys = Object.keys(clientSchema.shape);
|
||||
expect(keys.length).toBeGreaterThan(10);
|
||||
|
||||
const sentinels = Object.fromEntries(keys.map((key) => [key, `sentinel::${key}`]));
|
||||
const { clientEnv } = await importWith(sentinels);
|
||||
|
||||
for (const key of keys) {
|
||||
if (NOT_A_PASSTHROUGH.includes(key)) continue;
|
||||
expect({ [key]: clientEnv[key as keyof typeof clientEnv] }).toEqual({
|
||||
[key]: `sentinel::${key}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('exempts only keys that genuinely are not passthroughs', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
expect(NOT_A_PASSTHROUGH.filter((key) => !(key in clientSchema.shape))).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares the same keys in the schema and in clientEnv', async () => {
|
||||
const { clientSchema, clientEnv } = await importWith({});
|
||||
expect(Object.keys(clientEnv).sort()).toEqual(Object.keys(clientSchema.shape).sort());
|
||||
});
|
||||
|
||||
it('names every key with the NEXT_PUBLIC_ prefix', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
const keys = Object.keys(clientSchema.shape);
|
||||
expect(keys.filter((key) => !key.startsWith('NEXT_PUBLIC_'))).toEqual([]);
|
||||
});
|
||||
|
||||
describe('NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER', () => {
|
||||
it('is Paddle only when the variable says Paddle', async () => {
|
||||
const { clientEnv } = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: 'Paddle' });
|
||||
expect(clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Paddle');
|
||||
});
|
||||
|
||||
it('falls back to Stripe when unset or unrecognised', async () => {
|
||||
const unset = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: undefined });
|
||||
expect(unset.clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Stripe');
|
||||
|
||||
const junk = await importWith({ NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER: 'Coinbase' });
|
||||
expect(junk.clientEnv.NEXT_PUBLIC_DEFAULT_PAYMENT_PROVIDER).toBe('Stripe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NEXT_PUBLIC_BASE_URL', () => {
|
||||
/**
|
||||
* The `NEXTAUTH_URL` fallback resolves server-side only: Next inlines nothing
|
||||
* without the `NEXT_PUBLIC_` prefix, so an environment setting only `NEXTAUTH_URL`
|
||||
* leaves the browser with `undefined`. Pinned rather than removed — see PR #4035.
|
||||
*/
|
||||
it('prefers its own variable', async () => {
|
||||
const { clientEnv } = await importWith({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://own.test',
|
||||
NEXTAUTH_URL: 'https://fallback.test',
|
||||
});
|
||||
expect(clientEnv.NEXT_PUBLIC_BASE_URL).toBe('https://own.test');
|
||||
});
|
||||
|
||||
it('falls back to the server-only NEXTAUTH_URL when its own is unset', async () => {
|
||||
const { clientEnv } = await importWith({
|
||||
NEXT_PUBLIC_BASE_URL: undefined,
|
||||
NEXTAUTH_URL: 'https://fallback.test',
|
||||
});
|
||||
expect(clientEnv.NEXT_PUBLIC_BASE_URL).toBe('https://fallback.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NEXT_PUBLIC_LOG_TRPC', () => {
|
||||
it('parses a boolean token', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
expect(clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: 'true' }).NEXT_PUBLIC_LOG_TRPC).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty value as unset rather than as a parse error', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
expect(clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: '' }).NEXT_PUBLIC_LOG_TRPC).toBe(false);
|
||||
});
|
||||
|
||||
it('still rejects a value it cannot parse', async () => {
|
||||
const { clientSchema } = await importWith({});
|
||||
expect(() => clientSchema.parse({ NEXT_PUBLIC_LOG_TRPC: 'verbose' })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+9
-4
@@ -8,7 +8,6 @@ import { isProd } from './other';
|
||||
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
export const clientSchema = z.object({
|
||||
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION: z.string().default(''),
|
||||
NEXT_PUBLIC_IMAGE_LOCATION: z.string().default(''),
|
||||
NEXT_PUBLIC_CIVITAI_LINK: isProd ? z.url() : z.url().optional(),
|
||||
NEXT_PUBLIC_GIT_HASH: z.string().optional(),
|
||||
@@ -26,7 +25,14 @@ export const clientSchema = z.object({
|
||||
NEXT_PUBLIC_GPTT_UUID_GREEN: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: z.stringbool().default(true),
|
||||
NEXT_PUBLIC_LOG_TRPC: z.stringbool().default(false),
|
||||
// An empty value reads as unset rather than as a parse error: the read below named
|
||||
// the wrong variable until #4035, so a config carrying a valueless key had been
|
||||
// inert and would otherwise start throwing out of `~/env/client`. Anything else
|
||||
// unparseable still throws, as it does for every other stringbool here.
|
||||
NEXT_PUBLIC_LOG_TRPC: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.stringbool().default(false)
|
||||
),
|
||||
NEXT_PUBLIC_RECAPTCHA_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_CHOPPED_ENDPOINT: z.url().optional(),
|
||||
@@ -75,7 +81,6 @@ export const clientSchema = z.object({
|
||||
* @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}
|
||||
*/
|
||||
export const clientEnv = {
|
||||
NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION: process.env.NEXT_PUBLIC_CONTENT_DECTECTION_LOCATION,
|
||||
NEXT_PUBLIC_IMAGE_LOCATION: process.env.NEXT_PUBLIC_IMAGE_LOCATION,
|
||||
NEXT_PUBLIC_GIT_HASH: process.env.NEXT_PUBLIC_GIT_HASH,
|
||||
NEXT_PUBLIC_CIVITAI_LINK: process.env.NEXT_PUBLIC_CIVITAI_LINK,
|
||||
@@ -93,7 +98,7 @@ export const clientEnv = {
|
||||
NEXT_PUBLIC_GPTT_UUID_GREEN: process.env.NEXT_PUBLIC_GPTT_UUID_GREEN,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL ?? process.env.NEXTAUTH_URL,
|
||||
NEXT_PUBLIC_UI_HOMEPAGE_IMAGES: process.env.NEXT_PUBLIC_UI_HOMEPAGE_IMAGES,
|
||||
NEXT_PUBLIC_LOG_TRPC: process.env.NEXT_PUBLIC_LOG_TRP,
|
||||
NEXT_PUBLIC_LOG_TRPC: process.env.NEXT_PUBLIC_LOG_TRPC,
|
||||
NEXT_PUBLIC_RECAPTCHA_KEY: process.env.NEXT_PUBLIC_RECAPTCHA_KEY,
|
||||
NEXT_PUBLIC_PAYPAL_CLIENT_ID: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID,
|
||||
NEXT_PUBLIC_CHOPPED_ENDPOINT: process.env.NEXT_PUBLIC_CHOPPED_ENDPOINT,
|
||||
|
||||
Reference in New Issue
Block a user