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