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