1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.wordpress;
12 eachSite = cfg.sites;
13 user = "wordpress";
14 webserver = config.services.${cfg.webserver};
15 stateDir = hostName: "/var/lib/wordpress/${hostName}";
16
17 pkg =
18 hostName: cfg:
19 pkgs.stdenv.mkDerivation rec {
20 pname = "wordpress-${hostName}";
21 version = src.version;
22 src = cfg.package;
23
24 installPhase = ''
25 mkdir -p $out
26 cp -r * $out/
27
28 # symlink the wordpress config
29 ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
30 # symlink uploads directory
31 ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
32 ln -s ${cfg.fontsDir} $out/share/wordpress/wp-content/fonts
33
34 # https://github.com/NixOS/nixpkgs/pull/53399
35 #
36 # Symlinking works for most plugins and themes, but Avada, for instance, fails to
37 # understand the symlink, causing its file path stripping to fail. This results in
38 # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
39 # Since hard linking directories is not allowed, copying is the next best thing.
40
41 # copy additional plugin(s), theme(s) and language(s)
42 ${concatStringsSep "\n" (
43 mapAttrsToList (
44 name: theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${name}"
45 ) cfg.themes
46 )}
47 ${concatStringsSep "\n" (
48 mapAttrsToList (
49 name: plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${name}"
50 ) cfg.plugins
51 )}
52 ${concatMapStringsSep "\n" (
53 language: "cp -r ${language} $out/share/wordpress/wp-content/languages/"
54 ) cfg.languages}
55 '';
56 };
57
58 mergeConfig =
59 cfg:
60 {
61 # wordpress is installed onto a read-only file system
62 DISALLOW_FILE_EDIT = true;
63 AUTOMATIC_UPDATER_DISABLED = true;
64 DB_NAME = cfg.database.name;
65 DB_HOST = "${cfg.database.host}:${
66 if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port
67 }";
68 DB_USER = cfg.database.user;
69 DB_CHARSET = "utf8";
70 # Always set DB_PASSWORD even when passwordFile is not set. This is the
71 # default Wordpress behaviour.
72 DB_PASSWORD =
73 if (cfg.database.passwordFile != null) then { _file = cfg.database.passwordFile; } else "";
74 }
75 // cfg.settings;
76
77 wpConfig =
78 hostName: cfg:
79 let
80 conf_gen = c: mapAttrsToList (k: v: "define('${k}', ${mkPhpValue v});") cfg.mergedConfig;
81 in
82 pkgs.writeTextFile {
83 name = "wp-config-${hostName}.php";
84 text = ''
85 <?php
86 $table_prefix = '${cfg.database.tablePrefix}';
87
88 require_once('${stateDir hostName}/secret-keys.php');
89
90 ${cfg.extraConfig}
91 ${concatStringsSep "\n" (conf_gen cfg.mergedConfig)}
92
93 if ( !defined('ABSPATH') )
94 define('ABSPATH', dirname(__FILE__) . '/');
95
96 require_once(ABSPATH . 'wp-settings.php');
97 ?>
98 '';
99 checkPhase = "${pkgs.php}/bin/php --syntax-check $target";
100 };
101
102 mkPhpValue =
103 v:
104 let
105 isHasAttr = s: isAttrs v && hasAttr s v;
106 # "you're escaped" -> "'you\'re escaped'"
107 # https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.single
108 toPhpString = s: "'${escape [ "'" "\\" ] s}'";
109 in
110 if isString v then
111 toPhpString v
112 # NOTE: If any value contains a , (comma) this will not get escaped
113 else if isList v && strings.isConvertibleWithToString v then
114 toPhpString (concatMapStringsSep "," toString v)
115 else if isInt v then
116 toString v
117 else if isBool v then
118 boolToString v
119 else if isHasAttr "_file" then
120 "trim(file_get_contents(${toPhpString (toString v._file)}))"
121 else if isHasAttr "_raw" then
122 v._raw
123 else
124 abort "The Wordpress config value ${lib.generators.toPretty { } v} can not be encoded.";
125
126 secretsVars = [
127 "AUTH_KEY"
128 "SECURE_AUTH_KEY"
129 "LOGGED_IN_KEY"
130 "NONCE_KEY"
131 "AUTH_SALT"
132 "SECURE_AUTH_SALT"
133 "LOGGED_IN_SALT"
134 "NONCE_SALT"
135 ];
136 secretsScript = hostStateDir: ''
137 # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839
138 grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php"
139 if ! test -e "${hostStateDir}/secret-keys.php"; then
140 umask 0177
141 echo "<?php" >> "${hostStateDir}/secret-keys.php"
142 ${concatMapStringsSep "\n" (var: ''
143 echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
144 '') secretsVars}
145 echo "?>" >> "${hostStateDir}/secret-keys.php"
146 chmod 440 "${hostStateDir}/secret-keys.php"
147 fi
148 '';
149
150 siteOpts =
151 {
152 lib,
153 name,
154 config,
155 ...
156 }:
157 {
158 options = {
159 package = mkPackageOption pkgs "wordpress" { };
160
161 uploadsDir = mkOption {
162 type = types.path;
163 default = "/var/lib/wordpress/${name}/uploads";
164 description = ''
165 This directory is used for uploads of pictures. The directory passed here is automatically
166 created and permissions adjusted as required.
167 '';
168 };
169
170 fontsDir = mkOption {
171 type = types.path;
172 default = "/var/lib/wordpress/${name}/fonts";
173 description = ''
174 This directory is used to download fonts from a remote location, e.g.
175 to host google fonts locally.
176 '';
177 };
178
179 plugins = mkOption {
180 type =
181 with types;
182 coercedTo (listOf path) (
183 l:
184 warn "setting this option with a list is deprecated" listToAttrs (
185 map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l
186 )
187 ) (attrsOf path);
188 default = { };
189 description = ''
190 Path(s) to respective plugin(s) which are copied from the 'plugins' directory.
191
192 ::: {.note}
193 These plugins need to be packaged before use, see example.
194 :::
195 '';
196 example = literalExpression ''
197 {
198 inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin;
199 }
200 '';
201 };
202
203 themes = mkOption {
204 type =
205 with types;
206 coercedTo (listOf path) (
207 l:
208 warn "setting this option with a list is deprecated" listToAttrs (
209 map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l
210 )
211 ) (attrsOf path);
212 default = { inherit (pkgs.wordpressPackages.themes) twentytwentyfive; };
213 defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentyfive; }";
214 description = ''
215 Path(s) to respective theme(s) which are copied from the 'theme' directory.
216
217 ::: {.note}
218 These themes need to be packaged before use, see example.
219 :::
220 '';
221 example = literalExpression ''
222 {
223 inherit (pkgs.wordpressPackages.themes) responsive-theme;
224 }
225 '';
226 };
227
228 languages = mkOption {
229 type = types.listOf types.path;
230 default = [ ];
231 description = ''
232 List of path(s) to respective language(s) which are copied from the 'languages' directory.
233 '';
234 example = literalExpression ''
235 [
236 # Let's package the German language.
237 # For other languages try to replace language and country code in the download URL with your desired one.
238 # Reference https://translate.wordpress.org for available translations and
239 # codes.
240 (pkgs.stdenv.mkDerivation {
241 name = "language-de";
242 src = pkgs.fetchurl {
243 url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz";
244 # Name is required to invalidate the hash when wordpress is updated
245 name = "wordpress-''${pkgs.wordpress.version}-language-de";
246 sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0=";
247 };
248 installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/";
249 })
250 ];
251 '';
252 };
253
254 database = {
255 host = mkOption {
256 type = types.str;
257 default = "localhost";
258 description = "Database host address.";
259 };
260
261 port = mkOption {
262 type = types.port;
263 default = 3306;
264 description = "Database host port.";
265 };
266
267 name = mkOption {
268 type = types.str;
269 default = "wordpress";
270 description = "Database name.";
271 };
272
273 user = mkOption {
274 type = types.str;
275 default = "wordpress";
276 description = "Database user.";
277 };
278
279 passwordFile = mkOption {
280 type = types.nullOr types.path;
281 default = null;
282 example = "/run/keys/wordpress-dbpassword";
283 description = ''
284 A file containing the password corresponding to
285 {option}`database.user`.
286 '';
287 };
288
289 tablePrefix = mkOption {
290 type = types.str;
291 default = "wp_";
292 description = ''
293 The $table_prefix is the value placed in the front of your database tables.
294 Change the value if you want to use something other than wp_ for your database
295 prefix. Typically this is changed if you are installing multiple WordPress blogs
296 in the same database.
297
298 See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>.
299 '';
300 };
301
302 socket = mkOption {
303 type = types.nullOr types.path;
304 default = null;
305 defaultText = literalExpression "/run/mysqld/mysqld.sock";
306 description = "Path to the unix socket file to use for authentication.";
307 };
308
309 createLocally = mkOption {
310 type = types.bool;
311 default = true;
312 description = "Create the database and database user locally.";
313 };
314 };
315
316 virtualHost = mkOption {
317 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
318 example = literalExpression ''
319 {
320 adminAddr = "webmaster@example.org";
321 forceSSL = true;
322 enableACME = true;
323 }
324 '';
325 description = ''
326 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
327 '';
328 };
329
330 poolConfig = mkOption {
331 type =
332 with types;
333 attrsOf (oneOf [
334 str
335 int
336 bool
337 ]);
338 default = {
339 "pm" = "dynamic";
340 "pm.max_children" = 32;
341 "pm.start_servers" = 2;
342 "pm.min_spare_servers" = 2;
343 "pm.max_spare_servers" = 4;
344 "pm.max_requests" = 500;
345 };
346 description = ''
347 Options for the WordPress PHP pool. See the documentation on `php-fpm.conf`
348 for details on configuration directives.
349 '';
350 };
351
352 settings = mkOption {
353 type = types.attrsOf types.anything;
354 default = { };
355 description = ''
356 Structural Wordpress configuration.
357 Refer to <https://developer.wordpress.org/apis/wp-config-php>
358 for details and supported values.
359 '';
360 example = literalExpression ''
361 {
362 WP_DEFAULT_THEME = "twentytwentytwo";
363 WP_SITEURL = "https://example.org";
364 WP_HOME = "https://example.org";
365 WP_DEBUG = true;
366 WP_DEBUG_DISPLAY = true;
367 WPLANG = "de_DE";
368 FORCE_SSL_ADMIN = true;
369 AUTOMATIC_UPDATER_DISABLED = true;
370 }
371 '';
372 };
373
374 mergedConfig = mkOption {
375 readOnly = true;
376 default = mergeConfig config;
377 defaultText = literalExpression ''
378 {
379 DISALLOW_FILE_EDIT = true;
380 AUTOMATIC_UPDATER_DISABLED = true;
381 }
382 '';
383 description = ''
384 Read only representation of the final configuration.
385 '';
386 };
387
388 extraConfig = mkOption {
389 type = types.lines;
390 default = "";
391 description = ''
392 Any additional text to be appended to the wp-config.php
393 configuration file. This is a PHP script. For configuration
394 settings, see <https://codex.wordpress.org/Editing_wp-config.php>.
395
396 **Note**: Please pass structured settings via
397 `services.wordpress.sites.${name}.settings` instead.
398 '';
399 example = ''
400 @ini_set( 'log_errors', 'Off' );
401 @ini_set( 'display_errors', 'On' );
402 '';
403 };
404
405 };
406
407 config.virtualHost.hostName = mkDefault name;
408 };
409in
410{
411 # interface
412 options = {
413 services.wordpress = {
414
415 sites = mkOption {
416 type = types.attrsOf (types.submodule siteOpts);
417 default = { };
418 description = "Specification of one or more WordPress sites to serve";
419 };
420
421 webserver = mkOption {
422 type = types.enum [
423 "httpd"
424 "nginx"
425 "caddy"
426 ];
427 default = "httpd";
428 description = ''
429 Whether to use apache2 or nginx for virtual host management.
430
431 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
432 See [](#opt-services.nginx.virtualHosts) for further information.
433
434 Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`.
435 See [](#opt-services.httpd.virtualHosts) for further information.
436 '';
437 };
438
439 };
440 };
441
442 # implementation
443 config = mkIf (eachSite != { }) (mkMerge [
444 {
445
446 assertions =
447 (mapAttrsToList (hostName: cfg: {
448 assertion = cfg.database.createLocally -> cfg.database.user == user;
449 message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
450 }) eachSite)
451 ++ (mapAttrsToList (hostName: cfg: {
452 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
453 message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
454 }) eachSite);
455
456 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
457 enable = true;
458 package = mkDefault pkgs.mariadb;
459 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
460 ensureUsers = mapAttrsToList (hostName: cfg: {
461 name = cfg.database.user;
462 ensurePermissions = {
463 "${cfg.database.name}.*" = "ALL PRIVILEGES";
464 };
465 }) eachSite;
466 };
467
468 services.phpfpm.pools = mapAttrs' (
469 hostName: cfg:
470 (nameValuePair "wordpress-${hostName}" {
471 inherit user;
472 group = webserver.group;
473 settings = {
474 "listen.owner" = webserver.user;
475 "listen.group" = webserver.group;
476 } // cfg.poolConfig;
477 })
478 ) eachSite;
479
480 }
481
482 (mkIf (cfg.webserver == "httpd") {
483 services.httpd = {
484 enable = true;
485 extraModules = [ "proxy_fcgi" ];
486 virtualHosts = mapAttrs (
487 hostName: cfg:
488 mkMerge [
489 cfg.virtualHost
490 {
491 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
492 extraConfig = ''
493 <Directory "${pkg hostName cfg}/share/wordpress">
494 <FilesMatch "\.php$">
495 <If "-f %{REQUEST_FILENAME}">
496 SetHandler "proxy:unix:${
497 config.services.phpfpm.pools."wordpress-${hostName}".socket
498 }|fcgi://localhost/"
499 </If>
500 </FilesMatch>
501
502 # standard wordpress .htaccess contents
503 <IfModule mod_rewrite.c>
504 RewriteEngine On
505 RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
506 RewriteBase /
507 RewriteRule ^index\.php$ - [L]
508 RewriteCond %{REQUEST_FILENAME} !-f
509 RewriteCond %{REQUEST_FILENAME} !-d
510 RewriteRule . /index.php [L]
511 </IfModule>
512
513 DirectoryIndex index.php
514 Require all granted
515 Options +FollowSymLinks -Indexes
516 </Directory>
517
518 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
519 <Files wp-config.php>
520 Require all denied
521 </Files>
522 '';
523 }
524 ]
525 ) eachSite;
526 };
527 })
528
529 {
530 systemd.tmpfiles.rules = flatten (
531 mapAttrsToList (hostName: cfg: [
532 "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
533 "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
534 "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
535 "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
536 "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
537 ]) eachSite
538 );
539
540 systemd.services = mkMerge [
541 (mapAttrs' (
542 hostName: cfg:
543 (nameValuePair "wordpress-init-${hostName}" {
544 wantedBy = [ "multi-user.target" ];
545 before = [ "phpfpm-wordpress-${hostName}.service" ];
546 after = optional cfg.database.createLocally "mysql.service";
547 script = secretsScript (stateDir hostName);
548
549 serviceConfig = {
550 Type = "oneshot";
551 User = user;
552 Group = webserver.group;
553 };
554 })
555 ) eachSite)
556
557 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
558 httpd.after = [ "mysql.service" ];
559 })
560 ];
561
562 users.users.${user} = {
563 group = webserver.group;
564 isSystemUser = true;
565 };
566 }
567
568 (mkIf (cfg.webserver == "nginx") {
569 services.nginx = {
570 enable = true;
571 virtualHosts = mapAttrs (hostName: cfg: {
572 serverName = mkDefault hostName;
573 root = "${pkg hostName cfg}/share/wordpress";
574 extraConfig = ''
575 index index.php;
576 '';
577 locations = {
578 "/" = {
579 priority = 200;
580 extraConfig = ''
581 try_files $uri $uri/ /index.php$is_args$args;
582 '';
583 };
584 "~ \\.php$" = {
585 priority = 500;
586 extraConfig = ''
587 fastcgi_split_path_info ^(.+\.php)(/.+)$;
588 fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket};
589 fastcgi_index index.php;
590 include "${config.services.nginx.package}/conf/fastcgi.conf";
591 fastcgi_param PATH_INFO $fastcgi_path_info;
592 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
593 # Mitigate https://httpoxy.org/ vulnerabilities
594 fastcgi_param HTTP_PROXY "";
595 fastcgi_intercept_errors off;
596 fastcgi_buffer_size 16k;
597 fastcgi_buffers 4 16k;
598 fastcgi_connect_timeout 300;
599 fastcgi_send_timeout 300;
600 fastcgi_read_timeout 300;
601 '';
602 };
603 "~ /\\." = {
604 priority = 800;
605 extraConfig = "deny all;";
606 };
607 "~* /(?:uploads|files)/.*\\.php$" = {
608 priority = 900;
609 extraConfig = "deny all;";
610 };
611 "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
612 priority = 1000;
613 extraConfig = ''
614 expires max;
615 log_not_found off;
616 '';
617 };
618 };
619 }) eachSite;
620 };
621 })
622
623 (mkIf (cfg.webserver == "caddy") {
624 services.caddy = {
625 enable = true;
626 virtualHosts = mapAttrs' (
627 hostName: cfg:
628 (nameValuePair hostName {
629 extraConfig = ''
630 root * /${pkg hostName cfg}/share/wordpress
631 file_server
632
633 php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}
634
635 @uploads {
636 path_regexp path /uploads\/(.*)\.php
637 }
638 rewrite @uploads /
639
640 @wp-admin {
641 path not ^\/wp-admin/*
642 }
643 rewrite @wp-admin {path}/index.php?{query}
644 '';
645 })
646 ) eachSite;
647 };
648 })
649
650 ]);
651}