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