diff --git a/modules/default.nix b/modules/default.nix index 96bfba4..2b35285 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -4,6 +4,7 @@ amdgpu-pwm = ./services/hardware/amdgpu-pwm.nix; betanin = ./services/web-apps/betanin.nix; dunst = ./services/x11/dunst.nix; + koillection = ./services/web-apps/koillection.nix; porkbun-ddns = ./services/networking/porkbun-ddns.nix; radeon-profile-daemon = ./services/hardware/radeon-profile-daemon.nix; } diff --git a/modules/services/web-apps/koillection.nix b/modules/services/web-apps/koillection.nix new file mode 100644 index 0000000..2eab732 --- /dev/null +++ b/modules/services/web-apps/koillection.nix @@ -0,0 +1,318 @@ +{ config, lib, modulesPath, options, pkgs, ... }: +let + inherit (lib) literalExpression mkEnableOption mkIf mkOption optionalString types; + + opt = options.services.koillection; + cfg = config.services.koillection; + db = cfg.database; + + koillection = (pkgs.koillection or pkgs.callPackage ../../../pkgs/by-name/ko/koillection/package.nix { }).override { + dataDir = cfg.dataDir; + }; + inherit (koillection.passthru) phpPackage; +in +{ + options.services.koillection = { + enable = mkEnableOption "Koillection, a collection manager"; + + user = mkOption { + type = types.str; + default = "koillection"; + description = lib.mdDoc "User Koillection runs as."; + }; + + group = mkOption { + type = types.str; + default = "koillection"; + description = lib.mdDoc "Group Koillection runs as."; + }; + + hostName = lib.mkOption { + type = types.str; + default = config.networking.fqdnOrHostName; + defaultText = "config.networking.fqdnOrHostName"; + example = "koillection.example.com"; + description = lib.mdDoc "The hostname to serve Koillection on."; + }; + + dataDir = mkOption { + description = lib.mdDoc "Koillection data directory"; + default = "/var/lib/koillection"; + type = types.path; + }; + + database = { + host = mkOption { + type = types.str; + default = if db.createLocally then "/run/postgresql" else null; + defaultText = literalExpression '' + if config.${opt.database.createLocally} + then "/run/postgresql" + else null + ''; + example = "192.168.12.85"; + description = lib.mdDoc "Database host address or unix socket."; + }; + port = mkOption { + type = types.port; + default = 5432; + description = lib.mdDoc "Database host port."; + }; + name = mkOption { + type = types.str; + default = "koillection"; + description = lib.mdDoc "Database name."; + }; + user = mkOption { + type = types.str; + default = cfg.user; + defaultText = literalExpression "user"; + description = lib.mdDoc "Database username."; + }; + passwordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/koillection-dbpassword"; + description = lib.mdDoc '' + A file containing the password corresponding to + {option}`database.user`. + ''; + }; + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc "Create the database and database user locally."; + }; + }; + + poolConfig = mkOption { + type = with types; attrsOf (oneOf [ str int bool ]); + default = { + "pm" = "dynamic"; + "pm.max_children" = 32; + "pm.start_servers" = 2; + "pm.min_spare_servers" = 2; + "pm.max_spare_servers" = 4; + "pm.max_requests" = 500; + }; + description = lib.mdDoc '' + Options for the bookstack PHP pool. See the documentation on `php-fpm.conf` + for details on configuration directives. + ''; + }; + + nginx = mkOption { + type = types.submodule ( + lib.recursiveUpdate + (import "${modulesPath}/services/web-servers/nginx/vhost-options.nix" { inherit config lib; }) + { } + ); + default = { }; + example = literalExpression '' + { + serverAliases = [ + "koillection.''${config.networking.domain}" + ]; + # To enable encryption and let let's encrypt take care of certificate + forceSSL = true; + enableACME = true; + } + ''; + description = lib.mdDoc '' + With this option, you can customize the nginx virtualHost settings. + ''; + }; + + config = mkOption { + type = with types; + attrsOf + (nullOr + (either + (oneOf [ bool int port path str ]) + (submodule { + options = { + _secret = mkOption { + type = nullOr str; + description = lib.mdDoc '' + The path to a file containing the value the option should + be set to in the final configuration file. + ''; + }; + }; + }))); + default = { }; + example = literalExpression '' + ''; + }; + }; + + config = mkIf cfg.enable { + services.koillection.config = { + APP_ENV = "prod"; + # DB_DRIVER = "pdo_pgsql"; + DB_USER = db.user; + DB_PASSWORD._secret = db.passwordFile; + DB_HOST = db.host; + DB_PORT = db.port; + DB_NAME = db.name; + # FIXME + # DB_VERSION = lib.versions.major config.services.postgresql.package.version; + DB_VERSION = "15"; + PHP_TZ = config.time.timeZone; + }; + + services.postgresql = mkIf db.createLocally { + enable = true; + ensureDatabases = [ db.name ]; + ensureUsers = [{ + name = db.user; + ensureDBOwnership = true; + }]; + }; + + services.phpfpm.pools.koillection = { + inherit (cfg) user group; + inherit phpPackage; + # Copied from docker/php.ini + phpOptions = '' + max_execution_time = 200 + + apc.enabled = 1 + apc.shm_size = 64M + apc.ttl = 7200 + + opcache.enable = 1 + opcache.memory_consumption = 256 + opcache.max_accelerated_files = 20000 + opcache.max_wasted_percentage = 10 + opcache.validate_timestamps = 0 + opcache.preload = ${koillection}/share/php/koillection/config/preload.php + opcache.preload_user = ${cfg.user} + expose_php = Off + + session.cookie_httponly = 1 + realpath_cache_size = 4096K + realpath_cache_ttle = 600 + ''; + settings = { + "listen.mode" = "0660"; + "listen.owner" = cfg.user; + "listen.group" = cfg.group; + } // cfg.poolConfig; + }; + + services.nginx = { + enable = lib.mkDefault true; + virtualHosts."${cfg.hostName}" = lib.mkMerge [ + cfg.nginx + { + root = lib.mkForce "${koillection}/share/php/koillection/public"; + extraConfig = optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;"; + locations = { + "/" = { + index = "index.php"; + extraConfig = ''try_files $uri $uri/ /index.php?query_string;''; + }; + "~ \.php$" = { + extraConfig = '' + try_files $uri $uri/ /index.php?$query_string; + include ${config.services.nginx.package}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param REDIRECT_STATUS 200; + fastcgi_pass unix:${config.services.phpfpm.pools."koillection".socket}; + ${optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;"} + ''; + }; + "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = { + extraConfig = "expires 365d;"; + }; + }; + } + ]; + }; + + systemd.services.koillection-setup = { + description = "Preparation tasks for koillection"; + before = [ "phpfpm-koillection.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = cfg.user; + WorkingDirectory = koillection; + RuntimeDirectory = "koillection/cache"; + RuntimeDirectoryMode = "0700"; + }; + path = [ pkgs.replace-secret ]; + script = + let + isSecret = v: lib.isAttrs v && v ? _secret && lib.isString v._secret; + koillectionEnvVars = lib.generators.toKeyValue { + mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" { + mkValueString = v: with builtins; + if isInt v then toString v + else if lib.isString v then v + else if true == v then "true" + else if false == v then "false" + else if isSecret v then hashString "sha256" v._secret + else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}"; + }; + }; + secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config); + mkSecretReplacement = file: '' + replace-secret ${lib.escapeShellArgs [ ( builtins.hashString "sha256" file ) file "${cfg.dataDir}/.env" ]} + ''; + secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; + filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! builtins.elem v [{ } null])) cfg.config; + koillectionEnv = pkgs.writeText "koillection.env" (koillectionEnvVars filteredConfig); + in + '' + # error handling + set -euo pipefail + + # set permissions + umask 077 + + # create .env file + install -T -m 0600 -o ${cfg.user} ${koillectionEnv} "${cfg.dataDir}/.env" + ${secretReplacements} + + # prepend `base64:` if it does not exist in APP_KEY + + if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then + sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env" + fi + + # migrate db + ${lib.getExe phpPackage} ${koillection}/share/php/koillection/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration + + # dump translations + # TODO: Might need pointing somewhere else. + ${lib.getExe phpPackage} ${koillection}/share/php/koillection/bin/console app:translations:dump + ''; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0710 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/public 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/public/uploads 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/var 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/var/cache 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/var/cache/prod 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.dataDir}/var/logs 0700 ${cfg.user} ${cfg.group} - -" + ]; + + users = { + users = mkIf (cfg.user == "koillection") { + koillection = { + inherit (cfg) group; + isSystemUser = true; + }; + "${config.services.nginx.user}".extraGroups = [ cfg.group ]; + }; + groups = mkIf (cfg.group == "koillection") { + koillection = { }; + }; + }; + }; +}