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}