at 25.11-pre 14 kB view raw
1{ 2 config, 3 pkgs, 4 lib, 5 ... 6}: 7 8with lib; 9 10let 11 cfg = config.services.invoiceplane; 12 eachSite = cfg.sites; 13 user = "invoiceplane"; 14 webserver = config.services.${cfg.webserver}; 15 16 invoiceplane-config = 17 hostName: cfg: 18 pkgs.writeText "ipconfig.php" '' 19 IP_URL=http://${hostName} 20 ENABLE_DEBUG=false 21 DISABLE_SETUP=false 22 REMOVE_INDEXPHP=false 23 DB_HOSTNAME=${cfg.database.host} 24 DB_USERNAME=${cfg.database.user} 25 # NOTE: file_get_contents adds newline at the end of returned string 26 DB_PASSWORD=${ 27 optionalString ( 28 cfg.database.passwordFile != null 29 ) "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")" 30 } 31 DB_DATABASE=${cfg.database.name} 32 DB_PORT=${toString cfg.database.port} 33 SESS_EXPIRATION=864000 34 ENABLE_INVOICE_DELETION=false 35 DISABLE_READ_ONLY=false 36 ENCRYPTION_KEY= 37 ENCRYPTION_CIPHER=AES-256 38 SETUP_COMPLETED=false 39 REMOVE_INDEXPHP=true 40 ''; 41 42 mkPhpValue = 43 v: 44 if isString v then 45 escapeShellArg v 46 # NOTE: If any value contains a , (comma) this will not get escaped 47 else if isList v && strings.isConvertibleWithToString v then 48 escapeShellArg (concatMapStringsSep "," toString v) 49 else if isInt v then 50 toString v 51 else if isBool v then 52 boolToString v 53 else 54 abort "The Invoiceplane config value ${lib.generators.toPretty { } v} can not be encoded."; 55 56 extraConfig = 57 hostName: cfg: 58 let 59 settings = mapAttrsToList (k: v: "${k}=${mkPhpValue v}") cfg.settings; 60 in 61 pkgs.writeText "extraConfig.php" (concatStringsSep "\n" settings); 62 63 pkg = 64 hostName: cfg: 65 pkgs.stdenv.mkDerivation rec { 66 pname = "invoiceplane-${hostName}"; 67 version = src.version; 68 src = pkgs.invoiceplane; 69 70 postPatch = '' 71 # Patch index.php file to load additional config file 72 substituteInPlace index.php \ 73 --replace-fail "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, 'extraConfig.php'); \$dotenv->load();"; 74 ''; 75 76 installPhase = '' 77 mkdir -p $out 78 cp -r * $out/ 79 80 # symlink uploads and log directories 81 rm -r $out/uploads $out/application/logs $out/vendor/mpdf/mpdf/tmp 82 ln -sf ${cfg.stateDir}/uploads $out/ 83 ln -sf ${cfg.stateDir}/logs $out/application/ 84 ln -sf ${cfg.stateDir}/tmp $out/vendor/mpdf/mpdf/ 85 86 # symlink the InvoicePlane config 87 ln -s ${cfg.stateDir}/ipconfig.php $out/ipconfig.php 88 89 # symlink the extraConfig file 90 ln -s ${extraConfig hostName cfg} $out/extraConfig.php 91 92 # symlink additional templates 93 ${concatMapStringsSep "\n" ( 94 template: "cp -r ${template}/. $out/application/views/invoice_templates/pdf/" 95 ) cfg.invoiceTemplates} 96 ''; 97 }; 98 99 siteOpts = 100 { lib, name, ... }: 101 { 102 options = { 103 104 enable = mkEnableOption "InvoicePlane web application"; 105 106 stateDir = mkOption { 107 type = types.path; 108 default = "/var/lib/invoiceplane/${name}"; 109 description = '' 110 This directory is used for uploads of attachments and cache. 111 The directory passed here is automatically created and permissions 112 adjusted as required. 113 ''; 114 }; 115 116 database = { 117 host = mkOption { 118 type = types.str; 119 default = "localhost"; 120 description = "Database host address."; 121 }; 122 123 port = mkOption { 124 type = types.port; 125 default = 3306; 126 description = "Database host port."; 127 }; 128 129 name = mkOption { 130 type = types.str; 131 default = "invoiceplane"; 132 description = "Database name."; 133 }; 134 135 user = mkOption { 136 type = types.str; 137 default = "invoiceplane"; 138 description = "Database user."; 139 }; 140 141 passwordFile = mkOption { 142 type = types.nullOr types.path; 143 default = null; 144 example = "/run/keys/invoiceplane-dbpassword"; 145 description = '' 146 A file containing the password corresponding to 147 {option}`database.user`. 148 ''; 149 }; 150 151 createLocally = mkOption { 152 type = types.bool; 153 default = true; 154 description = "Create the database and database user locally."; 155 }; 156 }; 157 158 invoiceTemplates = mkOption { 159 type = types.listOf types.path; 160 default = [ ]; 161 description = '' 162 List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory. 163 164 ::: {.note} 165 These templates need to be packaged before use, see example. 166 ::: 167 ''; 168 example = literalExpression '' 169 let 170 # Let's package an example template 171 template-vtdirektmarketing = pkgs.stdenv.mkDerivation { 172 name = "vtdirektmarketing"; 173 # Download the template from a public repository 174 src = pkgs.fetchgit { 175 url = "https://git.project-insanity.org/onny/invoiceplane-vtdirektmarketing.git"; 176 sha256 = "1hh0q7wzsh8v8x03i82p6qrgbxr4v5fb05xylyrpp975l8axyg2z"; 177 }; 178 sourceRoot = "."; 179 # Installing simply means copying template php file to the output directory 180 installPhase = "" 181 mkdir -p $out 182 cp invoiceplane-vtdirektmarketing/vtdirektmarketing.php $out/ 183 ""; 184 }; 185 # And then pass this package to the template list like this: 186 in [ template-vtdirektmarketing ] 187 ''; 188 }; 189 190 poolConfig = mkOption { 191 type = 192 with types; 193 attrsOf (oneOf [ 194 str 195 int 196 bool 197 ]); 198 default = { 199 "pm" = "dynamic"; 200 "pm.max_children" = 32; 201 "pm.start_servers" = 2; 202 "pm.min_spare_servers" = 2; 203 "pm.max_spare_servers" = 4; 204 "pm.max_requests" = 500; 205 }; 206 description = '' 207 Options for the InvoicePlane PHP pool. See the documentation on `php-fpm.conf` 208 for details on configuration directives. 209 ''; 210 }; 211 212 settings = mkOption { 213 type = types.attrsOf types.anything; 214 default = { }; 215 description = '' 216 Structural InvoicePlane configuration. Refer to 217 <https://github.com/InvoicePlane/InvoicePlane/blob/master/ipconfig.php.example> 218 for details and supported values. 219 ''; 220 example = literalExpression '' 221 { 222 SETUP_COMPLETED = true; 223 DISABLE_SETUP = true; 224 IP_URL = "https://invoice.example.com"; 225 } 226 ''; 227 }; 228 229 cron = { 230 enable = mkOption { 231 type = types.bool; 232 default = false; 233 description = '' 234 Enable cron service which periodically runs Invoiceplane tasks. 235 Requires key taken from the administration page. Refer to 236 <https://wiki.invoiceplane.com/en/1.0/modules/recurring-invoices> 237 on how to configure it. 238 ''; 239 }; 240 key = mkOption { 241 type = types.str; 242 description = "Cron key taken from the administration page."; 243 }; 244 }; 245 246 }; 247 248 }; 249in 250{ 251 # interface 252 options = { 253 services.invoiceplane = mkOption { 254 type = types.submodule { 255 256 options.sites = mkOption { 257 type = types.attrsOf (types.submodule siteOpts); 258 default = { }; 259 description = "Specification of one or more InvoicePlane sites to serve"; 260 }; 261 262 options.webserver = mkOption { 263 type = types.enum [ 264 "caddy" 265 "nginx" 266 ]; 267 default = "caddy"; 268 example = "nginx"; 269 description = '' 270 Which webserver to use for virtual host management. 271 ''; 272 }; 273 }; 274 default = { }; 275 description = "InvoicePlane configuration."; 276 }; 277 278 }; 279 280 # implementation 281 config = mkIf (eachSite != { }) (mkMerge [ 282 { 283 284 assertions = flatten ( 285 mapAttrsToList (hostName: cfg: [ 286 { 287 assertion = cfg.database.createLocally -> cfg.database.user == user; 288 message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned''; 289 } 290 { 291 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null; 292 message = ''services.invoiceplane.sites."${hostName}".database.passwordFile cannot be specified if services.invoiceplane.sites."${hostName}".database.createLocally is set to true.''; 293 } 294 { 295 assertion = cfg.cron.enable -> cfg.cron.key != null; 296 message = ''services.invoiceplane.sites."${hostName}".cron.key must be set in order to use cron service.''; 297 } 298 ]) eachSite 299 ); 300 301 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { 302 enable = true; 303 package = mkDefault pkgs.mariadb; 304 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite; 305 ensureUsers = mapAttrsToList (hostName: cfg: { 306 name = cfg.database.user; 307 ensurePermissions = { 308 "${cfg.database.name}.*" = "ALL PRIVILEGES"; 309 }; 310 }) eachSite; 311 }; 312 313 services.phpfpm = { 314 phpPackage = pkgs.php81; 315 pools = mapAttrs' ( 316 hostName: cfg: 317 (nameValuePair "invoiceplane-${hostName}" { 318 inherit user; 319 group = webserver.group; 320 settings = { 321 "listen.owner" = webserver.user; 322 "listen.group" = webserver.group; 323 } // cfg.poolConfig; 324 }) 325 ) eachSite; 326 }; 327 328 } 329 330 { 331 332 systemd.tmpfiles.rules = flatten ( 333 mapAttrsToList (hostName: cfg: [ 334 "d ${cfg.stateDir} 0750 ${user} ${webserver.group} - -" 335 "f ${cfg.stateDir}/ipconfig.php 0750 ${user} ${webserver.group} - -" 336 "d ${cfg.stateDir}/logs 0750 ${user} ${webserver.group} - -" 337 "d ${cfg.stateDir}/uploads 0750 ${user} ${webserver.group} - -" 338 "d ${cfg.stateDir}/uploads/archive 0750 ${user} ${webserver.group} - -" 339 "d ${cfg.stateDir}/uploads/customer_files 0750 ${user} ${webserver.group} - -" 340 "d ${cfg.stateDir}/uploads/temp 0750 ${user} ${webserver.group} - -" 341 "d ${cfg.stateDir}/uploads/temp/mpdf 0750 ${user} ${webserver.group} - -" 342 "d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -" 343 ]) eachSite 344 ); 345 346 systemd.services.invoiceplane-config = { 347 serviceConfig.Type = "oneshot"; 348 script = concatStrings ( 349 mapAttrsToList (hostName: cfg: '' 350 mkdir -p ${cfg.stateDir}/logs \ 351 ${cfg.stateDir}/uploads 352 if ! grep -q IP_URL "${cfg.stateDir}/ipconfig.php"; then 353 cp "${invoiceplane-config hostName cfg}" "${cfg.stateDir}/ipconfig.php" 354 fi 355 '') eachSite 356 ); 357 wantedBy = [ "multi-user.target" ]; 358 }; 359 360 users.users.${user} = { 361 group = webserver.group; 362 isSystemUser = true; 363 }; 364 365 } 366 { 367 368 # Cron service implementation 369 370 systemd.timers = mapAttrs' ( 371 hostName: cfg: 372 (nameValuePair "invoiceplane-cron-${hostName}" ( 373 mkIf cfg.cron.enable { 374 wantedBy = [ "timers.target" ]; 375 timerConfig = { 376 OnBootSec = "5m"; 377 OnUnitActiveSec = "5m"; 378 Unit = "invoiceplane-cron-${hostName}.service"; 379 }; 380 } 381 )) 382 ) eachSite; 383 384 systemd.services = mapAttrs' ( 385 hostName: cfg: 386 (nameValuePair "invoiceplane-cron-${hostName}" ( 387 mkIf cfg.cron.enable { 388 serviceConfig = { 389 Type = "oneshot"; 390 User = user; 391 ExecStart = "${pkgs.curl}/bin/curl --header 'Host: ${hostName}' http://localhost/invoices/cron/recur/${cfg.cron.key}"; 392 }; 393 } 394 )) 395 ) eachSite; 396 397 } 398 399 (mkIf (cfg.webserver == "caddy") { 400 services.caddy = { 401 enable = true; 402 virtualHosts = mapAttrs' ( 403 hostName: cfg: 404 (nameValuePair "http://${hostName}" { 405 extraConfig = '' 406 root * ${pkg hostName cfg} 407 file_server 408 php_fastcgi unix/${config.services.phpfpm.pools."invoiceplane-${hostName}".socket} 409 ''; 410 }) 411 ) eachSite; 412 }; 413 }) 414 415 (mkIf (cfg.webserver == "nginx") { 416 services.nginx = { 417 enable = true; 418 virtualHosts = mapAttrs' ( 419 hostName: cfg: 420 (nameValuePair hostName { 421 root = pkg hostName cfg; 422 extraConfig = '' 423 index index.php index.html index.htm; 424 425 if (!-e $request_filename){ 426 rewrite ^(.*)$ /index.php break; 427 } 428 ''; 429 430 locations = { 431 "/setup".extraConfig = '' 432 rewrite ^(.*)$ http://${hostName}/ redirect; 433 ''; 434 435 "~ .php$" = { 436 extraConfig = '' 437 fastcgi_split_path_info ^(.+\.php)(/.+)$; 438 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 439 fastcgi_pass unix:${config.services.phpfpm.pools."invoiceplane-${hostName}".socket}; 440 include ${config.services.nginx.package}/conf/fastcgi_params; 441 include ${config.services.nginx.package}/conf/fastcgi.conf; 442 ''; 443 }; 444 }; 445 }) 446 ) eachSite; 447 }; 448 }) 449 450 ]); 451}