1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.bookstack;
7 bookstack = pkgs.bookstack.override {
8 dataDir = cfg.dataDir;
9 };
10 db = cfg.database;
11 mail = cfg.mail;
12
13 user = cfg.user;
14 group = cfg.group;
15
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 '';
26
27 tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
28
29in {
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 ];
34
35 options.services.bookstack = {
36
37 enable = mkEnableOption (lib.mdDoc "BookStack");
38
39 user = mkOption {
40 default = "bookstack";
41 description = lib.mdDoc "User bookstack runs as.";
42 type = types.str;
43 };
44
45 group = mkOption {
46 default = "bookstack";
47 description = lib.mdDoc "Group bookstack runs as.";
48 type = types.str;
49 };
50
51 appKeyFile = mkOption {
52 description = lib.mdDoc ''
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 };
60
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 = lib.mdDoc ''
67 The hostname to serve BookStack on.
68 '';
69 };
70
71 appURL = mkOption {
72 description = lib.mdDoc ''
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 };
81
82 dataDir = mkOption {
83 description = lib.mdDoc "BookStack data directory";
84 default = "/var/lib/bookstack";
85 type = types.path;
86 };
87
88 database = {
89 host = mkOption {
90 type = types.str;
91 default = "localhost";
92 description = lib.mdDoc "Database host address.";
93 };
94 port = mkOption {
95 type = types.port;
96 default = 3306;
97 description = lib.mdDoc "Database host port.";
98 };
99 name = mkOption {
100 type = types.str;
101 default = "bookstack";
102 description = lib.mdDoc "Database name.";
103 };
104 user = mkOption {
105 type = types.str;
106 default = user;
107 defaultText = literalExpression "user";
108 description = lib.mdDoc "Database username.";
109 };
110 passwordFile = mkOption {
111 type = with types; nullOr path;
112 default = null;
113 example = "/run/keys/bookstack-dbpassword";
114 description = lib.mdDoc ''
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 = lib.mdDoc "Create the database and database user locally.";
123 };
124 };
125
126 mail = {
127 driver = mkOption {
128 type = types.enum [ "smtp" "sendmail" ];
129 default = "smtp";
130 description = lib.mdDoc "Mail driver to use.";
131 };
132 host = mkOption {
133 type = types.str;
134 default = "localhost";
135 description = lib.mdDoc "Mail host address.";
136 };
137 port = mkOption {
138 type = types.port;
139 default = 1025;
140 description = lib.mdDoc "Mail host port.";
141 };
142 fromName = mkOption {
143 type = types.str;
144 default = "BookStack";
145 description = lib.mdDoc "Mail \"from\" name.";
146 };
147 from = mkOption {
148 type = types.str;
149 default = "mail@bookstackapp.com";
150 description = lib.mdDoc "Mail \"from\" email.";
151 };
152 user = mkOption {
153 type = with types; nullOr str;
154 default = null;
155 example = "bookstack";
156 description = lib.mdDoc "Mail username.";
157 };
158 passwordFile = mkOption {
159 type = with types; nullOr path;
160 default = null;
161 example = "/run/keys/bookstack-mailpassword";
162 description = lib.mdDoc ''
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 = lib.mdDoc "SMTP encryption mechanism to use.";
171 };
172 };
173
174 maxUploadSize = mkOption {
175 type = types.str;
176 default = "18M";
177 example = "1G";
178 description = lib.mdDoc "The maximum size for uploads (e.g. images).";
179 };
180
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 = lib.mdDoc ''
192 Options for the bookstack PHP pool. See the documentation on `php-fpm.conf`
193 for details on configuration directives.
194 '';
195 };
196
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 = lib.mdDoc ''
214 With this option, you can customize the nginx virtualHost settings.
215 '';
216 };
217
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 = lib.mdDoc ''
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 = lib.mdDoc ''
257 BookStack configuration options to set in the
258 {file}`.env` file.
259
260 Refer to <https://www.bookstackapp.com/docs/>
261 for details on supported values.
262
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 };
273
274 };
275
276 config = mkIf cfg.enable {
277
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 ];
286
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 };
310
311 environment.systemPackages = [ artisan ];
312
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 };
323
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 };
338
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 };
360
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
399
400 # set permissions
401 umask 077
402
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
409
410 # migrate db
411 ${pkgs.php}/bin/php artisan migrate --force
412 '';
413 };
414
415 systemd.tmpfiles.rules = [
416 "d ${cfg.dataDir} 0710 ${user} ${group} - -"
417 "d ${cfg.dataDir}/public 0750 ${user} ${group} - -"
418 "d ${cfg.dataDir}/public/uploads 0750 ${user} ${group} - -"
419 "d ${cfg.dataDir}/storage 0700 ${user} ${group} - -"
420 "d ${cfg.dataDir}/storage/app 0700 ${user} ${group} - -"
421 "d ${cfg.dataDir}/storage/fonts 0700 ${user} ${group} - -"
422 "d ${cfg.dataDir}/storage/framework 0700 ${user} ${group} - -"
423 "d ${cfg.dataDir}/storage/framework/cache 0700 ${user} ${group} - -"
424 "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
425 "d ${cfg.dataDir}/storage/framework/views 0700 ${user} ${group} - -"
426 "d ${cfg.dataDir}/storage/logs 0700 ${user} ${group} - -"
427 "d ${cfg.dataDir}/storage/uploads 0700 ${user} ${group} - -"
428 ];
429
430 users = {
431 users = mkIf (user == "bookstack") {
432 bookstack = {
433 inherit group;
434 isSystemUser = true;
435 };
436 "${config.services.nginx.user}".extraGroups = [ group ];
437 };
438 groups = mkIf (group == "bookstack") {
439 bookstack = {};
440 };
441 };
442
443 };
444
445 meta.maintainers = with maintainers; [ ymarkus ];
446}