1{ config, pkgs, lib, ... }:
2
3let
4 inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types;
5 inherit (lib) any attrValues concatMapStringsSep flatten literalExample;
6 inherit (lib) mapAttrs mapAttrs' mapAttrsToList nameValuePair optional optionalAttrs optionalString;
7
8 eachSite = config.services.wordpress;
9 user = "wordpress";
10 group = config.services.httpd.group;
11 stateDir = hostName: "/var/lib/wordpress/${hostName}";
12
13 pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
14 pname = "wordpress-${hostName}";
15 version = src.version;
16 src = cfg.package;
17
18 installPhase = ''
19 mkdir -p $out
20 cp -r * $out/
21
22 # symlink the wordpress config
23 ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
24 # symlink uploads directory
25 ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
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) and theme(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 '';
38 };
39
40 wpConfig = hostName: cfg: pkgs.writeText "wp-config-${hostName}.php" ''
41 <?php
42 define('DB_NAME', '${cfg.database.name}');
43 define('DB_HOST', '${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}');
44 define('DB_USER', '${cfg.database.user}');
45 ${optionalString (cfg.database.passwordFile != null) "define('DB_PASSWORD', file_get_contents('${cfg.database.passwordFile}'));"}
46 define('DB_CHARSET', 'utf8');
47 $table_prefix = '${cfg.database.tablePrefix}';
48
49 require_once('${stateDir hostName}/secret-keys.php');
50
51 # wordpress is installed onto a read-only file system
52 define('DISALLOW_FILE_EDIT', true);
53 define('AUTOMATIC_UPDATER_DISABLED', true);
54
55 ${cfg.extraConfig}
56
57 if ( !defined('ABSPATH') )
58 define('ABSPATH', dirname(__FILE__) . '/');
59
60 require_once(ABSPATH . 'wp-settings.php');
61 ?>
62 '';
63
64 secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ];
65 secretsScript = hostStateDir: ''
66 if ! test -e "${hostStateDir}/secret-keys.php"; then
67 umask 0177
68 echo "<?php" >> "${hostStateDir}/secret-keys.php"
69 ${concatMapStringsSep "\n" (var: ''
70 echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
71 '') secretsVars}
72 echo "?>" >> "${hostStateDir}/secret-keys.php"
73 chmod 440 "${hostStateDir}/secret-keys.php"
74 fi
75 '';
76
77 siteOpts = { lib, name, ... }:
78 {
79 options = {
80 package = mkOption {
81 type = types.package;
82 default = pkgs.wordpress;
83 description = "Which WordPress package to use.";
84 };
85
86 uploadsDir = mkOption {
87 type = types.path;
88 default = "/var/lib/wordpress/${name}/uploads";
89 description = ''
90 This directory is used for uploads of pictures. The directory passed here is automatically
91 created and permissions adjusted as required.
92 '';
93 };
94
95 plugins = mkOption {
96 type = types.listOf types.path;
97 default = [];
98 description = ''
99 List of path(s) to respective plugin(s) which are copied from the 'plugins' directory.
100 <note><para>These plugins need to be packaged before use, see example.</para></note>
101 '';
102 example = ''
103 # Wordpress plugin 'embed-pdf-viewer' installation example
104 embedPdfViewerPlugin = pkgs.stdenv.mkDerivation {
105 name = "embed-pdf-viewer-plugin";
106 # Download the theme from the wordpress site
107 src = pkgs.fetchurl {
108 url = "https://downloads.wordpress.org/plugin/embed-pdf-viewer.2.0.3.zip";
109 sha256 = "1rhba5h5fjlhy8p05zf0p14c9iagfh96y91r36ni0rmk6y891lyd";
110 };
111 # We need unzip to build this package
112 nativeBuildInputs = [ pkgs.unzip ];
113 # Installing simply means copying all files to the output directory
114 installPhase = "mkdir -p $out; cp -R * $out/";
115 };
116
117 And then pass this theme to the themes list like this:
118 plugins = [ embedPdfViewerPlugin ];
119 '';
120 };
121
122 themes = mkOption {
123 type = types.listOf types.path;
124 default = [];
125 description = ''
126 List of path(s) to respective theme(s) which are copied from the 'theme' directory.
127 <note><para>These themes need to be packaged before use, see example.</para></note>
128 '';
129 example = ''
130 # Let's package the responsive theme
131 responsiveTheme = pkgs.stdenv.mkDerivation {
132 name = "responsive-theme";
133 # Download the theme from the wordpress site
134 src = pkgs.fetchurl {
135 url = "https://downloads.wordpress.org/theme/responsive.3.14.zip";
136 sha256 = "0rjwm811f4aa4q43r77zxlpklyb85q08f9c8ns2akcarrvj5ydx3";
137 };
138 # We need unzip to build this package
139 nativeBuildInputs = [ pkgs.unzip ];
140 # Installing simply means copying all files to the output directory
141 installPhase = "mkdir -p $out; cp -R * $out/";
142 };
143
144 And then pass this theme to the themes list like this:
145 themes = [ responsiveTheme ];
146 '';
147 };
148
149 database = {
150 host = mkOption {
151 type = types.str;
152 default = "localhost";
153 description = "Database host address.";
154 };
155
156 port = mkOption {
157 type = types.port;
158 default = 3306;
159 description = "Database host port.";
160 };
161
162 name = mkOption {
163 type = types.str;
164 default = "wordpress";
165 description = "Database name.";
166 };
167
168 user = mkOption {
169 type = types.str;
170 default = "wordpress";
171 description = "Database user.";
172 };
173
174 passwordFile = mkOption {
175 type = types.nullOr types.path;
176 default = null;
177 example = "/run/keys/wordpress-dbpassword";
178 description = ''
179 A file containing the password corresponding to
180 <option>database.user</option>.
181 '';
182 };
183
184 tablePrefix = mkOption {
185 type = types.str;
186 default = "wp_";
187 description = ''
188 The $table_prefix is the value placed in the front of your database tables.
189 Change the value if you want to use something other than wp_ for your database
190 prefix. Typically this is changed if you are installing multiple WordPress blogs
191 in the same database.
192
193 See <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php#table_prefix'/>.
194 '';
195 };
196
197 socket = mkOption {
198 type = types.nullOr types.path;
199 default = null;
200 defaultText = "/run/mysqld/mysqld.sock";
201 description = "Path to the unix socket file to use for authentication.";
202 };
203
204 createLocally = mkOption {
205 type = types.bool;
206 default = true;
207 description = "Create the database and database user locally.";
208 };
209 };
210
211 virtualHost = mkOption {
212 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
213 example = literalExample ''
214 {
215 adminAddr = "webmaster@example.org";
216 forceSSL = true;
217 enableACME = true;
218 }
219 '';
220 description = ''
221 Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>.
222 '';
223 };
224
225 poolConfig = mkOption {
226 type = with types; attrsOf (oneOf [ str int bool ]);
227 default = {
228 "pm" = "dynamic";
229 "pm.max_children" = 32;
230 "pm.start_servers" = 2;
231 "pm.min_spare_servers" = 2;
232 "pm.max_spare_servers" = 4;
233 "pm.max_requests" = 500;
234 };
235 description = ''
236 Options for the WordPress PHP pool. See the documentation on <literal>php-fpm.conf</literal>
237 for details on configuration directives.
238 '';
239 };
240
241 extraConfig = mkOption {
242 type = types.lines;
243 default = "";
244 description = ''
245 Any additional text to be appended to the wp-config.php
246 configuration file. This is a PHP script. For configuration
247 settings, see <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php'/>.
248 '';
249 example = ''
250 define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds
251 '';
252 };
253 };
254
255 config.virtualHost.hostName = mkDefault name;
256 };
257in
258{
259 # interface
260 options = {
261 services.wordpress = mkOption {
262 type = types.attrsOf (types.submodule siteOpts);
263 default = {};
264 description = "Specification of one or more WordPress sites to serve via Apache.";
265 };
266 };
267
268 # implementation
269 config = mkIf (eachSite != {}) {
270
271 assertions = mapAttrsToList (hostName: cfg:
272 { assertion = cfg.database.createLocally -> cfg.database.user == user;
273 message = "services.wordpress.${hostName}.database.user must be ${user} if the database is to be automatically provisioned";
274 }
275 ) eachSite;
276
277 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
278 enable = true;
279 package = mkDefault pkgs.mariadb;
280 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
281 ensureUsers = mapAttrsToList (hostName: cfg:
282 { name = cfg.database.user;
283 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
284 }
285 ) eachSite;
286 };
287
288 services.phpfpm.pools = mapAttrs' (hostName: cfg: (
289 nameValuePair "wordpress-${hostName}" {
290 inherit user group;
291 settings = {
292 "listen.owner" = config.services.httpd.user;
293 "listen.group" = config.services.httpd.group;
294 } // cfg.poolConfig;
295 }
296 )) eachSite;
297
298 services.httpd = {
299 enable = true;
300 extraModules = [ "proxy_fcgi" ];
301 virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
302 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
303 extraConfig = ''
304 <Directory "${pkg hostName cfg}/share/wordpress">
305 <FilesMatch "\.php$">
306 <If "-f %{REQUEST_FILENAME}">
307 SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
308 </If>
309 </FilesMatch>
310
311 # standard wordpress .htaccess contents
312 <IfModule mod_rewrite.c>
313 RewriteEngine On
314 RewriteBase /
315 RewriteRule ^index\.php$ - [L]
316 RewriteCond %{REQUEST_FILENAME} !-f
317 RewriteCond %{REQUEST_FILENAME} !-d
318 RewriteRule . /index.php [L]
319 </IfModule>
320
321 DirectoryIndex index.php
322 Require all granted
323 Options +FollowSymLinks
324 </Directory>
325
326 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
327 <Files wp-config.php>
328 Require all denied
329 </Files>
330 '';
331 } ]) eachSite;
332 };
333
334 systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
335 "d '${stateDir hostName}' 0750 ${user} ${group} - -"
336 "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
337 "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
338 ]) eachSite);
339
340 systemd.services = mkMerge [
341 (mapAttrs' (hostName: cfg: (
342 nameValuePair "wordpress-init-${hostName}" {
343 wantedBy = [ "multi-user.target" ];
344 before = [ "phpfpm-wordpress-${hostName}.service" ];
345 after = optional cfg.database.createLocally "mysql.service";
346 script = secretsScript (stateDir hostName);
347
348 serviceConfig = {
349 Type = "oneshot";
350 User = user;
351 Group = group;
352 };
353 })) eachSite)
354
355 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
356 httpd.after = [ "mysql.service" ];
357 })
358 ];
359
360 users.users.${user} = {
361 group = group;
362 isSystemUser = true;
363 };
364
365 };
366}