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 = mkPackageOption pkgs "wordpress" { };
108
109 uploadsDir = mkOption {
110 type = types.path;
111 default = "/var/lib/wordpress/${name}/uploads";
112 description = ''
113 This directory is used for uploads of pictures. The directory passed here is automatically
114 created and permissions adjusted as required.
115 '';
116 };
117
118 fontsDir = mkOption {
119 type = types.path;
120 default = "/var/lib/wordpress/${name}/fonts";
121 description = ''
122 This directory is used to download fonts from a remote location, e.g.
123 to host google fonts locally.
124 '';
125 };
126
127 plugins = mkOption {
128 type = with types; coercedTo
129 (listOf path)
130 (l: warn "setting this option with a list is deprecated"
131 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l))
132 (attrsOf path);
133 default = {};
134 description = ''
135 Path(s) to respective plugin(s) which are copied from the 'plugins' directory.
136
137 ::: {.note}
138 These plugins need to be packaged before use, see example.
139 :::
140 '';
141 example = literalExpression ''
142 {
143 inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin;
144 }
145 '';
146 };
147
148 themes = mkOption {
149 type = with types; coercedTo
150 (listOf path)
151 (l: warn "setting this option with a list is deprecated"
152 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l))
153 (attrsOf path);
154 default = { inherit (pkgs.wordpressPackages.themes) twentytwentythree; };
155 defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentythree; }";
156 description = ''
157 Path(s) to respective theme(s) which are copied from the 'theme' directory.
158
159 ::: {.note}
160 These themes need to be packaged before use, see example.
161 :::
162 '';
163 example = literalExpression ''
164 {
165 inherit (pkgs.wordpressPackages.themes) responsive-theme;
166 }
167 '';
168 };
169
170 languages = mkOption {
171 type = types.listOf types.path;
172 default = [];
173 description = ''
174 List of path(s) to respective language(s) which are copied from the 'languages' directory.
175 '';
176 example = literalExpression ''
177 [
178 # Let's package the German language.
179 # For other languages try to replace language and country code in the download URL with your desired one.
180 # Reference https://translate.wordpress.org for available translations and
181 # codes.
182 (pkgs.stdenv.mkDerivation {
183 name = "language-de";
184 src = pkgs.fetchurl {
185 url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz";
186 # Name is required to invalidate the hash when wordpress is updated
187 name = "wordpress-''${pkgs.wordpress.version}-language-de";
188 sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0=";
189 };
190 installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/";
191 })
192 ];
193 '';
194 };
195
196 database = {
197 host = mkOption {
198 type = types.str;
199 default = "localhost";
200 description = "Database host address.";
201 };
202
203 port = mkOption {
204 type = types.port;
205 default = 3306;
206 description = "Database host port.";
207 };
208
209 name = mkOption {
210 type = types.str;
211 default = "wordpress";
212 description = "Database name.";
213 };
214
215 user = mkOption {
216 type = types.str;
217 default = "wordpress";
218 description = "Database user.";
219 };
220
221 passwordFile = mkOption {
222 type = types.nullOr types.path;
223 default = null;
224 example = "/run/keys/wordpress-dbpassword";
225 description = ''
226 A file containing the password corresponding to
227 {option}`database.user`.
228 '';
229 };
230
231 tablePrefix = mkOption {
232 type = types.str;
233 default = "wp_";
234 description = ''
235 The $table_prefix is the value placed in the front of your database tables.
236 Change the value if you want to use something other than wp_ for your database
237 prefix. Typically this is changed if you are installing multiple WordPress blogs
238 in the same database.
239
240 See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>.
241 '';
242 };
243
244 socket = mkOption {
245 type = types.nullOr types.path;
246 default = null;
247 defaultText = literalExpression "/run/mysqld/mysqld.sock";
248 description = "Path to the unix socket file to use for authentication.";
249 };
250
251 createLocally = mkOption {
252 type = types.bool;
253 default = true;
254 description = "Create the database and database user locally.";
255 };
256 };
257
258 virtualHost = mkOption {
259 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
260 example = literalExpression ''
261 {
262 adminAddr = "webmaster@example.org";
263 forceSSL = true;
264 enableACME = true;
265 }
266 '';
267 description = ''
268 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
269 '';
270 };
271
272 poolConfig = mkOption {
273 type = with types; attrsOf (oneOf [ str int bool ]);
274 default = {
275 "pm" = "dynamic";
276 "pm.max_children" = 32;
277 "pm.start_servers" = 2;
278 "pm.min_spare_servers" = 2;
279 "pm.max_spare_servers" = 4;
280 "pm.max_requests" = 500;
281 };
282 description = ''
283 Options for the WordPress PHP pool. See the documentation on `php-fpm.conf`
284 for details on configuration directives.
285 '';
286 };
287
288 settings = mkOption {
289 type = types.attrsOf types.anything;
290 default = {};
291 description = ''
292 Structural Wordpress configuration.
293 Refer to <https://developer.wordpress.org/apis/wp-config-php>
294 for details and supported values.
295 '';
296 example = literalExpression ''
297 {
298 WP_DEFAULT_THEME = "twentytwentytwo";
299 WP_SITEURL = "https://example.org";
300 WP_HOME = "https://example.org";
301 WP_DEBUG = true;
302 WP_DEBUG_DISPLAY = true;
303 WPLANG = "de_DE";
304 FORCE_SSL_ADMIN = true;
305 AUTOMATIC_UPDATER_DISABLED = true;
306 }
307 '';
308 };
309
310 mergedConfig = mkOption {
311 readOnly = true;
312 default = mergeConfig config;
313 defaultText = literalExpression ''
314 {
315 DISALLOW_FILE_EDIT = true;
316 AUTOMATIC_UPDATER_DISABLED = true;
317 }
318 '';
319 description = ''
320 Read only representation of the final configuration.
321 '';
322 };
323
324 extraConfig = mkOption {
325 type = types.lines;
326 default = "";
327 description = ''
328 Any additional text to be appended to the wp-config.php
329 configuration file. This is a PHP script. For configuration
330 settings, see <https://codex.wordpress.org/Editing_wp-config.php>.
331
332 **Note**: Please pass structured settings via
333 `services.wordpress.sites.${name}.settings` instead.
334 '';
335 example = ''
336 @ini_set( 'log_errors', 'Off' );
337 @ini_set( 'display_errors', 'On' );
338 '';
339 };
340
341 };
342
343 config.virtualHost.hostName = mkDefault name;
344 };
345in
346{
347 # interface
348 options = {
349 services.wordpress = {
350
351 sites = mkOption {
352 type = types.attrsOf (types.submodule siteOpts);
353 default = {};
354 description = "Specification of one or more WordPress sites to serve";
355 };
356
357 webserver = mkOption {
358 type = types.enum [ "httpd" "nginx" "caddy" ];
359 default = "httpd";
360 description = ''
361 Whether to use apache2 or nginx for virtual host management.
362
363 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
364 See [](#opt-services.nginx.virtualHosts) for further information.
365
366 Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`.
367 See [](#opt-services.httpd.virtualHosts) for further information.
368 '';
369 };
370
371 };
372 };
373
374 # implementation
375 config = mkIf (eachSite != {}) (mkMerge [{
376
377 assertions =
378 (mapAttrsToList (hostName: cfg:
379 { assertion = cfg.database.createLocally -> cfg.database.user == user;
380 message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
381 }) eachSite) ++
382 (mapAttrsToList (hostName: cfg:
383 { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
384 message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
385 }) eachSite);
386
387
388 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
389 enable = true;
390 package = mkDefault pkgs.mariadb;
391 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
392 ensureUsers = mapAttrsToList (hostName: cfg:
393 { name = cfg.database.user;
394 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
395 }
396 ) eachSite;
397 };
398
399 services.phpfpm.pools = mapAttrs' (hostName: cfg: (
400 nameValuePair "wordpress-${hostName}" {
401 inherit user;
402 group = webserver.group;
403 settings = {
404 "listen.owner" = webserver.user;
405 "listen.group" = webserver.group;
406 } // cfg.poolConfig;
407 }
408 )) eachSite;
409
410 }
411
412 (mkIf (cfg.webserver == "httpd") {
413 services.httpd = {
414 enable = true;
415 extraModules = [ "proxy_fcgi" ];
416 virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
417 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
418 extraConfig = ''
419 <Directory "${pkg hostName cfg}/share/wordpress">
420 <FilesMatch "\.php$">
421 <If "-f %{REQUEST_FILENAME}">
422 SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
423 </If>
424 </FilesMatch>
425
426 # standard wordpress .htaccess contents
427 <IfModule mod_rewrite.c>
428 RewriteEngine On
429 RewriteBase /
430 RewriteRule ^index\.php$ - [L]
431 RewriteCond %{REQUEST_FILENAME} !-f
432 RewriteCond %{REQUEST_FILENAME} !-d
433 RewriteRule . /index.php [L]
434 </IfModule>
435
436 DirectoryIndex index.php
437 Require all granted
438 Options +FollowSymLinks -Indexes
439 </Directory>
440
441 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
442 <Files wp-config.php>
443 Require all denied
444 </Files>
445 '';
446 } ]) eachSite;
447 };
448 })
449
450 {
451 systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
452 "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
453 "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
454 "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
455 "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
456 "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
457 ]) eachSite);
458
459 systemd.services = mkMerge [
460 (mapAttrs' (hostName: cfg: (
461 nameValuePair "wordpress-init-${hostName}" {
462 wantedBy = [ "multi-user.target" ];
463 before = [ "phpfpm-wordpress-${hostName}.service" ];
464 after = optional cfg.database.createLocally "mysql.service";
465 script = secretsScript (stateDir hostName);
466
467 serviceConfig = {
468 Type = "oneshot";
469 User = user;
470 Group = webserver.group;
471 };
472 })) eachSite)
473
474 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
475 httpd.after = [ "mysql.service" ];
476 })
477 ];
478
479 users.users.${user} = {
480 group = webserver.group;
481 isSystemUser = true;
482 };
483 }
484
485 (mkIf (cfg.webserver == "nginx") {
486 services.nginx = {
487 enable = true;
488 virtualHosts = mapAttrs (hostName: cfg: {
489 serverName = mkDefault hostName;
490 root = "${pkg hostName cfg}/share/wordpress";
491 extraConfig = ''
492 index index.php;
493 '';
494 locations = {
495 "/" = {
496 priority = 200;
497 extraConfig = ''
498 try_files $uri $uri/ /index.php$is_args$args;
499 '';
500 };
501 "~ \\.php$" = {
502 priority = 500;
503 extraConfig = ''
504 fastcgi_split_path_info ^(.+\.php)(/.+)$;
505 fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket};
506 fastcgi_index index.php;
507 include "${config.services.nginx.package}/conf/fastcgi.conf";
508 fastcgi_param PATH_INFO $fastcgi_path_info;
509 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
510 # Mitigate https://httpoxy.org/ vulnerabilities
511 fastcgi_param HTTP_PROXY "";
512 fastcgi_intercept_errors off;
513 fastcgi_buffer_size 16k;
514 fastcgi_buffers 4 16k;
515 fastcgi_connect_timeout 300;
516 fastcgi_send_timeout 300;
517 fastcgi_read_timeout 300;
518 '';
519 };
520 "~ /\\." = {
521 priority = 800;
522 extraConfig = "deny all;";
523 };
524 "~* /(?:uploads|files)/.*\\.php$" = {
525 priority = 900;
526 extraConfig = "deny all;";
527 };
528 "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
529 priority = 1000;
530 extraConfig = ''
531 expires max;
532 log_not_found off;
533 '';
534 };
535 };
536 }) eachSite;
537 };
538 })
539
540 (mkIf (cfg.webserver == "caddy") {
541 services.caddy = {
542 enable = true;
543 virtualHosts = mapAttrs' (hostName: cfg: (
544 nameValuePair "http://${hostName}" {
545 extraConfig = ''
546 root * /${pkg hostName cfg}/share/wordpress
547 file_server
548
549 php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}
550
551 @uploads {
552 path_regexp path /uploads\/(.*)\.php
553 }
554 rewrite @uploads /
555
556 @wp-admin {
557 path not ^\/wp-admin/*
558 }
559 rewrite @wp-admin {path}/index.php?{query}
560 '';
561 }
562 )) eachSite;
563 };
564 })
565
566
567 ]);
568}