1{ config, lib, pkgs, ... }:
2
3let
4 inherit (lib)
5 filterAttrsRecursive
6 generators
7 literalExpression
8 mkDefault
9 mkIf
10 mkOption
11 mkEnableOption
12 mkPackageOption
13 mkMerge
14 pipe
15 types
16 ;
17
18 cfg = config.services.movim;
19
20 defaultPHPCfg = {
21 "output_buffering" = 0;
22 "error_reporting" = "E_ALL & ~E_DEPRECATED & ~E_STRICT";
23 "opcache.enable_cli" = 1;
24 "opcache.interned_strings_buffer" = 8;
25 "opcache.max_accelerated_files" = 6144;
26 "opcache.memory_consumption" = 128;
27 "opcache.revalidate_freq" = 2;
28 "opcache.fast_shutdown" = 1;
29 };
30
31 phpCfg = generators.toKeyValue
32 { mkKeyValue = generators.mkKeyValueDefault { } " = "; }
33 (defaultPHPCfg // cfg.phpCfg);
34
35 podConfigFlags =
36 let
37 bevalue = a: lib.escapeShellArg (generators.mkValueStringDefault { } a);
38 in
39 lib.concatStringsSep " "
40 (lib.attrsets.foldlAttrs
41 (acc: k: v: acc ++ lib.optional (v != null) "--${k}=${bevalue v}")
42 [ ]
43 cfg.podConfig);
44
45 package =
46 let
47 p = cfg.package.override
48 ({
49 inherit phpCfg;
50 withPgsql = cfg.database.type == "pgsql";
51 withMysql = cfg.database.type == "mysql";
52 inherit (cfg) minifyStaticFiles;
53 } // lib.optionalAttrs (lib.isAttrs cfg.minifyStaticFiles) (with cfg.minifyStaticFiles; {
54 esbuild = esbuild.package;
55 lightningcss = lightningcss.package;
56 scour = scour.package;
57 }));
58 in
59 p.overrideAttrs (finalAttrs: prevAttrs:
60 let
61 appDir = "$out/share/php/${finalAttrs.pname}";
62
63 stateDirectories = ''
64 # Symlinking in our state directories
65 rm -rf $out/.env $out/cache ${appDir}/public/cache
66 ln -s ${cfg.dataDir}/.env ${appDir}/.env
67 ln -s ${cfg.dataDir}/public/cache ${appDir}/public/cache
68 ln -s ${cfg.logDir} ${appDir}/log
69 ln -s ${cfg.runtimeDir}/cache ${appDir}/cache
70 '';
71
72 exposeComposer = ''
73 # Expose PHP Composer for scripts
74 mkdir -p $out/bin
75 echo "#!${lib.getExe pkgs.dash}" > $out/bin/movim-composer
76 echo "${finalAttrs.php.packages.composer}/bin/composer --working-dir="${appDir}" \"\$@\"" >> $out/bin/movim-composer
77 chmod +x $out/bin/movim-composer
78 '';
79
80 podConfigInputDisableReplace = lib.optionalString (podConfigFlags != "")
81 (lib.concatStringsSep "\n"
82 (lib.attrsets.foldlAttrs
83 (acc: k: v:
84 acc ++ lib.optional (v != null)
85 # Disable all Admin panel options that were set in the
86 # `cfg.podConfig` to prevent confusing situtions where the
87 # values are rewritten on server reboot
88 ''
89 substituteInPlace ${appDir}/app/widgets/AdminMain/adminmain.tpl \
90 --replace-warn 'name="${k}"' 'name="${k}" disabled'
91 '')
92 [ ]
93 cfg.podConfig));
94
95 precompressStaticFilesJobs =
96 let
97 inherit (cfg.precompressStaticFiles) brotli gzip;
98
99 findTextFileNames = lib.concatStringsSep " -o "
100 (builtins.map (n: ''-iname "*.${n}"'')
101 [ "css" "ini" "js" "json" "manifest" "mjs" "svg" "webmanifest" ]);
102 in
103 lib.concatStringsSep "\n" [
104 (lib.optionalString brotli.enable ''
105 echo -n "Precompressing static files with Brotli …"
106 find ${appDir}/public -type f ${findTextFileNames} -print0 \
107 | xargs -0 -n 1 -P $NIX_BUILD_CORES ${pkgs.writeShellScript "movim_precompress_broti" ''
108 file="$1"
109 ${lib.getExe brotli.package} --keep --quality=${builtins.toString brotli.compressionLevel} --output=$file.br $file
110 ''}
111 echo " done."
112 '')
113 (lib.optionalString gzip.enable ''
114 echo -n "Precompressing static files with Gzip …"
115 find ${appDir}/public -type f ${findTextFileNames} -print0 \
116 | xargs -0 -n 1 -P $NIX_BUILD_CORES ${pkgs.writeShellScript "movim_precompress_broti" ''
117 file="$1"
118 ${lib.getExe gzip.package} -c -${builtins.toString gzip.compressionLevel} $file > $file.gz
119 ''}
120 echo " done."
121 '')
122 ];
123 in
124 {
125 postInstall = lib.concatStringsSep "\n\n" [
126 prevAttrs.postInstall
127 stateDirectories
128 exposeComposer
129 podConfigInputDisableReplace
130 precompressStaticFilesJobs
131 ];
132 });
133
134 configFile = pipe cfg.settings [
135 (filterAttrsRecursive (_: v: v != null))
136 (generators.toKeyValue { })
137 (pkgs.writeText "movim-env")
138 ];
139
140 pool = "movim";
141 fpm = config.services.phpfpm.pools.${pool};
142 phpExecutionUnit = "phpfpm-${pool}";
143
144 dbService = {
145 "postgresql" = "postgresql.service";
146 "mysql" = "mysql.service";
147 }.${cfg.database.type};
148in
149{
150 options.services = {
151 movim = {
152 enable = mkEnableOption "a Movim instance";
153 package = mkPackageOption pkgs "movim" { };
154 phpPackage = mkPackageOption pkgs "php" { };
155
156 phpCfg = mkOption {
157 type = with types; attrsOf (oneOf [ int str bool ]);
158 defaultText = literalExpression (generators.toPretty { } defaultPHPCfg);
159 default = { };
160 description = "Extra PHP INI options such as `memory_limit`, `max_execution_time`, etc.";
161 };
162
163 user = mkOption {
164 type = types.nonEmptyStr;
165 default = "movim";
166 description = "User running Movim service";
167 };
168
169 group = mkOption {
170 type = types.nonEmptyStr;
171 default = "movim";
172 description = "Group running Movim service";
173 };
174
175 dataDir = mkOption {
176 type = types.nonEmptyStr;
177 default = "/var/lib/movim";
178 description = "State directory of the `movim` user which holds the application’s state & data.";
179 };
180
181 logDir = mkOption {
182 type = types.nonEmptyStr;
183 default = "/var/log/movim";
184 description = "Log directory of the `movim` user which holds the application’s logs.";
185 };
186
187 runtimeDir = mkOption {
188 type = types.nonEmptyStr;
189 default = "/run/movim";
190 description = "Runtime directory of the `movim` user which holds the application’s caches & temporary files.";
191 };
192
193 domain = mkOption {
194 type = types.nonEmptyStr;
195 description = "Fully-qualified domain name (FQDN) for the Movim instance.";
196 };
197
198 port = mkOption {
199 type = types.port;
200 default = 8080;
201 description = "Movim daemon port.";
202 };
203
204 debug = mkOption {
205 type = types.bool;
206 default = false;
207 description = "Debugging logs.";
208 };
209
210 verbose = mkOption {
211 type = types.bool;
212 default = false;
213 description = "Verbose logs.";
214 };
215
216 minifyStaticFiles = mkOption {
217 type = with types; either bool (submodule {
218 options = {
219 script = mkOption {
220 type = types.submodule {
221 options = {
222 enable = mkEnableOption "Script minification";
223 package = mkPackageOption pkgs "esbuild" { };
224 target = mkOption {
225 type = with types; nullOr nonEmptyStr;
226 default = null;
227 };
228 };
229 };
230 };
231 style = mkOption {
232 type = types.submodule {
233 options = {
234 enable = mkEnableOption "Script minification";
235 package = mkPackageOption pkgs "lightningcss" { };
236 target = mkOption {
237 type = with types; nullOr nonEmptyStr;
238 default = null;
239 };
240 };
241 };
242 };
243 svg = mkOption {
244 type = types.submodule {
245 options = {
246 enable = mkEnableOption "SVG minification";
247 package = mkPackageOption pkgs "scour" { };
248 };
249 };
250 };
251 };
252 });
253 default = true;
254 description = "Do minification on public static files";
255 };
256
257 precompressStaticFiles = mkOption {
258 type = with types; submodule {
259 options = {
260 brotli = {
261 enable = mkEnableOption "Brotli precompression";
262 package = mkPackageOption pkgs "brotli" { };
263 compressionLevel = mkOption {
264 type = types.ints.between 0 11;
265 default = 11;
266 description = "Brotli compression level";
267 };
268 };
269 gzip = {
270 enable = mkEnableOption "Gzip precompression";
271 package = mkPackageOption pkgs "gzip" { };
272 compressionLevel = mkOption {
273 type = types.ints.between 1 9;
274 default = 9;
275 description = "Gzip compression level";
276 };
277 };
278 };
279 };
280 default = {
281 brotli.enable = true;
282 gzip.enable = false;
283 };
284 description = "Aggressively precompress static files";
285 };
286
287 podConfig = mkOption {
288 type = types.submodule {
289 options = {
290 info = mkOption {
291 type = with types; nullOr str;
292 default = null;
293 description = "Content of the info box on the login page";
294 };
295
296 description = mkOption {
297 type = with types; nullOr str;
298 default = null;
299 description = "General description of the instance";
300 };
301
302 timezone = mkOption {
303 type = with types; nullOr str;
304 default = null;
305 description = "The server timezone";
306 };
307
308 restrictsuggestions = mkOption {
309 type = with types; nullOr bool;
310 default = null;
311 description = "Only suggest chatrooms, Communities and other contents that are available on the user XMPP server and related services";
312 };
313
314 chatonly = mkOption {
315 type = with types; nullOr bool;
316 default = null;
317 description = "Disable all the social feature (Communities, Blog…) and keep only the chat ones";
318 };
319
320 disableregistration = mkOption {
321 type = with types; nullOr bool;
322 default = null;
323 description = "Remove the XMPP registration flow and buttons from the interface";
324 };
325
326 loglevel = mkOption {
327 type = with types; nullOr (ints.between 0 3);
328 default = null;
329 description = "The server loglevel";
330 };
331
332 locale = mkOption {
333 type = with types; nullOr str;
334 default = null;
335 description = "The server main locale";
336 };
337
338 xmppdomain = mkOption {
339 type = with types; nullOr str;
340 default = null;
341 description = "The default XMPP server domain";
342 };
343
344 xmppdescription = mkOption {
345 type = with types; nullOr str;
346 default = null;
347 description = "The default XMPP server description";
348 };
349
350 xmppwhitelist = mkOption {
351 type = with types; nullOr str;
352 default = null;
353 description = "The allowlisted XMPP servers";
354 };
355 };
356 };
357 default = { };
358 description = ''
359 Pod configuration (values from `php daemon.php config --help`).
360 Note that these values will now be disabled in the admin panel.
361 '';
362 };
363
364 settings = mkOption {
365 type = with types; attrsOf (nullOr (oneOf [ int str bool ]));
366 default = { };
367 description = ".env settings for Movim. Secrets should use `secretFile` option instead. `null`s will be culled.";
368 };
369
370 secretFile = mkOption {
371 type = with types; nullOr path;
372 default = null;
373 description = "The secret file to be sourced for the .env settings.";
374 };
375
376 database = {
377 type = mkOption {
378 type = types.enum [ "mysql" "postgresql" ];
379 example = "mysql";
380 default = "postgresql";
381 description = "Database engine to use.";
382 };
383
384 name = mkOption {
385 type = types.str;
386 default = "movim";
387 description = "Database name.";
388 };
389
390 user = mkOption {
391 type = types.str;
392 default = "movim";
393 description = "Database username.";
394 };
395
396 createLocally = mkOption {
397 type = types.bool;
398 default = true;
399 description = "local database using UNIX socket authentication";
400 };
401 };
402
403 nginx = mkOption {
404 type = with types; nullOr (submodule
405 (import ../web-servers/nginx/vhost-options.nix {
406 inherit config lib;
407 }));
408 default = null;
409 example = lib.literalExpression /* nginx */ ''
410 {
411 serverAliases = [
412 "pics.''${config.networking.domain}"
413 ];
414 enableACME = true;
415 forceHttps = true;
416 }
417 '';
418 description = ''
419 With this option, you can customize an nginx virtual host which already has sensible defaults for Movim.
420 Set to `{ }` if you do not need any customization to the virtual host.
421 If enabled, then by default, the {option}`serverName` is `''${domain}`,
422 If this is set to null (the default), no nginx virtualHost will be configured.
423 '';
424 };
425
426 poolConfig = mkOption {
427 type = with types; attrsOf (oneOf [ int str bool ]);
428 default = { };
429 description = "Options for Movim’s PHP-FPM pool.";
430 };
431 };
432 };
433
434 config = mkIf cfg.enable {
435 environment.systemPackages = [ cfg.package ];
436
437 users = {
438 users = {
439 movim = mkIf (cfg.user == "movim") {
440 isSystemUser = true;
441 group = cfg.group;
442 };
443 "${config.services.nginx.user}".extraGroups = [ cfg.group ];
444 };
445 groups = {
446 ${cfg.group} = { };
447 };
448 };
449
450 services = {
451 movim = {
452 settings = mkMerge [
453 {
454 DAEMON_URL = "//${cfg.domain}";
455 DAEMON_PORT = cfg.port;
456 DAEMON_INTERFACE = "127.0.0.1";
457 DAEMON_DEBUG = cfg.debug;
458 DAEMON_VERBOSE = cfg.verbose;
459 }
460 (mkIf cfg.database.createLocally {
461 DB_DRIVER = {
462 "postgresql" = "pgsql";
463 "mysql" = "mysql";
464 }.${cfg.database.type};
465 DB_HOST = "localhost";
466 DB_PORT = config.services.${cfg.database.type}.settings.port;
467 DB_DATABASE = cfg.database.name;
468 DB_USERNAME = cfg.database.user;
469 DB_PASSWORD = "";
470 })
471 ];
472
473 poolConfig = lib.mapAttrs' (n: v: lib.nameValuePair n (lib.mkDefault v)) {
474 "pm" = "dynamic";
475 "php_admin_value[error_log]" = "stderr";
476 "php_admin_flag[log_errors]" = true;
477 "catch_workers_output" = true;
478 "pm.max_children" = 32;
479 "pm.start_servers" = 2;
480 "pm.min_spare_servers" = 2;
481 "pm.max_spare_servers" = 8;
482 "pm.max_requests" = 500;
483 };
484 };
485
486 nginx = mkIf (cfg.nginx != null) {
487 enable = true;
488 recommendedOptimisation = true;
489 recommendedGzipSettings = true;
490 recommendedBrotliSettings = true;
491 recommendedProxySettings = true;
492 # TODO: recommended cache options already in Nginx⁇
493 appendHttpConfig = /* nginx */ ''
494 fastcgi_cache_path /tmp/nginx_cache levels=1:2 keys_zone=nginx_cache:100m inactive=60m;
495 fastcgi_cache_key "$scheme$request_method$host$request_uri";
496 '';
497 virtualHosts."${cfg.domain}" = mkMerge [
498 cfg.nginx
499 {
500 root = lib.mkForce "${package}/share/php/movim/public";
501 locations = {
502 "/favicon.ico" = {
503 priority = 100;
504 extraConfig = /* nginx */ ''
505 access_log off;
506 log_not_found off;
507 '';
508 };
509 "/robots.txt" = {
510 priority = 100;
511 extraConfig = /* nginx */ ''
512 access_log off;
513 log_not_found off;
514 '';
515 };
516 "~ /\\.(?!well-known).*" = {
517 priority = 210;
518 extraConfig = /* nginx */ ''
519 deny all;
520 '';
521 };
522 # Ask nginx to cache every URL starting with "/picture"
523 "/picture" = {
524 priority = 400;
525 tryFiles = "$uri $uri/ /index.php$is_args$args";
526 extraConfig = /* nginx */ ''
527 set $no_cache 0; # Enable cache only there
528 '';
529 };
530 "/" = {
531 priority = 490;
532 tryFiles = "$uri $uri/ /index.php$is_args$args";
533 extraConfig = /* nginx */ ''
534 # https://github.com/movim/movim/issues/314
535 add_header Content-Security-Policy "default-src 'self'; img-src 'self' aesgcm: https:; media-src 'self' aesgcm: https:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline';";
536 set $no_cache 1;
537 '';
538 };
539 "~ \\.php$" = {
540 priority = 500;
541 tryFiles = "$uri =404";
542 extraConfig = /* nginx */ ''
543 include ${config.services.nginx.package}/conf/fastcgi.conf;
544 add_header X-Cache $upstream_cache_status;
545 fastcgi_ignore_headers "Cache-Control" "Expires" "Set-Cookie";
546 fastcgi_cache nginx_cache;
547 fastcgi_cache_valid any 7d;
548 fastcgi_cache_bypass $no_cache;
549 fastcgi_no_cache $no_cache;
550 fastcgi_split_path_info ^(.+\.php)(/.+)$;
551 fastcgi_index index.php;
552 fastcgi_pass unix:${fpm.socket};
553 '';
554 };
555 "/ws/" = {
556 priority = 900;
557 proxyPass = "http://${cfg.settings.DAEMON_INTERFACE}:${builtins.toString cfg.port}/";
558 proxyWebsockets = true;
559 recommendedProxySettings = true;
560 extraConfig = /* nginx */ ''
561 proxy_set_header X-Forwarded-Proto $scheme;
562 proxy_redirect off;
563 '';
564 };
565 };
566 extraConfig = /* ngnix */ ''
567 index index.php;
568 '';
569 }
570 ];
571 };
572
573 mysql = mkIf (cfg.database.createLocally && cfg.database.type == "mysql") {
574 enable = mkDefault true;
575 package = mkDefault pkgs.mariadb;
576 ensureDatabases = [ cfg.database.name ];
577 ensureUsers = [{
578 name = cfg.user;
579 ensureDBOwnership = true;
580 }];
581 };
582
583 postgresql = mkIf (cfg.database.createLocally && cfg.database.type == "postgresql") {
584 enable = mkDefault true;
585 ensureDatabases = [ cfg.database.name ];
586 ensureUsers = [{
587 name = cfg.user;
588 ensureDBOwnership = true;
589 }];
590 authentication = ''
591 host ${cfg.database.name} ${cfg.database.user} localhost trust
592 '';
593 };
594
595 phpfpm.pools.${pool} =
596 let
597 socketOwner =
598 if (cfg.nginx != null)
599 then config.services.nginx.user
600 else cfg.user;
601 in
602 {
603 phpPackage = package.php;
604 user = cfg.user;
605 group = cfg.group;
606
607 phpOptions = ''
608 error_log = 'stderr'
609 log_errors = on
610 '';
611
612 settings = {
613 "listen.owner" = socketOwner;
614 "listen.group" = cfg.group;
615 "listen.mode" = "0660";
616 "catch_workers_output" = true;
617 } // cfg.poolConfig;
618 };
619 };
620
621 systemd = {
622 services.movim-data-setup = {
623 description = "Movim setup: .env file, databases init, cache reload";
624 wantedBy = [ "multi-user.target" ];
625 requiredBy = [ "${phpExecutionUnit}.service" ];
626 before = [ "${phpExecutionUnit}.service" ];
627 after = lib.optional cfg.database.createLocally dbService;
628 requires = lib.optional cfg.database.createLocally dbService;
629
630 serviceConfig = {
631 Type = "oneshot";
632 User = cfg.user;
633 Group = cfg.group;
634 UMask = "077";
635 } // lib.optionalAttrs (cfg.secretFile != null) {
636 LoadCredential = "env-secrets:${cfg.secretFile}";
637 };
638
639 script = ''
640 # Env vars
641 rm -f ${cfg.dataDir}/.env
642 cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
643 echo -e '\n' >> ${cfg.dataDir}/.env
644 if [[ -f "$CREDENTIALS_DIRECTORY/env-secrets" ]]; then
645 cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env
646 echo -e '\n' >> ${cfg.dataDir}/.env
647 fi
648
649 # Caches, logs
650 mkdir -p ${cfg.dataDir}/public/cache ${cfg.logDir} ${cfg.runtimeDir}/cache
651 chmod -R ug+rw ${cfg.dataDir}/public/cache
652 chmod -R ug+rw ${cfg.logDir}
653 chmod -R ug+rwx ${cfg.runtimeDir}/cache
654
655 # Migrations
656 MOVIM_VERSION="${package.version}"
657 if [[ ! -f "${cfg.dataDir}/.migration-version" ]] || [[ "$MOVIM_VERSION" != "$(<${cfg.dataDir}/.migration-version)" ]]; then
658 ${package}/bin/movim-composer movim:migrate && echo $MOVIM_VERSION > ${cfg.dataDir}/.migration-version
659 fi
660 ''
661 + lib.optionalString (podConfigFlags != "") (
662 let
663 flags = lib.concatStringsSep " "
664 ([ "--no-interaction" ]
665 ++ lib.optional cfg.debug "-vvv"
666 ++ lib.optional (!cfg.debug && cfg.verbose) "-v");
667 in
668 ''
669 ${lib.getExe package} config ${podConfigFlags}
670 ''
671 );
672 };
673
674 services.movim = {
675 description = "Movim daemon";
676 wantedBy = [ "multi-user.target" ];
677 after = [ "movim-data-setup.service" ];
678 requires = [ "movim-data-setup.service" ]
679 ++ lib.optional cfg.database.createLocally dbService;
680 environment = {
681 PUBLIC_URL = "//${cfg.domain}";
682 WS_PORT = builtins.toString cfg.port;
683 };
684
685 serviceConfig = {
686 User = cfg.user;
687 Group = cfg.group;
688 WorkingDirectory = "${package}/share/php/movim";
689 ExecStart = "${lib.getExe package} start";
690 };
691 };
692
693 services.${phpExecutionUnit} = {
694 after = [ "movim-data-setup.service" ];
695 requires = [ "movim-data-setup.service" ]
696 ++ lib.optional cfg.database.createLocally dbService;
697 };
698
699 tmpfiles.settings."10-movim" = with cfg; {
700 "${dataDir}".d = { inherit user group; mode = "0710"; };
701 "${dataDir}/public".d = { inherit user group; mode = "0750"; };
702 "${dataDir}/public/cache".d = { inherit user group; mode = "0750"; };
703 "${runtimeDir}".d = { inherit user group; mode = "0700"; };
704 "${runtimeDir}/cache".d = { inherit user group; mode = "0700"; };
705 "${logDir}".d = { inherit user group; mode = "0700"; };
706 };
707 };
708 };
709}