diff --git a/nixos/doc/manual/release-notes/rl-2205.section.md b/nixos/doc/manual/release-notes/rl-2205.section.md index f45258d0038b..38f503570105 100644 --- a/nixos/doc/manual/release-notes/rl-2205.section.md +++ b/nixos/doc/manual/release-notes/rl-2205.section.md @@ -99,7 +99,7 @@ In addition to numerous new and upgraded packages, this release has the followin - [input-remapper](https://github.com/sezanzeb/input-remapper), an easy to use tool to change the mapping of your input device buttons. Available at [services.input-remapper](#opt-services.input-remapper.enable). -- [InvoicePlane](https://invoiceplane.com), web application for managing and creating invoices. Available at `services.invoiceplane`. +- [InvoicePlane](https://invoiceplane.com), web application for managing and creating invoices. Available at [services.invoiceplane](#opt-services.invoiceplane.sites._name_.enable). - [k3b](https://userbase.kde.org/K3b), the KDE disk burning application. Available as [programs.k3b](#opt-programs.k3b.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 8211bb7ebc24..e16ecf28922c 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1657,6 +1657,7 @@ ./services/web-apps/immich.nix ./services/web-apps/immichframe.nix ./services/web-apps/invidious.nix + ./services/web-apps/invoiceplane.nix ./services/web-apps/isso.nix ./services/web-apps/jirafeau.nix ./services/web-apps/jitsi-meet.nix diff --git a/nixos/modules/rename.nix b/nixos/modules/rename.nix index f3fcf6055946..0e4afc8935cd 100644 --- a/nixos/modules/rename.nix +++ b/nixos/modules/rename.nix @@ -442,10 +442,6 @@ in Consider migrating or switching to Incus, or remove from your configuration. https://linuxcontainers.org/incus/docs/main/howto/server_migrate_lxd/ '') - (mkRemovedOptionModule [ "services" "invoiceplane" ] '' - services.invoiceplane has been removed since the service only supported PHP 8.1 which is EOL - and removed from nixpkgs. - '') (mkRemovedOptionModule [ "services" "filesender" ] '' services.filesender has been removed since it depends on simplesamlphp which was severely unmaintained. '') diff --git a/nixos/modules/services/web-apps/invoiceplane.nix b/nixos/modules/services/web-apps/invoiceplane.nix new file mode 100644 index 000000000000..bd261ea9ec23 --- /dev/null +++ b/nixos/modules/services/web-apps/invoiceplane.nix @@ -0,0 +1,491 @@ +{ + config, + pkgs, + lib, + ... +}: + +with lib; + +let + 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 && 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 __DIR__ . '/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} + ${concatMapStringsSep "\n" ( + template: "cp -r ${template}/. $out/application/views/quote_templates/pdf/" + ) cfg.quoteTemplates} + ''; + }; + + siteOpts = + { lib, 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 ] + ''; + }; + + quoteTemplates = mkOption { + type = types.listOf types.path; + default = [ ]; + description = '' + List of path(s) to respective template(s) which are copied from the 'quote_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 +{ + # interface + options = { + services.invoiceplane = mkOption { + type = types.submodule { + + options.sites = mkOption { + type = types.attrsOf (types.submodule siteOpts); + default = { }; + description = "Specification of one or more InvoicePlane 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 = { + 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 + if ! grep -q 'php exit' "${cfg.stateDir}/ipconfig.php"; then + sed -i "1i # " "${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 = '' + rewrite ^(.*)$ http://${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; + }; + }) + + ]); +}