import "server-only"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), APP_URL: z.string().trim().optional(), NEXT_PUBLIC_SITE_URL: z.string().trim().optional(), BETTER_AUTH_URL: z.string().trim().optional(), BETTER_AUTH_SECRET: z.string().trim().optional(), TRUSTED_ORIGINS: z.string().trim().optional(), DATA_DIR: z.string().trim().optional(), DATABASE_PATH: z.string().trim().optional(), OLLAMA_BASE_URL: z.string().url().optional(), AI_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).optional(), NETA_MINIMUM_MOBILE_VERSION: z .string() .trim() .regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/) .optional(), }); export type ServerConfig = { nodeEnv: "development" | "test" | "production"; dataDir: string; databasePath: string; uploadsDir: string; backupsDir: string; tmpDir: string; appUrl: string; trustedOrigins: string[]; secureCookies: boolean; betterAuthSecret?: string; ollamaBaseUrl: string; aiRequestTimeoutMs: number; minimumMobileClientVersion: string | null; }; let cachedConfig: ServerConfig | undefined; export function getServerConfig(): ServerConfig { if (cachedConfig) { return cachedConfig; } const parsed = envSchema.parse(process.env); const isProductionBuild = process.env.NEXT_PHASE === "phase-production-build"; const defaultDataDir = isProductionBuild ? path.join(os.tmpdir(), `neta-production-build-${process.pid}`) : parsed.NODE_ENV === "production" ? "/app/data" : path.join(process.cwd(), ".data"); const dataDir = path.resolve( parsed.DATA_DIR && parsed.DATA_DIR.length > 0 ? parsed.DATA_DIR : defaultDataDir, ); const databasePath = path.resolve( parsed.DATABASE_PATH && parsed.DATABASE_PATH.length > 0 ? parsed.DATABASE_PATH : path.join(dataDir, "neta.db"), ); const appUrl = normalizeOrigin( parsed.BETTER_AUTH_URL || parsed.APP_URL || parsed.NEXT_PUBLIC_SITE_URL || "http://localhost:3000", ); const secureCookies = validateAppUrlSecurity(appUrl, parsed.NODE_ENV); const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl); const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV); cachedConfig = { nodeEnv: parsed.NODE_ENV, dataDir, databasePath, uploadsDir: path.join(dataDir, "uploads"), backupsDir: path.join(dataDir, "backups"), tmpDir: path.join(dataDir, "tmp"), appUrl, trustedOrigins, secureCookies, betterAuthSecret, ollamaBaseUrl: parsed.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1", aiRequestTimeoutMs: parsed.AI_REQUEST_TIMEOUT_MS ?? 30_000, minimumMobileClientVersion: parsed.NETA_MINIMUM_MOBILE_VERSION ?? null, }; return cachedConfig; } function normalizeOrigin(value: string): string { const url = new URL(value); return url.origin; } function normalizeTrustedOrigins(value: string | undefined, appUrl: string): string[] { const origins = new Set([appUrl]); for (const rawOrigin of value?.split(",") ?? []) { const origin = rawOrigin.trim(); if (!origin) { continue; } if (origin.includes("*")) { throw new Error("TRUSTED_ORIGINS wildcard icermemelidir."); } origins.add(normalizeOrigin(origin)); } return [...origins]; } function validateAppUrlSecurity( appUrl: string, nodeEnv: ServerConfig["nodeEnv"], ): boolean { const url = new URL(appUrl); const isHttps = url.protocol === "https:"; const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); if (nodeEnv === "production" && !isHttps && !isLoopback) { throw new Error("Production APP_URL HTTPS kullanmalidir; HTTP yalnizca localhost icin desteklenir."); } return isHttps; } function normalizeAuthSecret( value: string | undefined, nodeEnv: ServerConfig["nodeEnv"], ): string | undefined { if (value && value.length < 32) { throw new Error("BETTER_AUTH_SECRET en az 32 karakter olmalidir."); } if (value) { return value; } if (nodeEnv === "production" && process.env.NEXT_PHASE !== "phase-production-build") { throw new Error("BETTER_AUTH_SECRET production runtime icin zorunludur."); } if (process.env.NEXT_PHASE === "phase-production-build") { return "build-time-placeholder-do-not-use-at-runtime"; } return undefined; } export function ensureDataDirectories(config = getServerConfig()): void { for (const dir of [config.dataDir, config.uploadsDir, config.backupsDir, config.tmpDir]) { fs.mkdirSync(dir, { recursive: true }); } }