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