2026-09-03 17:35:10 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Support;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Immutable application configuration, resolved from environment variables with
|
|
|
|
|
* development-friendly defaults.
|
|
|
|
|
*/
|
|
|
|
|
final class Config
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
public readonly string $databasePath,
|
|
|
|
|
public readonly string $jwtSecret,
|
|
|
|
|
public readonly int $jwtTtl,
|
|
|
|
|
public readonly bool $displayErrors,
|
2026-09-04 18:19:48 +01:00
|
|
|
/** When false, POST /auth/magic-link only signs existing users in -- it never creates a new account. */
|
|
|
|
|
public readonly bool $allowRegistration,
|
2026-09-03 20:04:49 +01:00
|
|
|
/** Base URL of the frontend, used to build magic links. */
|
|
|
|
|
public readonly string $appUrl,
|
2026-09-04 19:34:53 +01:00
|
|
|
/** 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,
|
2026-09-04 21:30:41 +01:00
|
|
|
/** Minimum gap between magic links sent to the same address. */
|
|
|
|
|
public readonly int $resendIntervalSeconds,
|
2026-09-05 09:15:06 +01:00
|
|
|
/** Maximum number of projects a single owner may create. 0 means unlimited. */
|
|
|
|
|
public readonly int $maxProjectsPerOwner,
|
2026-09-03 20:04:49 +01:00
|
|
|
public readonly MailConfig $mail,
|
2026-09-03 17:35:10 +01:00
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function load(string $basePath): self
|
|
|
|
|
{
|
|
|
|
|
if (is_file($basePath . '/.env')) {
|
|
|
|
|
\Dotenv\Dotenv::createImmutable($basePath)->safeLoad();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 17:51:56 +01:00
|
|
|
$storagePath = self::env('STORAGE_PATH', $basePath . '/storage');
|
2026-09-03 17:35:10 +01:00
|
|
|
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);
|
2026-09-04 18:19:48 +01:00
|
|
|
$allowRegistration = filter_var(self::env('APP_ALLOW_REGISTRATION', 'true'), FILTER_VALIDATE_BOOL);
|
2026-09-03 17:35:10 +01:00
|
|
|
|
2026-09-03 20:04:49 +01:00
|
|
|
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
|
|
|
|
|
|
2026-09-04 19:34:53 +01:00
|
|
|
// 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');
|
|
|
|
|
|
2026-09-04 21:30:41 +01:00
|
|
|
$resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60');
|
2026-09-05 09:15:06 +01:00
|
|
|
$maxProjectsPerOwner = (int) (self::env('MAX_PROJECTS_PER_OWNER') ?? '0');
|
2026-09-04 21:30:41 +01:00
|
|
|
|
2026-09-03 20:04:49 +01:00
|
|
|
$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')),
|
2026-09-04 08:40:36 +01:00
|
|
|
fromAddress: self::env('MAIL_FROM', 'no-reply@todo.test'),
|
2026-09-04 11:28:59 +01:00
|
|
|
fromName: self::env('MAIL_FROM_NAME', 'Projects'),
|
2026-09-03 20:04:49 +01:00
|
|
|
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')),
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-04 19:34:53 +01:00
|
|
|
return new self(
|
|
|
|
|
$databasePath,
|
|
|
|
|
$jwtSecret,
|
|
|
|
|
$jwtTtl,
|
|
|
|
|
$displayErrors,
|
|
|
|
|
$allowRegistration,
|
|
|
|
|
$appUrl,
|
|
|
|
|
$webauthnRpId,
|
|
|
|
|
$webauthnRpName,
|
2026-09-04 21:30:41 +01:00
|
|
|
$resendIntervalSeconds,
|
2026-09-05 09:15:06 +01:00
|
|
|
$maxProjectsPerOwner,
|
2026-09-04 19:34:53 +01:00
|
|
|
$mail,
|
|
|
|
|
);
|
2026-09-03 17:35:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|