no restriction. */ public readonly EmailAllowlist $emailAllowlist, /** Base URL of the frontend, used to build magic links. */ public readonly string $appUrl, /** WebAuthn relying party ID -- the domain a passkey is bound to. */ public readonly string $webauthnRpId, /** WebAuthn relying party display name, shown by the browser/OS passkey prompt. */ public readonly string $webauthnRpName, /** Minimum gap between magic links sent to the same address. */ public readonly int $resendIntervalSeconds, /** Maximum number of projects a single owner may create. 0 means unlimited. */ public readonly int $maxProjectsPerOwner, public readonly MailConfig $mail, ) { } public static function load(string $basePath): self { if (is_file($basePath . '/.env')) { \Dotenv\Dotenv::createImmutable($basePath)->safeLoad(); } $storagePath = self::env('STORAGE_PATH', $basePath . '/storage'); if (!is_dir($storagePath)) { mkdir($storagePath, 0775, true); } $databasePath = self::env('DATABASE_PATH', $storagePath . '/database.sqlite'); if (!self::isAbsolutePath($databasePath)) { $databasePath = $basePath . '/' . ltrim($databasePath, '/'); } $jwtSecret = self::env('JWT_SECRET') ?? self::resolveSecret($storagePath . '/secret.key'); $jwtTtl = (int) (self::env('JWT_TTL') ?? '86400'); $displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL); $allowRegistration = filter_var(self::env('APP_ALLOW_REGISTRATION', 'true'), FILTER_VALIDATE_BOOL); $emailAllowlist = EmailAllowlist::fromString(self::env('APP_EMAIL_ALLOWLIST')); $appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/'); // A passkey is bound to a domain (the "relying party ID"), never a full // origin -- defaults to the frontend's host. WebAuthn requires this to // be `localhost` or a real domain served over HTTPS; a LAN IP won't work. $webauthnRpId = self::env('WEBAUTHN_RP_ID') ?? (parse_url($appUrl, PHP_URL_HOST) ?: 'localhost'); $webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects'); $resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60'); $maxProjectsPerOwner = (int) (self::env('MAX_PROJECTS_PER_OWNER') ?? '0'); $mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log'); if (!self::isAbsolutePath($mailLogPath)) { $mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/'); } $mail = new MailConfig( transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')), fromAddress: self::env('MAIL_FROM', 'no-reply@todo.test'), fromName: self::env('MAIL_FROM_NAME', 'Projects'), logPath: $mailLogPath, smtpHost: self::env('MAIL_SMTP_HOST'), smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'), smtpUsername: self::env('MAIL_SMTP_USERNAME'), smtpPassword: self::env('MAIL_SMTP_PASSWORD'), smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')), ); return new self( $databasePath, $jwtSecret, $jwtTtl, $displayErrors, $allowRegistration, $emailAllowlist, $appUrl, $webauthnRpId, $webauthnRpName, $resendIntervalSeconds, $maxProjectsPerOwner, $mail, ); } private static function env(string $key, ?string $default = null): ?string { $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); if ($value === false || $value === null || $value === '') { return $default; } return (string) $value; } private static function isAbsolutePath(string $path): bool { return str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[\\\\/]#', $path) === 1; } /** * Return the persisted signing secret, generating and storing one on first run * so local development works with zero configuration. */ private static function resolveSecret(string $path): string { if (is_file($path)) { return trim((string) file_get_contents($path)); } $secret = bin2hex(random_bytes(32)); file_put_contents($path, $secret); @chmod($path, 0600); return $secret; } }