import "server-only"; /** * Zentraler, geprüfter Zugriff auf die Umgebungsvariablen. * * Absicht: Tippfehler und fehlende Pflichtwerte fallen beim Start auf, nicht * erst irgendwo tief in einem Request. Optionale Bausteine (TMDB, SMTP, OIDC) * melden sich stattdessen über `isConfigured`-Flags ab. */ function required(name: string, fallback?: string): string { const value = process.env[name] ?? fallback; if (!value) { throw new Error( `Umgebungsvariable ${name} fehlt. Bitte in der .env setzen — siehe .env.example.`, ); } return value; } function optional(name: string, fallback = ""): string { return process.env[name]?.trim() ?? fallback; } function bool(name: string, fallback: boolean): boolean { const raw = process.env[name]?.trim().toLowerCase(); if (raw === undefined || raw === "") return fallback; return raw === "1" || raw === "true" || raw === "yes" || raw === "ja"; } function int(name: string, fallback: number): number { const raw = Number.parseInt(process.env[name] ?? "", 10); return Number.isFinite(raw) ? raw : fallback; } const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; export const env = { appName: optional("APP_NAME", "Flimmer"), appUrl: optional("APP_URL", "http://localhost:3000").replace(/\/+$/, ""), databaseUrl: isBuildPhase ? optional("DATABASE_URL", "postgresql://build:build@localhost:5432/build") : required("DATABASE_URL"), authSecret: isBuildPhase ? optional("AUTH_SECRET", "build-time-placeholder") : required("AUTH_SECRET"), dataDir: optional("DATA_DIR", "./data"), timezone: optional("TZ", "Europe/Berlin"), allowRegistrationDefault: bool("ALLOW_REGISTRATION", true), tmdb: { apiKey: optional("TMDB_API_KEY"), /** * Abweichende Adresse der TMDB-API. Gedacht für Instanzen hinter einem * eigenen Proxy und für den Testbetrieb; leer lassen heißt: direkt zu TMDB. */ baseUrl: optional("TMDB_BASE_URL", "https://api.themoviedb.org/3").replace(/\/+$/, ""), imageBaseUrl: optional("TMDB_IMAGE_BASE_URL", "https://image.tmdb.org/t/p").replace(/\/+$/, ""), accessToken: optional("TMDB_ACCESS_TOKEN"), language: optional("TMDB_LANGUAGE", "de-DE"), region: optional("TMDB_REGION", "DE"), get isConfigured() { return Boolean(this.apiKey || this.accessToken); }, }, scheduler: { enabled: bool("ENABLE_SCHEDULER", false), hour: Math.min(23, Math.max(0, int("SYNC_HOUR", 4))), cronSecret: optional("CRON_SECRET"), }, smtp: { host: optional("SMTP_HOST"), port: int("SMTP_PORT", 587), user: optional("SMTP_USER"), password: optional("SMTP_PASSWORD"), secure: bool("SMTP_SECURE", false), from: optional("SMTP_FROM", "Flimmer "), get isConfigured() { return Boolean(this.host); }, }, passkeys: { /** * Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie * kosten nichts, solange niemand einen anlegt. */ enabled: bool("ENABLE_PASSKEYS", true), /** * Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt * aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse * Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären * plötzlich ungültig. */ get rpId(): string { try { return new URL(env.appUrl).hostname; } catch { return "localhost"; } }, /** * Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und * localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig. */ get isAvailable(): boolean { if (!this.enabled) return false; try { const url = new URL(env.appUrl); if (url.protocol === "https:") return true; return url.hostname === "localhost" || url.hostname.endsWith(".localhost"); } catch { return false; } }, }, oidc: { issuer: optional("OIDC_ISSUER"), clientId: optional("OIDC_CLIENT_ID"), clientSecret: optional("OIDC_CLIENT_SECRET"), scopes: optional("OIDC_SCOPES", "openid profile email"), buttonLabel: optional("OIDC_BUTTON_LABEL", "Mit SSO anmelden"), autoRegister: bool("OIDC_AUTO_REGISTER", true), get isConfigured() { return Boolean(this.issuer && this.clientId && this.clientSecret); }, }, } as const; export const isProduction = process.env.NODE_ENV === "production";