grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / bookstack.nix
blob21948fd310d6a43bf801040209ba1469748880fb
1 { config, lib, pkgs, ... }:
3 with lib;
5 let
6   cfg = config.services.bookstack;
7   bookstack = pkgs.bookstack.override {
8     dataDir = cfg.dataDir;
9   };
10   db = cfg.database;
11   mail = cfg.mail;
13   user = cfg.user;
14   group = cfg.group;
16   # shell script for local administration
17   artisan = pkgs.writeScriptBin "bookstack" ''
18     #! ${pkgs.runtimeShell}
19     cd ${bookstack}
20     sudo=exec
21     if [[ "$USER" != ${user} ]]; then
22       sudo='exec /run/wrappers/bin/sudo -u ${user}'
23     fi
24     $sudo ${pkgs.php}/bin/php artisan $*
25   '';
27   tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
29 in {
30   imports = [
31     (mkRemovedOptionModule [ "services" "bookstack" "extraConfig" ] "Use services.bookstack.config instead.")
32     (mkRemovedOptionModule [ "services" "bookstack" "cacheDir" ] "The cache directory is now handled automatically.")
33   ];
35   options.services.bookstack = {
37     enable = mkEnableOption "BookStack";
39     user = mkOption {
40       default = "bookstack";
41       description = "User bookstack runs as.";
42       type = types.str;
43     };
45     group = mkOption {
46       default = "bookstack";
47       description = "Group bookstack runs as.";
48       type = types.str;
49     };
51     appKeyFile = mkOption {
52       description = ''
53         A file containing the Laravel APP_KEY - a 32 character long,
54         base64 encoded key used for encryption where needed. Can be
55         generated with `head -c 32 /dev/urandom | base64`.
56       '';
57       example = "/run/keys/bookstack-appkey";
58       type = types.path;
59     };
61     hostname = lib.mkOption {
62       type = lib.types.str;
63       default = config.networking.fqdnOrHostName;
64       defaultText = lib.literalExpression "config.networking.fqdnOrHostName";
65       example = "bookstack.example.com";
66       description = ''
67         The hostname to serve BookStack on.
68       '';
69     };
71     appURL = mkOption {
72       description = ''
73         The root URL that you want to host BookStack on. All URLs in BookStack will be generated using this value.
74         If you change this in the future you may need to run a command to update stored URLs in the database. Command example: `php artisan bookstack:update-url https://old.example.com https://new.example.com`
75       '';
76       default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
77       defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}'';
78       example = "https://example.com";
79       type = types.str;
80     };
82     dataDir = mkOption {
83       description = "BookStack data directory";
84       default = "/var/lib/bookstack";
85       type = types.path;
86     };
88     database = {
89       host = mkOption {
90         type = types.str;
91         default = "localhost";
92         description = "Database host address.";
93       };
94       port = mkOption {
95         type = types.port;
96         default = 3306;
97         description = "Database host port.";
98       };
99       name = mkOption {
100         type = types.str;
101         default = "bookstack";
102         description = "Database name.";
103       };
104       user = mkOption {
105         type = types.str;
106         default = user;
107         defaultText = literalExpression "user";
108         description = "Database username.";
109       };
110       passwordFile = mkOption {
111         type = with types; nullOr path;
112         default = null;
113         example = "/run/keys/bookstack-dbpassword";
114         description = ''
115           A file containing the password corresponding to
116           {option}`database.user`.
117         '';
118       };
119       createLocally = mkOption {
120         type = types.bool;
121         default = false;
122         description = "Create the database and database user locally.";
123       };
124     };
126     mail = {
127       driver = mkOption {
128         type = types.enum [ "smtp" "sendmail" ];
129         default = "smtp";
130         description = "Mail driver to use.";
131       };
132       host = mkOption {
133         type = types.str;
134         default = "localhost";
135         description = "Mail host address.";
136       };
137       port = mkOption {
138         type = types.port;
139         default = 1025;
140         description = "Mail host port.";
141       };
142       fromName = mkOption {
143         type = types.str;
144         default = "BookStack";
145         description = "Mail \"from\" name.";
146       };
147       from = mkOption {
148         type = types.str;
149         default = "mail@bookstackapp.com";
150         description = "Mail \"from\" email.";
151       };
152       user = mkOption {
153         type = with types; nullOr str;
154         default = null;
155         example = "bookstack";
156         description = "Mail username.";
157       };
158       passwordFile = mkOption {
159         type = with types; nullOr path;
160         default = null;
161         example = "/run/keys/bookstack-mailpassword";
162         description = ''
163           A file containing the password corresponding to
164           {option}`mail.user`.
165         '';
166       };
167       encryption = mkOption {
168         type = with types; nullOr (enum [ "tls" ]);
169         default = null;
170         description = "SMTP encryption mechanism to use.";
171       };
172     };
174     maxUploadSize = mkOption {
175       type = types.str;
176       default = "18M";
177       example = "1G";
178       description = "The maximum size for uploads (e.g. images).";
179     };
181     poolConfig = mkOption {
182       type = with types; attrsOf (oneOf [ str int bool ]);
183       default = {
184         "pm" = "dynamic";
185         "pm.max_children" = 32;
186         "pm.start_servers" = 2;
187         "pm.min_spare_servers" = 2;
188         "pm.max_spare_servers" = 4;
189         "pm.max_requests" = 500;
190       };
191       description = ''
192         Options for the bookstack PHP pool. See the documentation on `php-fpm.conf`
193         for details on configuration directives.
194       '';
195     };
197     nginx = mkOption {
198       type = types.submodule (
199         recursiveUpdate
200           (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {}
201       );
202       default = {};
203       example = literalExpression ''
204         {
205           serverAliases = [
206             "bookstack.''${config.networking.domain}"
207           ];
208           # To enable encryption and let let's encrypt take care of certificate
209           forceSSL = true;
210           enableACME = true;
211         }
212       '';
213       description = ''
214         With this option, you can customize the nginx virtualHost settings.
215       '';
216     };
218     config = mkOption {
219       type = with types;
220         attrsOf
221           (nullOr
222             (either
223               (oneOf [
224                 bool
225                 int
226                 port
227                 path
228                 str
229               ])
230               (submodule {
231                 options = {
232                   _secret = mkOption {
233                     type = nullOr str;
234                     description = ''
235                       The path to a file containing the value the
236                       option should be set to in the final
237                       configuration file.
238                     '';
239                   };
240                 };
241               })));
242       default = {};
243       example = literalExpression ''
244         {
245           ALLOWED_IFRAME_HOSTS = "https://example.com";
246           WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf";
247           AUTH_METHOD = "oidc";
248           OIDC_NAME = "MyLogin";
249           OIDC_DISPLAY_NAME_CLAIMS = "name";
250           OIDC_CLIENT_ID = "bookstack";
251           OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
252           OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
253           OIDC_ISSUER_DISCOVER = true;
254         }
255       '';
256       description = ''
257         BookStack configuration options to set in the
258         {file}`.env` file.
260         Refer to <https://www.bookstackapp.com/docs/>
261         for details on supported values.
263         Settings containing secret data should be set to an attribute
264         set containing the attribute `_secret` - a
265         string pointing to a file containing the value the option
266         should be set to. See the example to get a better picture of
267         this: in the resulting {file}`.env` file, the
268         `OIDC_CLIENT_SECRET` key will be set to the
269         contents of the {file}`/run/keys/oidc_secret`
270         file.
271       '';
272     };
274   };
276   config = mkIf cfg.enable {
278     assertions = [
279       { assertion = db.createLocally -> db.user == user;
280         message = "services.bookstack.database.user must be set to ${user} if services.bookstack.database.createLocally is set true.";
281       }
282       { assertion = db.createLocally -> db.passwordFile == null;
283         message = "services.bookstack.database.passwordFile cannot be specified if services.bookstack.database.createLocally is set to true.";
284       }
285     ];
287     services.bookstack.config = {
288       APP_KEY._secret = cfg.appKeyFile;
289       APP_URL = cfg.appURL;
290       DB_HOST = db.host;
291       DB_PORT = db.port;
292       DB_DATABASE = db.name;
293       DB_USERNAME = db.user;
294       MAIL_DRIVER = mail.driver;
295       MAIL_FROM_NAME = mail.fromName;
296       MAIL_FROM = mail.from;
297       MAIL_HOST = mail.host;
298       MAIL_PORT = mail.port;
299       MAIL_USERNAME = mail.user;
300       MAIL_ENCRYPTION = mail.encryption;
301       DB_PASSWORD._secret = db.passwordFile;
302       MAIL_PASSWORD._secret = mail.passwordFile;
303       APP_SERVICES_CACHE = "/run/bookstack/cache/services.php";
304       APP_PACKAGES_CACHE = "/run/bookstack/cache/packages.php";
305       APP_CONFIG_CACHE = "/run/bookstack/cache/config.php";
306       APP_ROUTES_CACHE = "/run/bookstack/cache/routes-v7.php";
307       APP_EVENTS_CACHE = "/run/bookstack/cache/events.php";
308       SESSION_SECURE_COOKIE = tlsEnabled;
309     };
311     environment.systemPackages = [ artisan ];
313     services.mysql = mkIf db.createLocally {
314       enable = true;
315       package = mkDefault pkgs.mariadb;
316       ensureDatabases = [ db.name ];
317       ensureUsers = [
318         { name = db.user;
319           ensurePermissions = { "${db.name}.*" = "ALL PRIVILEGES"; };
320         }
321       ];
322     };
324     services.phpfpm.pools.bookstack = {
325       inherit user;
326       inherit group;
327       phpOptions = ''
328         log_errors = on
329         post_max_size = ${cfg.maxUploadSize}
330         upload_max_filesize = ${cfg.maxUploadSize}
331       '';
332       settings = {
333         "listen.mode" = "0660";
334         "listen.owner" = user;
335         "listen.group" = group;
336       } // cfg.poolConfig;
337     };
339     services.nginx = {
340       enable = mkDefault true;
341       recommendedTlsSettings = true;
342       recommendedOptimisation = true;
343       recommendedGzipSettings = true;
344       virtualHosts.${cfg.hostname} = mkMerge [ cfg.nginx {
345         root = mkForce "${bookstack}/public";
346         locations = {
347           "/" = {
348             index = "index.php";
349             tryFiles = "$uri $uri/ /index.php?$query_string";
350           };
351           "~ \.php$".extraConfig = ''
352             fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket};
353           '';
354           "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
355             extraConfig = "expires 365d;";
356           };
357         };
358       }];
359     };
361     systemd.services.bookstack-setup = {
362       description = "Preparation tasks for BookStack";
363       before = [ "phpfpm-bookstack.service" ];
364       after = optional db.createLocally "mysql.service";
365       wantedBy = [ "multi-user.target" ];
366       serviceConfig = {
367         Type = "oneshot";
368         RemainAfterExit = true;
369         User = user;
370         WorkingDirectory = "${bookstack}";
371         RuntimeDirectory = "bookstack/cache";
372         RuntimeDirectoryMode = "0700";
373       };
374       path = [ pkgs.replace-secret ];
375       script =
376         let
377           isSecret = v: isAttrs v && v ? _secret && isString v._secret;
378           bookstackEnvVars = lib.generators.toKeyValue {
379             mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
380               mkValueString = v: with builtins;
381                 if isInt         v then toString v
382                 else if isString v then v
383                 else if true  == v then "true"
384                 else if false == v then "false"
385                 else if isSecret v then hashString "sha256" v._secret
386                 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
387             };
388           };
389           secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
390           mkSecretReplacement = file: ''
391             replace-secret ${escapeShellArgs [ (builtins.hashString "sha256" file) file "${cfg.dataDir}/.env" ]}
392           '';
393           secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
394           filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ {} null ])) cfg.config;
395           bookstackEnv = pkgs.writeText "bookstack.env" (bookstackEnvVars filteredConfig);
396         in ''
397         # error handling
398         set -euo pipefail
400         # set permissions
401         umask 077
403         # create .env file
404         install -T -m 0600 -o ${user} ${bookstackEnv} "${cfg.dataDir}/.env"
405         ${secretReplacements}
406         if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
407             sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
408         fi
410         # migrate db
411         ${pkgs.php}/bin/php artisan migrate --force
412       '';
413     };
415     systemd.tmpfiles.settings."10-bookstack" = let
416       defaultConfig = {
417         inherit user group;
418         mode = "0700";
419       };
420     in {
421       "${cfg.dataDir}".d = defaultConfig // { mode = "0710"; };
422       "${cfg.dataDir}/public".d = defaultConfig // { mode = "0750"; };
423       "${cfg.dataDir}/public/uploads".d = defaultConfig // { mode = "0750"; };
424       "${cfg.dataDir}/storage".d = defaultConfig;
425       "${cfg.dataDir}/storage/app".d = defaultConfig;
426       "${cfg.dataDir}/storage/fonts".d = defaultConfig;
427       "${cfg.dataDir}/storage/framework".d = defaultConfig;
428       "${cfg.dataDir}/storage/framework/cache".d = defaultConfig;
429       "${cfg.dataDir}/storage/framework/sessions".d = defaultConfig;
430       "${cfg.dataDir}/storage/framework/views".d = defaultConfig;
431       "${cfg.dataDir}/storage/logs".d = defaultConfig;
432       "${cfg.dataDir}/storage/uploads".d = defaultConfig;
433     };
435     users = {
436       users = mkIf (user == "bookstack") {
437         bookstack = {
438           inherit group;
439           isSystemUser = true;
440         };
441         "${config.services.nginx.user}".extraGroups = [ group ];
442       };
443       groups = mkIf (group == "bookstack") {
444         bookstack = {};
445       };
446     };
448   };
450   meta.maintainers = with maintainers; [ ymarkus ];