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