diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 21a3447989d0..81ddc364fd24 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -102,6 +102,8 @@ - [Tinyauth](https://tinyauth.app/), a simple authentication middleware for web apps, with OAuth and LDAP support. Available as [services.tinyauth](#opt-services.tinyauth.enable). +- [Strichliste](https://www.strichliste.org), a digital self-service tallysheet used in hackerspaces, clubs and offices. Available as [services.strichliste](#opt-services.strichliste.enable). + - [Dawarich](https://dawarich.app/), a self-hostable location history tracker. Available as [services.dawarich](#opt-services.dawarich.enable). - [Howdy](https://github.com/boltgolt/howdy), a Windows Hello™ style facial authentication program for Linux. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f89194a4ef39..f72dcccf3e87 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1779,6 +1779,7 @@ ./services/web-apps/stash.nix ./services/web-apps/stirling-pdf.nix ./services/web-apps/strfry.nix + ./services/web-apps/strichliste.nix ./services/web-apps/suwayomi-server.nix ./services/web-apps/szurubooru.nix ./services/web-apps/tabbyapi.nix diff --git a/nixos/modules/services/web-apps/strichliste.nix b/nixos/modules/services/web-apps/strichliste.nix new file mode 100644 index 000000000000..4f7db7cd31da --- /dev/null +++ b/nixos/modules/services/web-apps/strichliste.nix @@ -0,0 +1,521 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + mkEnableOption + mkForce + mkIf + mkMerge + mkOption + mkPackageOption + types + ; + + cfg = config.services.strichliste; + + format = pkgs.formats.yaml { }; + settingsFile = format.generate "strichliste.yaml" { + parameters.strichliste = cfg.settings; + }; + + unitDependencies = + lib.optionals ( + lib.hasInfix "pgpsql" cfg.environment.DATABASE_URL + || lib.hasInfix "postgres" cfg.environment.DATABASE_URL + ) [ "postgresql.service" ] + ++ lib.optionals (lib.hasInfix "mysql" cfg.environment.DATABASE_URL) [ "mysql.service" ]; +in +{ + meta.buildDocsInSandbox = false; + + options.services.strichliste = { + enable = mkEnableOption "strichliste, a web based tally sheet."; + + packages = { + backend = mkPackageOption pkgs "strichliste" { }; + frontend = mkOption { + type = types.package; + default = pkgs.strichliste.frontend; + description = '' + The strichliste-frontend package to use. + ''; + }; + }; + + settings = mkOption { + type = types.submodule { + freeformType = format.type; + options = { + common = { + idleTimeout = mkOption { + type = types.int; + default = 30000; + description = '' + Time until the app returns to the start page. + ''; + }; + }; + + user = { + stalePeriod = mkOption { + type = types.str; + default = "10 day"; + example = "1 week"; + description = '' + Duration after which users are listed as inactive. + + The format used is documented in . + + ::: {.tip} + This helps unclutter the user listing by prioritizing active users. + ::: + ''; + }; + }; + + i18n = { + timezone = mkOption { + type = types.str; + default = config.time.timeZone; + defaultText = lib.literalExpression "config.time.timeZone"; + example = "Europe/Berlin"; + description = '' + Timezone used throughout the app, e.g. in the transaction log. + ''; + }; + + language = mkOption { + type = types.str; + default = "en"; + example = "de"; + description = '' + Language used throughout the app. + ''; + }; + + currency = { + name = mkOption { + type = types.str; + example = "Euro"; + description = '' + Name of the currency. + ''; + }; + + symbol = mkOption { + type = types.str; + example = "€"; + description = '' + Symbol for the currency. + ''; + }; + + alpha3 = mkOption { + type = types.str; + example = "EUR"; + description = '' + [ISO 4217] alpha code representing the currency. + + [ISO 4217]: https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes + ''; + }; + }; + }; + + account = { + lower = mkOption { + type = types.int; + default = -200000; + example = 0; + description = '' + The credit limit for user accounts. + ''; + }; + + upper = mkOption { + type = types.ints.positive; + default = 200000; + description = '' + The maximum balance on a user account. + ''; + }; + }; + + payment = { + boundary = { + lower = mkOption { + type = types.int; + default = -2000; + example = 0; + description = '' + The lowest amount that can be used for payments. + ''; + }; + + upper = mkOption { + type = types.ints.positive; + default = 15000; + description = '' + The highest amount that can be used for payment. + ''; + }; + }; + + deposit = { + enabled = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow money deposits. + ''; + }; + + custom = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow custom amounts for deposits. + ''; + }; + + steps = mkOption { + type = types.listOf ( + types.oneOf [ + types.int + types.float + ] + ); + example = [ + 0.5 + 1 + 2 + 5 + 10 + 20 + ]; + description = '' + List of selectable deposit amounts. + + This should match your most common coins and banknotes. + ''; + }; + }; + + dispense = { + enabled = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow spending money. + ''; + }; + + custom = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow custom spending amounts. + ''; + }; + + steps = mkOption { + type = types.listOf ( + types.oneOf [ + types.int + types.float + ] + ); + example = [ + 0.5 + 1 + 2 + 5 + 10 + 20 + ]; + description = '' + List of selectable spending amounts. + + This should match your most common products. + ''; + }; + }; + + transaction = { + enabled = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow transactions between user accounts. + ''; + }; + }; + + undo = { + enabled = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow undoing transactions withing the {option}`services.strichliste.settings.payment.undo.timeout` period. + ''; + }; + + delete = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow deleting within the {option}`services.strichliste.settings.payment.undo.timeout` period. + ''; + }; + + timeout = mkOption { + type = types.str; + default = "5 minute"; + description = '' + The time period after creating a transaction in which undoing/deleting remains possible. + + The format used is documented in . + ''; + }; + }; + }; + }; + }; + description = '' + The {file}`strichliste.yaml` configuration as a Nix attribute set. + + See the [configuration reference](https://github.com/strichliste/strichliste-backend/blob/v${cfg.packages.backend.version}/docs/Config.md) + for possible options. + ''; + }; + + domain = mkOption { + type = types.str; + example = "strichliste.example.com"; + description = '' + Domain name used to configure the webserver virtual host. + ''; + }; + + environment = mkOption { + type = types.submodule { + freeformType = types.attrs; + options = { + APP_ENV = mkOption { + type = types.str; + default = "prod"; + description = '' + The active environment. + ''; + }; + APP_LOG_DIR = mkOption { + type = types.path; + default = "/var/log/strichliste"; + description = '' + Directory to write logs. + ''; + }; + APP_CACHE_DIR = mkOption { + type = types.path; + default = "/var/cache/strichliste"; + description = '' + Directory used for caching. + ''; + }; + CORS_ALLOW_ORIGIN = mkOption { + type = types.str; + default = "^https?://${config.services.strichliste.domain}(:[0-9]+)?$"; + defaultText = lib.literalExpression "^https?://$${config.services.strichliste.domain}(:[0-9]+)?$"; + description = '' + Regular expression defining the allowed CORS origins. + ''; + }; + DATABASE_URL = mkOption { + type = types.str; + default = "sqlite:////var/lib/strichliste/db.sqlite"; + example = "postgresql://strichliste@localhost/strichliste?host=/run/postgresql"; + description = '' + See + for more URL examples. + ''; + }; + }; + }; + default = { }; + description = '' + Environment variables consumed by Symfony. + + See for possible options. + ''; + }; + + environmentFiles = mkOption { + type = types.listOf types.path; + default = [ ]; + example = lib.literalExpression '' + [ + "/run/keys/strichliste.env" + ] + ''; + description = '' + Environment files to configure Symfony. + + See for possible options. + + ::: {.important} + You should configure `APP_SECRET` here. + ::: + ''; + }; + + nginx = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable and configure an nginx vhost to serve strichliste. + ''; + }; + + virtualHost = mkOption { + type = types.submodule ( + import ../web-servers/nginx/vhost-options.nix { + inherit config lib; + } + ); + example = lib.literalExpression '' + { + enableACME = true; + forceSSL = true; + } + ''; + description = '' + Nginx virtual settings to allow direct customization of its settings. + ''; + }; + }; + }; + + config = mkMerge [ + (mkIf (cfg.enable && cfg.nginx.enable) { + services.phpfpm.pools.strichliste.settings = { + "listen.owner" = config.services.nginx.user; + "listen.group" = config.services.nginx.group; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts.${cfg.domain} = mkMerge [ + cfg.nginx.virtualHost + { + root = mkForce "${cfg.packages.frontend}"; + locations = { + "/" = { + tryFiles = toString [ + "$uri" + "$uri/" + "index.html" + ]; + }; + + "/api/" = { + fastcgiParams = { + SCRIPT_FILENAME = "${cfg.packages.backend}/share/php/strichliste-backend/public/index.php"; + SCRIPT_NAME = "/index.php"; + REQUEST_URI = "$request_uri"; + + modHeadersAvailable = "true"; + front_controller_active = "true"; + }; + extraConfig = '' + fastcgi_intercept_errors on; + fastcgi_pass unix:${config.services.phpfpm.pools.strichliste.socket}; + fastcgi_request_buffering off; + ''; + }; + }; + } + ]; + }) + + (mkIf cfg.enable { + environment.etc."strichliste.yaml".source = settingsFile; + + systemd.tmpfiles.settings."strichliste" = { + ${cfg.environment.APP_CACHE_DIR}.d = { + user = "strichliste"; + group = "strichliste"; + mode = "0700"; + }; + ${cfg.environment.APP_LOG_DIR}.d = { + user = "strichliste"; + group = "strichliste"; + mode = "0700"; + }; + }; + + systemd.services.strichliste-migrate = { + wantedBy = [ "phpfpm-strichliste.service" ]; + before = [ "phpfpm-strichliste.service" ]; + wants = unitDependencies; + after = unitDependencies; + inherit (cfg) environment; + preStart = '' + set -ex + if [ ! -e "/var/lib/strichliste/.db-init" ]; then + ${lib.optionalString (lib.hasInfix "sqlite" cfg.environment.DATABASE_URL) '' + ${lib.getExe cfg.packages.backend} doctrine:database:create + ''} + ${lib.getExe cfg.packages.backend} doctrine:schema:create + touch "/var/lib/strichliste/.db-init" + fi + ''; + serviceConfig = { + Type = "exec"; + User = "strichliste"; + Group = "strichliste"; + EnvironmentFile = cfg.environmentFiles; + ExecStart = toString [ + (lib.getExe cfg.packages.backend) + "doctrine:migrations:migrate" + "--allow-no-migration" + "--no-interaction" + ]; + }; + }; + + systemd.services.phpfpm-strichliste = { + inherit (cfg) environment; + serviceConfig.EnvironmentFile = cfg.environmentFiles; + }; + + services.phpfpm.pools.strichliste = { + user = "strichliste"; + group = "strichliste"; + settings = { + # support environment variables + "clear_env" = false; + "pm" = "dynamic"; + "pm.max_children" = 8; + "pm.start_servers" = 1; + "pm.min_spare_servers" = 1; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 256; + }; + inherit (cfg.packages.backend) phpPackage; + }; + + users.groups.strichliste = { }; + users.users.strichliste = { + group = "strichliste"; + home = "/var/lib/strichliste"; + createHome = true; + isSystemUser = true; + }; + }) + ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 929e8362e8f3..f50038332973 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1519,6 +1519,7 @@ in step-ca = handleTestOn [ "x86_64-linux" ] ./step-ca.nix { }; stirling-pdf-desktop = runTest ./stirling-pdf-desktop.nix; stratis = handleTest ./stratis { }; + strichliste = runTest ./web-apps/strichliste.nix; strongswan-swanctl = runTest ./strongswan-swanctl.nix; stub-ld = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./stub-ld.nix { }; stunnel = import ./stunnel.nix { inherit runTest; }; diff --git a/nixos/tests/web-apps/strichliste.nix b/nixos/tests/web-apps/strichliste.nix new file mode 100644 index 000000000000..9d64c113f0c2 --- /dev/null +++ b/nixos/tests/web-apps/strichliste.nix @@ -0,0 +1,105 @@ +{ + pkgs, + ... +}: + +{ + name = "strichliste"; + meta.maintainers = pkgs.strichliste.meta.maintainers; + + nodes = { + server = + { config, ... }: + { + networking.extraHosts = '' + 127.0.0.1 strichliste.local + ''; + + environment.systemPackages = with pkgs; [ httpie ]; + + time.timeZone = "Europe/Berlin"; + + services.strichliste = { + enable = true; + domain = "strichliste.local"; + environmentFiles = [ + (pkgs.writeText "strichliste-secret.env" '' + APP_SECRET=changemechangemechangeme + '') + ]; + settings = { + i18n = { + currency = { + alpha3 = "EUR"; + name = "Euro"; + symbol = "€"; + }; + }; + }; + }; + }; + }; + + testScript = + { + nodes, + ... + }: + # python + '' + import json + + start_all() + + def get_users(): + response = machine.succeed("http --check-status http://strichliste.local/api/user") + users = json.loads(response)["users"] + return users + + def get_user(uid: int): + response = machine.succeed(f"http --check-status http://strichliste.local/api/user/{uid}") + user = json.loads(response)["user"] + return user + + def test(): + with subtest("Check empty user list"): + users = get_users() + t.assertEqual(len(users), 0, "Strichliste must not have users.") + + with subtest("Create user"): + machine.succeed("http --check-status post http://strichliste.local/api/user name=Alice") + users = get_users() + t.assertEqual(len(users), 1, "Strichliste must have exactly one user.") + + with subtest("Retrieve user details"): + user = get_user(1) + t.assertEqual(user["name"], "Alice", "Created user must be named Alice") + t.assertEqual(user["balance"], 0, "New users should have a balance of 0") + + with subtest("Deposit money"): + machine.succeed("http --check-status post http://strichliste.local/api/user/1/transaction amount=500") + user = get_user(1) + t.assertEqual(user["balance"], 500, "Balance must be 500 after depositing 500") + + with subtest("Dispense money"): + machine.succeed("http --check-status post http://strichliste.local/api/user/1/transaction amount=-1000") + user = get_user(1) + t.assertEqual(user["balance"], -500, "Balance must be -500 after dispensing 1000") + + with subtest("Undo transaction"): + response = machine.succeed("http --check-status post http://strichliste.local/api/user/1/transaction amount=7500") + transaction = json.loads(response)["transaction"] + machine.succeed(f"http --check-status delete http://strichliste.local/api/user/1/transaction/{transaction['id']}") + + server.wait_for_unit("phpfpm-strichliste.service") + + # frontend + server.wait_until_succeeds("http --check-status http://strichliste.local/ | grep -q 'Strichliste'") + + # backend + server.wait_until_succeeds("http --check-status http://strichliste.local/api/settings") + + # sqlite + test() + ''; +} diff --git a/pkgs/by-name/st/strichliste/frontend.nix b/pkgs/by-name/st/strichliste/frontend.nix new file mode 100644 index 000000000000..a30c4fff3766 --- /dev/null +++ b/pkgs/by-name/st/strichliste/frontend.nix @@ -0,0 +1,43 @@ +{ + stdenv, + fetchFromGitHub, + fetchYarnDeps, + nodejs, + yarnConfigHook, + yarnBuildHook, + meta, +}: + +stdenv.mkDerivation (finalAttrs: { + pname = "strichliste-frontend"; + version = "2.0.0"; + + src = fetchFromGitHub { + owner = "strichliste"; + repo = "strichliste-web-frontend"; + tag = "v${finalAttrs.version}"; + hash = "sha256-fi4pz3ylWyC4yvDWsK2Rvv8KDaXeHNVz0jY6PpF07hE="; + }; + + yarnOfflineCache = fetchYarnDeps { + yarnLock = finalAttrs.src + "/yarn.lock"; + hash = "sha256-leMwcsyhbxPoHJdA3kZDz97Ti77d1TCe8SrzTQMGrWo="; + }; + + env.NODE_OPTIONS = "--openssl-legacy-provider"; + + nativeBuildInputs = [ + nodejs + yarnConfigHook + yarnBuildHook + ]; + + installPhase = '' + mkdir $out + cp -R build/* $out/ + ''; + + __structuredAttrs = true; + + inherit meta; +}) diff --git a/pkgs/by-name/st/strichliste/package.nix b/pkgs/by-name/st/strichliste/package.nix new file mode 100644 index 000000000000..d26d2dfef174 --- /dev/null +++ b/pkgs/by-name/st/strichliste/package.nix @@ -0,0 +1,55 @@ +{ + callPackage, + fetchFromGitHub, + lib, + pkgs, + php ? pkgs.php85, + nixosTests, +}: + +php.buildComposerProject2 (finalAttrs: { + pname = "strichliste-backend"; + version = "2.0.1"; + + src = fetchFromGitHub { + owner = "strichliste"; + repo = "strichliste-backend"; + tag = "v${finalAttrs.version}"; + hash = "sha256-yI20cUp19ehtOnWdu+MItwgOlNDnt1VK3giInaTQQ4Y="; + }; + + vendorHash = "sha256-vYPjUaNIf62GoKXopC4nGqIa+Z3C8Q5dnX9FPvM1Ers="; + composerNoDev = true; + composerStrictValidation = false; + + postPatch = '' + substituteInPlace config/services.yaml \ + --replace-fail "strichliste.yaml" "/etc/strichliste.yaml" + ''; + + postInstall = '' + mkdir $out/bin + ln -s $out/share/php/strichliste-backend/bin/console $out/bin/strichliste-console + ''; + + __structuredAttrs = true; + + passthru = { + frontend = callPackage ./frontend.nix { + inherit (finalAttrs) meta; + }; + phpPackage = php; + tests = { + inherit (nixosTests) strichliste; + }; + }; + + meta = { + description = "strichliste is a tool to replace a tally sheet."; + homepage = "https://www.strichliste.org/"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ hexa ]; + mainProgram = "strichliste-console"; + platforms = lib.platforms.all; + }; +})