{ config, pkgs, lib, ... }: let inherit (lib) any attrValues boolToString concatMapStringsSep concatStrings concatStringsSep escapeShellArg flatten isBool isInt isList isString literalExpression mapAttrs' mapAttrsToList mkDefault mkEnableOption mkIf mkMerge mkOption nameValuePair optionalString types ; cfg = config.services.invoiceplane; eachSite = cfg.sites; user = "invoiceplane"; webserver = config.services.${cfg.webserver}; invoiceplane-config = hostName: cfg: pkgs.writeText "ipconfig.php" '' IP_URL=http://${hostName} ENABLE_DEBUG=false DISABLE_SETUP=false REMOVE_INDEXPHP=false DB_HOSTNAME=${cfg.database.host} DB_USERNAME=${cfg.database.user} # NOTE: file_get_contents adds newline at the end of returned string DB_PASSWORD=${ optionalString ( cfg.database.passwordFile != null ) "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")" } DB_DATABASE=${cfg.database.name} DB_PORT=${toString cfg.database.port} SESS_EXPIRATION=864000 ENABLE_INVOICE_DELETION=false DISABLE_READ_ONLY=false ENCRYPTION_KEY= ENCRYPTION_CIPHER=AES-256 SETUP_COMPLETED=false REMOVE_INDEXPHP=true ''; mkPhpValue = v: if isString v then escapeShellArg v # NOTE: If any value contains a , (comma) this will not get escaped else if isList v && lib.strings.isConvertibleWithToString v then escapeShellArg (concatMapStringsSep "," toString v) else if isInt v then toString v else if isBool v then boolToString v else abort "The Invoiceplane config value ${lib.generators.toPretty { } v} can not be encoded."; extraConfig = hostName: cfg: let settings = mapAttrsToList (k: v: "${k}=${mkPhpValue v}") cfg.settings; in pkgs.writeText "extraConfig.php" (concatStringsSep "\n" settings); pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec { pname = "invoiceplane-${hostName}"; version = src.version; src = pkgs.invoiceplane; postPatch = '' # Patch index.php file to load additional config file substituteInPlace index.php \ --replace-fail "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, 'extraConfig.php'); \$dotenv->load();"; ''; installPhase = '' mkdir -p $out cp -r * $out/ # symlink uploads and log directories rm -r $out/uploads $out/application/logs $out/vendor/mpdf/mpdf/tmp ln -sf ${cfg.stateDir}/uploads $out/ ln -sf ${cfg.stateDir}/logs $out/application/ ln -sf ${cfg.stateDir}/tmp $out/vendor/mpdf/mpdf/ # symlink the InvoicePlane config ln -s ${cfg.stateDir}/ipconfig.php $out/ipconfig.php # symlink the extraConfig file ln -s ${extraConfig hostName cfg} $out/extraConfig.php # symlink additional templates ${concatMapStringsSep "\n" ( template: "cp -r ${template}/. $out/application/views/invoice_templates/pdf/" ) cfg.invoiceTemplates} ''; }; siteOpts = { name, ... }: { options = { enable = mkEnableOption "InvoicePlane web application"; stateDir = mkOption { type = types.path; default = "/var/lib/invoiceplane/${name}"; description = '' This directory is used for uploads of attachments and cache. The directory passed here is automatically created and permissions adjusted as required. ''; }; database = { host = mkOption { type = types.str; default = "localhost"; description = "Database host address."; }; port = mkOption { type = types.port; default = 3306; description = "Database host port."; }; name = mkOption { type = types.str; default = "invoiceplane"; description = "Database name."; }; user = mkOption { type = types.str; default = "invoiceplane"; description = "Database user."; }; passwordFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/keys/invoiceplane-dbpassword"; description = '' A file containing the password corresponding to {option}`database.user`. ''; }; createLocally = mkOption { type = types.bool; default = true; description = "Create the database and database user locally."; }; }; invoiceTemplates = mkOption { type = types.listOf types.path; default = [ ]; description = '' List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory. ::: {.note} These templates need to be packaged before use, see example. ::: ''; example = literalExpression '' let # Let's package an example template template-vtdirektmarketing = pkgs.stdenv.mkDerivation { name = "vtdirektmarketing"; # Download the template from a public repository src = pkgs.fetchgit { url = "https://git.project-insanity.org/onny/invoiceplane-vtdirektmarketing.git"; sha256 = "1hh0q7wzsh8v8x03i82p6qrgbxr4v5fb05xylyrpp975l8axyg2z"; }; sourceRoot = "."; # Installing simply means copying template php file to the output directory installPhase = "" mkdir -p $out cp invoiceplane-vtdirektmarketing/vtdirektmarketing.php $out/ ""; }; # And then pass this package to the template list like this: in [ template-vtdirektmarketing ] ''; }; 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 = '' Options for the InvoicePlane PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. ''; }; settings = mkOption { type = types.attrsOf types.anything; default = { }; description = '' Structural InvoicePlane configuration. Refer to for details and supported values. ''; example = literalExpression '' { SETUP_COMPLETED = true; DISABLE_SETUP = true; IP_URL = "https://invoice.example.com"; } ''; }; cron = { enable = mkOption { type = types.bool; default = false; description = '' Enable cron service which periodically runs Invoiceplane tasks. Requires key taken from the administration page. Refer to on how to configure it. ''; }; key = mkOption { type = types.str; description = "Cron key taken from the administration page."; }; }; }; }; in { disabledModules = [ "services/web-apps/invoiceplane.nix" ]; # interface options = { services.invoiceplane = mkOption { type = types.submodule { options.sites = mkOption { type = types.attrsOf (types.submodule siteOpts); default = { }; description = "Specification of one or more WordPress sites to serve"; }; options.webserver = mkOption { type = types.enum [ "caddy" "nginx" ]; default = "caddy"; example = "nginx"; description = '' Which webserver to use for virtual host management. ''; }; }; default = { }; description = "InvoicePlane configuration."; }; }; # implementation config = mkIf (eachSite != { }) (mkMerge [ { assertions = flatten ( mapAttrsToList (hostName: cfg: [ { assertion = cfg.database.createLocally -> cfg.database.user == user; message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; } { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; message = ''services.invoiceplane.sites."${hostName}".database.passwordFile cannot be specified if services.invoiceplane.sites."${hostName}".database.createLocally is set to true.''; } { assertion = cfg.cron.enable -> cfg.cron.key != null; message = ''services.invoiceplane.sites."${hostName}".cron.key must be set in order to use cron service.''; } ]) eachSite ); services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { enable = true; package = mkDefault pkgs.mariadb; ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; ensureUsers = mapAttrsToList (hostName: cfg: { name = cfg.database.user; ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; }; }) eachSite; }; services.phpfpm = { phpPackage = pkgs.php81; pools = mapAttrs' ( hostName: cfg: (nameValuePair "invoiceplane-${hostName}" { inherit user; group = webserver.group; settings = { "listen.owner" = webserver.user; "listen.group" = webserver.group; } // cfg.poolConfig; }) ) eachSite; }; } { systemd.tmpfiles.rules = flatten ( mapAttrsToList (hostName: cfg: [ "d ${cfg.stateDir} 0750 ${user} ${webserver.group} - -" "f ${cfg.stateDir}/ipconfig.php 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/logs 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/uploads 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/uploads/archive 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/uploads/customer_files 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/uploads/temp 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/uploads/temp/mpdf 0750 ${user} ${webserver.group} - -" "d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -" ]) eachSite ); systemd.services.invoiceplane-config = { serviceConfig.Type = "oneshot"; script = concatStrings ( mapAttrsToList (hostName: cfg: '' mkdir -p ${cfg.stateDir}/logs \ ${cfg.stateDir}/uploads if ! grep -q IP_URL "${cfg.stateDir}/ipconfig.php"; then cp "${invoiceplane-config hostName cfg}" "${cfg.stateDir}/ipconfig.php" fi '') eachSite ); wantedBy = [ "multi-user.target" ]; }; users.users.${user} = { group = webserver.group; isSystemUser = true; }; } { # Cron service implementation systemd.timers = mapAttrs' ( hostName: cfg: (nameValuePair "invoiceplane-cron-${hostName}" ( mkIf cfg.cron.enable { wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "5m"; OnUnitActiveSec = "5m"; Unit = "invoiceplane-cron-${hostName}.service"; }; } )) ) eachSite; systemd.services = mapAttrs' ( hostName: cfg: (nameValuePair "invoiceplane-cron-${hostName}" ( mkIf cfg.cron.enable { serviceConfig = { Type = "oneshot"; User = user; ExecStart = "${pkgs.curl}/bin/curl --header 'Host: ${hostName}' http://localhost/invoices/cron/recur/${cfg.cron.key}"; }; } )) ) eachSite; } (mkIf (cfg.webserver == "caddy") { services.caddy = { enable = true; virtualHosts = mapAttrs' ( hostName: cfg: (nameValuePair "http://${hostName}" { extraConfig = '' root * ${pkg hostName cfg} file_server php_fastcgi unix/${config.services.phpfpm.pools."invoiceplane-${hostName}".socket} ''; }) ) eachSite; }; }) (mkIf (cfg.webserver == "nginx") { services.nginx = { enable = true; virtualHosts = mapAttrs' ( hostName: cfg: (nameValuePair hostName { root = pkg hostName cfg; extraConfig = '' index index.php index.html index.htm; if (!-e $request_filename){ rewrite ^(.*)$ /index.php break; } ''; locations = { "/setup".extraConfig = let scheme = if config.services.nginx.virtualHosts.${hostName}.forceSSL then "https" else "http"; in '' rewrite ^(.*)$ ${scheme}://${hostName}/ redirect; ''; "~ .php$" = { extraConfig = '' fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:${config.services.phpfpm.pools."invoiceplane-${hostName}".socket}; include ${config.services.nginx.package}/conf/fastcgi_params; include ${config.services.nginx.package}/conf/fastcgi.conf; ''; }; }; }) ) eachSite; }; }) ]); }