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}