From b55769516e047470809097f67652b7ba50f43bd9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:14:47 +0300 Subject: [PATCH 1/7] test initial admin --- .../User/UseCases/EnsureInitialAdminTest.php | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/EnsureInitialAdminTest.php diff --git a/backend/tests/Unit/User/UseCases/EnsureInitialAdminTest.php b/backend/tests/Unit/User/UseCases/EnsureInitialAdminTest.php new file mode 100644 index 0000000..f1d624c --- /dev/null +++ b/backend/tests/Unit/User/UseCases/EnsureInitialAdminTest.php @@ -0,0 +1,78 @@ +userRepository = new FakeUserRepository(); + $this->passwordHasher = new FakeHasher(); + $this->ensureInitialAdmin = new EnsureInitialAdmin( + $this->userRepository, + $this->passwordHasher, + ); + } + + public function testCreatesInitialAdminWhenEmailIsMissing(): void + { + $this->ensureInitialAdmin->execute( + new EnsureInitialAdminRequest( + email: 'admin@example.com', + password: 'secret-password', + ) + ); + + $createdUser = $this->userRepository->findByEmail( + new EmailAddress('admin@example.com') + ); + + $this->assertNotNull($createdUser); + $this->assertSame( + 'hashed-secret-password', + $createdUser->getPasswordHash(), + ); + } + + public function testLeavesExistingAdminPasswordUnchanged(): void + { + $this->userRepository->create( + new CreateUserDto( + email: new EmailAddress('admin@example.com'), + passwordHash: 'existing-password-hash', + ) + ); + + $this->ensureInitialAdmin->execute( + new EnsureInitialAdminRequest( + email: 'admin@example.com', + password: 'new-secret-password', + ) + ); + + $existingUser = $this->userRepository->findByEmail( + new EmailAddress('admin@example.com') + ); + + $this->assertNotNull($existingUser); + $this->assertSame( + 'existing-password-hash', + $existingUser->getPasswordHash(), + ); + } +} From 0d2c44da13f6fda71afc537f16b04d43747ca363 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:15:30 +0300 Subject: [PATCH 2/7] add initial admin --- .../EnsureInitialAdmin/EnsureInitialAdmin.php | 31 +++++++++++++++++++ .../EnsureInitialAdminRequest.php | 12 +++++++ 2 files changed, 43 insertions(+) create mode 100644 backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdmin.php create mode 100644 backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdminRequest.php diff --git a/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdmin.php b/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdmin.php new file mode 100644 index 0000000..1044232 --- /dev/null +++ b/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdmin.php @@ -0,0 +1,31 @@ +email); + $existingUser = $this->userRepository->findByEmail($email); + if ($existingUser !== null) { + return; + } + + $this->userRepository->create(new CreateUserDto( + email: $email, + passwordHash: $this->passwordHasher->hash($request->password), + )); + } +} diff --git a/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdminRequest.php b/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdminRequest.php new file mode 100644 index 0000000..47ff5db --- /dev/null +++ b/backend/app/User/UseCases/EnsureInitialAdmin/EnsureInitialAdminRequest.php @@ -0,0 +1,12 @@ + Date: Thu, 2 Jul 2026 01:16:18 +0300 Subject: [PATCH 3/7] test auth cookies --- .../Unit/Controllers/AuthControllerTest.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/backend/tests/Unit/Controllers/AuthControllerTest.php b/backend/tests/Unit/Controllers/AuthControllerTest.php index 29ce7de..4104b53 100644 --- a/backend/tests/Unit/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Controllers/AuthControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Controllers; +use App\Auth\AuthCookieSettings; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\CreateSession\CreateSession; use App\Auth\UseCases\Logout\Logout; @@ -60,6 +61,11 @@ class AuthControllerTest extends TestCase $authenticateUser, $createSession, $logout, + new AuthCookieSettings( + secure: false, + domain: null, + sameSite: 'lax', + ), ); } @@ -104,6 +110,32 @@ class AuthControllerTest extends TestCase ); } + public function testLoginUsesConfiguredCookieSettings(): void + { + $this->controller = $this->controllerWithCookieSettings( + new AuthCookieSettings( + secure: true, + domain: '.example.com', + sameSite: 'none', + ) + ); + $this->seedStartupUser('user@example.com', 'password'); + + $request = new Request([ + 'email' => 'user@example.com', + 'password' => 'password', + ]); + $response = $this->controller->login($request); + + $cookies = $response->headers->getCookies(); + $this->assertCount(1, $cookies); + $cookie = $cookies[0]; + + $this->assertTrue($cookie->isSecure()); + $this->assertSame('.example.com', $cookie->getDomain()); + $this->assertSame('none', $cookie->getSameSite()); + } + public function testLoginReturns400WhenEmailMissing(): void { $request = new Request(['password' => 'correctpassword']); @@ -160,6 +192,38 @@ class AuthControllerTest extends TestCase $this->assertSame('', $cookies[0]->getValue()); } + public function testLogoutUsesConfiguredCookieSettings(): void + { + $this->controller = $this->controllerWithCookieSettings( + new AuthCookieSettings( + secure: true, + domain: '.example.com', + sameSite: 'none', + ) + ); + $this->seedStartupUser('user@example.com', 'correctpassword'); + $loginRequest = new Request([ + 'email' => 'user@example.com', + 'password' => 'correctpassword', + ]); + $this->controller->login($loginRequest); + + $logoutRequest = new Request(); + $logoutRequest->cookies->set( + AuthMiddleware::COOKIE_NAME, + 'session-token-1' + ); + $response = $this->controller->logout($logoutRequest); + + $cookies = $response->headers->getCookies(); + $this->assertCount(1, $cookies); + $cookie = $cookies[0]; + + $this->assertTrue($cookie->isSecure()); + $this->assertSame('.example.com', $cookie->getDomain()); + $this->assertSame('none', $cookie->getSameSite()); + } + public function testMeReturns200WithUserWhenAuthenticated(): void { $email = 'me@example.com'; @@ -180,4 +244,26 @@ class AuthControllerTest extends TestCase $this->assertSame($user->getId(), $body['user']['id']); $this->assertSame($email, $body['user']['email']); } + + private function controllerWithCookieSettings( + AuthCookieSettings $cookieSettings, + ): AuthController { + $authenticateUser = new AuthenticateUser( + $this->userRepo, + $this->hasher, + ); + $createSession = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + $logout = new Logout($this->sessionRepo); + + return new AuthController( + $authenticateUser, + $createSession, + $logout, + $cookieSettings, + ); + } } From 4ee20396e81664f46eb1bef7e1eb7a557d69727d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:17:13 +0300 Subject: [PATCH 4/7] configure auth cookies --- backend/app/Auth/AuthCookieSettings.php | 28 +++++++++++++ backend/app/Controllers/AuthController.php | 14 ++++--- backend/app/Providers/AppServiceProvider.php | 41 ++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 backend/app/Auth/AuthCookieSettings.php diff --git a/backend/app/Auth/AuthCookieSettings.php b/backend/app/Auth/AuthCookieSettings.php new file mode 100644 index 0000000..a68a1c9 --- /dev/null +++ b/backend/app/Auth/AuthCookieSettings.php @@ -0,0 +1,28 @@ +secure; + } + + public function getDomain(): ?string + { + return $this->domain; + } + + public function getSameSite(): string + { + return $this->sameSite; + } +} diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index 43c9964..36eb532 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\Auth\AuthCookieSettings; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest; use App\Auth\UseCases\CreateSession\CreateSession; @@ -20,6 +21,7 @@ class AuthController private AuthenticateUser $authenticateUser, private CreateSession $createSession, private Logout $logout, + private AuthCookieSettings $cookieSettings, ) { } @@ -55,11 +57,11 @@ class AuthController value: $session->getToken(), expire: $session->getExpiresAt()->getTimestamp(), path: '/', - domain: null, - secure: false, + domain: $this->cookieSettings->getDomain(), + secure: $this->cookieSettings->isSecure(), httpOnly: true, raw: false, - sameSite: Cookie::SAMESITE_LAX, + sameSite: $this->cookieSettings->getSameSite(), )); } @@ -98,11 +100,11 @@ class AuthController value: '', expire: 1, path: '/', - domain: null, - secure: false, + domain: $this->cookieSettings->getDomain(), + secure: $this->cookieSettings->isSecure(), httpOnly: true, raw: false, - sameSite: Cookie::SAMESITE_LAX, + sameSite: $this->cookieSettings->getSameSite(), )); } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 9c972cd..1d67ad1 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Auth\AuthCookieSettings; use App\Auth\BcryptPasswordHasher; use App\Auth\Clock; use App\Auth\PasswordHasher; @@ -32,5 +33,45 @@ class AppServiceProvider extends ServiceProvider Filesystem::class, LaravelFilesystem::class, ); + $this->app->bind( + AuthCookieSettings::class, + function (): AuthCookieSettings { + $secureCookie = config('session.secure'); + $sessionDomain = config('session.domain'); + $sameSite = config('session.same_site'); + + return new AuthCookieSettings( + secure: $this->booleanConfig($secureCookie), + domain: $this->nullableStringConfig($sessionDomain), + sameSite: is_string($sameSite) ? $sameSite : 'lax', + ); + } + ); + } + + private function booleanConfig(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1; + } + + if (is_string($value)) { + return in_array(strtolower($value), ['1', 'true'], true); + } + + return false; + } + + private function nullableStringConfig(mixed $value): ?string + { + if (! is_string($value) || trim($value) === '') { + return null; + } + + return $value; } } From 4462879b7dcc98512e255e086aaaed92f789d2cd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:18:28 +0300 Subject: [PATCH 5/7] test admin command --- .../EnsureInitialAdminCommandTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php diff --git a/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php b/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php new file mode 100644 index 0000000..93831a9 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php @@ -0,0 +1,70 @@ +credentialsProvider( + 'admin@example.com', + 'secret-password', + ), + ); + + $commandTester = new CommandTester($command); + $statusCode = $commandTester->execute([]); + + $createdUser = $userRepository->findByEmail( + new EmailAddress('admin@example.com') + ); + + $this->assertSame(0, $statusCode); + $this->assertNotNull($createdUser); + $this->assertSame( + 'hashed-secret-password', + $createdUser->getPasswordHash(), + ); + $this->assertStringContainsString( + 'initial admin is ready', + $commandTester->getDisplay(), + ); + } + + private function credentialsProvider( + string $email, + string $password, + ): InitialAdminCredentialsProvider { + return new class ($email, $password) implements + InitialAdminCredentialsProvider + { + public function __construct( + private string $email, + private string $password, + ) { + } + + public function getCredentials(): InitialAdminCredentials + { + return new InitialAdminCredentials( + email: $this->email, + password: $this->password, + ); + } + }; + } +} From fee174ec05d1ee59a2674ba0f6a5f17455b6299e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:21:04 +0300 Subject: [PATCH 6/7] add admin command --- .../Commands/EnsureInitialAdminCommand.php | 36 +++++++++++++++++++ backend/app/Providers/AppServiceProvider.php | 6 ++++ ...ronmentInitialAdminCredentialsProvider.php | 31 ++++++++++++++++ backend/app/User/InitialAdminCredentials.php | 22 ++++++++++++ .../User/InitialAdminCredentialsProvider.php | 8 +++++ backend/bootstrap/app.php | 4 +++ .../EnsureInitialAdminCommandTest.php | 13 +++++++ 7 files changed, 120 insertions(+) create mode 100644 backend/app/Console/Commands/EnsureInitialAdminCommand.php create mode 100644 backend/app/User/EnvironmentInitialAdminCredentialsProvider.php create mode 100644 backend/app/User/InitialAdminCredentials.php create mode 100644 backend/app/User/InitialAdminCredentialsProvider.php diff --git a/backend/app/Console/Commands/EnsureInitialAdminCommand.php b/backend/app/Console/Commands/EnsureInitialAdminCommand.php new file mode 100644 index 0000000..5a2b9e3 --- /dev/null +++ b/backend/app/Console/Commands/EnsureInitialAdminCommand.php @@ -0,0 +1,36 @@ +credentialsProvider->getCredentials(); + + $this->ensureInitialAdmin->execute(new EnsureInitialAdminRequest( + email: $credentials->getEmail(), + password: $credentials->getPassword(), + )); + + $this->info('initial admin is ready'); + + return self::SUCCESS; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 1d67ad1..38d7550 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -11,6 +11,8 @@ use App\Auth\SystemClock; use App\Auth\TokenGenerator; use App\Shared\Files\Filesystem; use App\Shared\Files\LaravelFilesystem; +use App\User\EnvironmentInitialAdminCredentialsProvider; +use App\User\InitialAdminCredentialsProvider; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -33,6 +35,10 @@ class AppServiceProvider extends ServiceProvider Filesystem::class, LaravelFilesystem::class, ); + $this->app->bind( + InitialAdminCredentialsProvider::class, + EnvironmentInitialAdminCredentialsProvider::class, + ); $this->app->bind( AuthCookieSettings::class, function (): AuthCookieSettings { diff --git a/backend/app/User/EnvironmentInitialAdminCredentialsProvider.php b/backend/app/User/EnvironmentInitialAdminCredentialsProvider.php new file mode 100644 index 0000000..15c22ef --- /dev/null +++ b/backend/app/User/EnvironmentInitialAdminCredentialsProvider.php @@ -0,0 +1,31 @@ +requiredEnvironmentValue( + 'RABBI_GERZI_INITIAL_ADMIN_EMAIL', + ), + password: $this->requiredEnvironmentValue( + 'RABBI_GERZI_INITIAL_ADMIN_PASSWORD', + ), + ); + } + + private function requiredEnvironmentValue(string $key): string + { + $value = getenv($key); + if (! is_string($value) || trim($value) === '') { + throw new RuntimeException("$key is required"); + } + + return $value; + } +} diff --git a/backend/app/User/InitialAdminCredentials.php b/backend/app/User/InitialAdminCredentials.php new file mode 100644 index 0000000..97493b9 --- /dev/null +++ b/backend/app/User/InitialAdminCredentials.php @@ -0,0 +1,22 @@ +email; + } + + public function getPassword(): string + { + return $this->password; + } +} diff --git a/backend/app/User/InitialAdminCredentialsProvider.php b/backend/app/User/InitialAdminCredentialsProvider.php new file mode 100644 index 0000000..011523a --- /dev/null +++ b/backend/app/User/InitialAdminCredentialsProvider.php @@ -0,0 +1,8 @@ +withCommands([ + EnsureInitialAdminCommand::class, + ]) ->withMiddleware(function (Middleware $middleware): void { // }) diff --git a/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php b/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php index 93831a9..5a2467f 100644 --- a/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php +++ b/backend/tests/Unit/User/UseCases/EnsureInitialAdminCommandTest.php @@ -7,6 +7,7 @@ use App\Shared\ValueObject\EmailAddress; use App\User\InitialAdminCredentials; use App\User\InitialAdminCredentialsProvider; use App\User\UseCases\EnsureInitialAdmin\EnsureInitialAdmin; +use Illuminate\Container\Container; use Symfony\Component\Console\Tester\CommandTester; use Tests\Fakes\FakeHasher; use Tests\Fakes\FakeUserRepository; @@ -25,6 +26,7 @@ class EnsureInitialAdminCommandTest extends TestCase 'secret-password', ), ); + $command->setLaravel($this->consoleContainer()); $commandTester = new CommandTester($command); $statusCode = $commandTester->execute([]); @@ -67,4 +69,15 @@ class EnsureInitialAdminCommandTest extends TestCase } }; } + + private function consoleContainer(): Container + { + return new class extends Container + { + public function runningUnitTests(): bool + { + return true; + } + }; + } } From e288b3a63b90030aad92fa4f47dbac18e9f9fec7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:31:58 +0300 Subject: [PATCH 7/7] export nix module --- flake.nix | 32 +++- nix/checks/module-eval.nix | 37 ++++ nix/nixos-module.nix | 349 +++++++++++++++++++++++++++++++++++++ nix/packages/backend.nix | 55 ++++++ nix/packages/default.nix | 7 + nix/packages/frontend.nix | 46 +++++ 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 nix/checks/module-eval.nix create mode 100644 nix/nixos-module.nix create mode 100644 nix/packages/backend.nix create mode 100644 nix/packages/default.nix create mode 100644 nix/packages/frontend.nix diff --git a/flake.nix b/flake.nix index 30e9749..ab7aca0 100644 --- a/flake.nix +++ b/flake.nix @@ -4,11 +4,32 @@ utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, utils }: - utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + utils, + }: + let + nixosModule = import ./nix/nixos-module.nix; + in + utils.lib.eachDefaultSystem ( + system: let pkgs = nixpkgs.legacyPackages.${system}; - in { + packages = import ./nix/packages { inherit pkgs; }; + in + { + inherit packages; + + checks = { + inherit (packages) backend frontend; + module-eval = pkgs.callPackage ./nix/checks/module-eval.nix { + inherit nixpkgs system; + module = nixosModule; + }; + }; + devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ onefetch @@ -44,5 +65,8 @@ ''; }; } - ); + ) + // { + nixosModules.default = nixosModule; + }; } diff --git a/nix/checks/module-eval.nix b/nix/checks/module-eval.nix new file mode 100644 index 0000000..9da8eea --- /dev/null +++ b/nix/checks/module-eval.nix @@ -0,0 +1,37 @@ +{ + lib, + nixpkgs, + module, + pkgs, + system, +}: + +let + evaluatedHostName = + (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + module + { + system.stateVersion = "26.05"; + + services.rabbi-gerzi = { + enable = true; + frontend.hostName = "www.example.test"; + backend = { + hostName = "api.example.test"; + environmentFile = "/run/secrets/rabbi-gerzi.env"; + }; + }; + } + ]; + }).config.services.rabbi-gerzi.backend.hostName; +in +pkgs.runCommand "rabbi-gerzi-module-eval" + { + value = evaluatedHostName; + preferLocalBuild = true; + } + '' + echo "$value" > $out + '' diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 0000000..3e7c507 --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,349 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.rabbi-gerzi; + + backendPackage = cfg.backend.package; + frontendPackage = cfg.frontend.package; + appDir = "${backendPackage}/share/php/rabbi-gerzi-backend"; + phpPackage = backendPackage.passthru.php; + poolName = "rabbi-gerzi"; + storagePath = "${cfg.stateDir}/storage"; + cachePath = "${cfg.cacheDir}/bootstrap-cache"; + setupService = "rabbi-gerzi-setup"; + phpfpmService = "phpfpm-${poolName}"; + + appEnvironment = { + APP_ENV = "production"; + APP_DEBUG = "false"; + APP_URL = cfg.backend.publicUrl; + APP_CONFIG_CACHE = "${cachePath}/config.php"; + APP_EVENTS_CACHE = "${cachePath}/events.php"; + APP_PACKAGES_CACHE = "${cachePath}/packages.php"; + APP_ROUTES_CACHE = "${cachePath}/routes.php"; + APP_SERVICES_CACHE = "${cachePath}/services.php"; + CACHE_STORE = "file"; + CORS_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.backend.corsAllowedOrigins; + DB_CONNECTION = "pgsql"; + DB_DATABASE = cfg.database.name; + DB_HOST = "/run/postgresql"; + DB_PASSWORD = ""; + DB_PORT = "5432"; + DB_USERNAME = cfg.database.user; + FILESYSTEM_DISK = "public"; + LARAVEL_STORAGE_PATH = storagePath; + LOG_CHANNEL = "single"; + MAIL_MAILER = "log"; + QUEUE_CONNECTION = "sync"; + SESSION_DRIVER = "database"; + SESSION_DOMAIN = cfg.backend.cookieDomain; + SESSION_SAME_SITE = cfg.backend.cookieSameSite; + SESSION_SECURE_COOKIE = lib.boolToString cfg.backend.cookieSecure; + }; + + fpmEnvironment = appEnvironment // { + APP_KEY = "$APP_KEY"; + }; + + artisan = "${phpPackage}/bin/php ${appDir}/artisan"; + + makeDirectoryRule = path: "d ${path} 0750 ${cfg.user} ${cfg.group} - -"; +in +{ + options.services.rabbi-gerzi = { + enable = lib.mkEnableOption "the Rabbi Gerzi application"; + + user = lib.mkOption { + type = lib.types.str; + default = "rabbi-gerzi"; + description = "User used to run the Rabbi Gerzi backend."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "rabbi-gerzi"; + description = "Group used to run the Rabbi Gerzi backend."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/rabbi-gerzi"; + description = "Writable state directory for uploads and storage."; + }; + + cacheDir = lib.mkOption { + type = lib.types.str; + default = "/var/cache/rabbi-gerzi"; + description = "Writable cache directory for Laravel cache files."; + }; + + database = { + name = lib.mkOption { + type = lib.types.str; + default = cfg.user; + description = "PostgreSQL database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = cfg.user; + description = "PostgreSQL database user."; + }; + }; + + frontend = { + hostName = lib.mkOption { + type = lib.types.str; + description = "Frontend nginx virtual host name."; + }; + + publicUrl = lib.mkOption { + type = lib.types.str; + default = "https://${cfg.frontend.hostName}"; + description = "Public frontend origin."; + }; + + apiBaseUrl = lib.mkOption { + type = lib.types.str; + default = cfg.backend.publicUrl; + description = "API origin baked into the frontend build."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./packages/frontend.nix { + inherit (cfg.frontend) apiBaseUrl; + }; + description = "Frontend package to serve."; + }; + + nginx = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Extra nginx virtual host options for the frontend."; + }; + }; + + backend = { + hostName = lib.mkOption { + type = lib.types.str; + description = "Backend nginx virtual host name."; + }; + + publicUrl = lib.mkOption { + type = lib.types.str; + default = "https://${cfg.backend.hostName}"; + description = "Public backend origin."; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Runtime dotenv file containing APP_KEY and admin secrets."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./packages/backend.nix { }; + description = "Backend package to run."; + }; + + corsAllowedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ cfg.frontend.publicUrl ]; + description = "Allowed CORS origins for the backend API."; + }; + + cookieSecure = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether auth cookies require HTTPS."; + }; + + cookieDomain = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Auth cookie domain. Empty means host-only cookies."; + }; + + cookieSameSite = lib.mkOption { + type = lib.types.enum [ + "lax" + "none" + "strict" + ]; + default = "none"; + description = "SameSite value for auth cookies."; + }; + + nginx = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Extra nginx virtual host options for the backend."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.backend.environmentFile != null; + message = "services.rabbi-gerzi.backend.environmentFile is required."; + } + { + assertion = cfg.backend.cookieSameSite != "none" || cfg.backend.cookieSecure; + message = "SameSite=None auth cookies require cookieSecure = true."; + } + { + assertion = cfg.database.name == cfg.database.user; + message = "Managed PostgreSQL database and user names must match."; + } + ]; + + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + }; + + systemd.tmpfiles.rules = map makeDirectoryRule [ + cfg.stateDir + cfg.cacheDir + storagePath + "${storagePath}/app" + "${storagePath}/app/private" + "${storagePath}/app/public" + "${storagePath}/framework" + "${storagePath}/framework/cache" + "${storagePath}/framework/cache/data" + "${storagePath}/framework/sessions" + "${storagePath}/framework/views" + "${storagePath}/logs" + cachePath + ]; + + services.phpfpm.pools.${poolName} = { + inherit (cfg) user group; + inherit phpPackage; + phpEnv = fpmEnvironment; + settings = { + "catch_workers_output" = true; + "clear_env" = "no"; + "listen.group" = config.services.nginx.group; + "listen.mode" = "0660"; + "listen.owner" = cfg.user; + "pm" = "dynamic"; + "pm.max_children" = 16; + "pm.max_requests" = 500; + "pm.max_spare_servers" = 4; + "pm.min_spare_servers" = 1; + "pm.start_servers" = 2; + }; + }; + + systemd.services.${setupService} = { + description = "Prepare Rabbi Gerzi backend"; + wantedBy = [ "multi-user.target" ]; + before = [ "${phpfpmService}.service" ]; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + path = [ phpPackage ]; + environment = appEnvironment; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + WorkingDirectory = appDir; + EnvironmentFile = cfg.backend.environmentFile; + ReadWritePaths = [ + cfg.stateDir + cfg.cacheDir + ]; + }; + script = '' + ${artisan} migrate --force + ${artisan} rabbi-gerzi:ensure-initial-admin + ''; + }; + + systemd.services.${phpfpmService} = { + after = [ "${setupService}.service" ]; + requires = [ "${setupService}.service" ]; + serviceConfig = { + EnvironmentFile = cfg.backend.environmentFile; + ReadWritePaths = [ + cfg.stateDir + cfg.cacheDir + ]; + }; + }; + + services.nginx = { + enable = lib.mkDefault true; + recommendedGzipSettings = lib.mkDefault true; + recommendedOptimisation = lib.mkDefault true; + recommendedProxySettings = lib.mkDefault true; + recommendedTlsSettings = lib.mkDefault true; + + virtualHosts = { + ${cfg.frontend.hostName} = lib.mkMerge [ + cfg.frontend.nginx + { + root = "${frontendPackage}"; + locations."/" = { + tryFiles = "$uri $uri/ /index.html"; + }; + } + ]; + + ${cfg.backend.hostName} = lib.mkMerge [ + cfg.backend.nginx + { + root = "${appDir}/public"; + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + + "/storage/" = { + alias = "${storagePath}/app/public/"; + extraConfig = '' + expires 30d; + access_log off; + ''; + }; + + "~ \\.php$" = { + extraConfig = '' + include ${pkgs.nginx}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:${config.services.phpfpm.pools.${poolName}.socket}; + ''; + }; + }; + } + ]; + }; + }; + }; +} diff --git a/nix/packages/backend.nix b/nix/packages/backend.nix new file mode 100644 index 0000000..e9eac6f --- /dev/null +++ b/nix/packages/backend.nix @@ -0,0 +1,55 @@ +{ + lib, + makeWrapper, + php, +}: + +let + phpPackage = php.withExtensions ( + { enabled, all }: + enabled + ++ [ + all.pdo_pgsql + ] + ); +in +phpPackage.buildComposerProject2 (finalAttrs: { + pname = "rabbi-gerzi-backend"; + version = "0.0.0"; + + src = lib.cleanSourceWith { + src = ../../backend; + filter = + path: type: + let + relativePath = lib.removePrefix (toString ../../backend + "/") (toString path); + in + !( + lib.hasPrefix ".env" relativePath + || lib.hasPrefix "vendor/" relativePath + || lib.hasPrefix ".phpunit" relativePath + || lib.hasPrefix "storage/logs/" relativePath + || lib.hasPrefix "storage/framework/views/" relativePath + || lib.hasSuffix "~" relativePath + ); + }; + + php = phpPackage; + nativeBuildInputs = [ makeWrapper ]; + vendorHash = "sha256-EhBxKo3qjEuVTlxK/F+vx6ZBGbApBjcgOK8+3u8UiNk="; + + postInstall = '' + mkdir -p $out/bin + makeWrapper ${phpPackage}/bin/php $out/bin/rabbi-gerzi-artisan \ + --add-flags "$out/share/php/${finalAttrs.pname}/artisan" + ''; + + passthru = { + php = phpPackage; + }; + + meta = { + description = "Rabbi Gerzi Laravel backend"; + mainProgram = "rabbi-gerzi-artisan"; + }; +}) diff --git a/nix/packages/default.nix b/nix/packages/default.nix new file mode 100644 index 0000000..45b1d04 --- /dev/null +++ b/nix/packages/default.nix @@ -0,0 +1,7 @@ +{ pkgs }: + +rec { + backend = pkgs.callPackage ./backend.nix { }; + frontend = pkgs.callPackage ./frontend.nix { }; + default = frontend; +} diff --git a/nix/packages/frontend.nix b/nix/packages/frontend.nix new file mode 100644 index 0000000..137bb6d --- /dev/null +++ b/nix/packages/frontend.nix @@ -0,0 +1,46 @@ +{ + buildNpmPackage, + lib, + apiBaseUrl ? "", +}: + +buildNpmPackage { + pname = "rabbi-gerzi-frontend"; + version = "0.0.0"; + + src = lib.cleanSourceWith { + src = ../../frontend/rabbi_gerzi; + filter = + path: type: + let + relativePath = lib.removePrefix (toString ../../frontend/rabbi_gerzi + "/") (toString path); + in + !( + lib.hasPrefix ".env" relativePath + || lib.hasPrefix "node_modules/" relativePath + || lib.hasPrefix "dist/" relativePath + || lib.hasPrefix "coverage/" relativePath + || lib.hasSuffix ".tsbuildinfo" relativePath + || lib.hasSuffix ".timestamp-" relativePath + ); + }; + + npmDepsHash = "sha256-SF6YRzLySEkYAnwr5YYsftyvwbohtHQo0UpelHmJ7CY="; + npmFlags = [ "--legacy-peer-deps" ]; + + CYPRESS_INSTALL_BINARY = "0"; + VITE_API_BASE_URL = apiBaseUrl; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -R dist/* $out/ + + runHook postInstall + ''; + + meta = { + description = "Rabbi Gerzi Vue frontend"; + }; +}