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