Files
neta/server/config.ts
T

165 lines
4.7 KiB
TypeScript

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 });
}
}