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