nixpkgs/nixos/modules/services/web-apps/wordpress.nix
Colin Arnott 66e0e5ad74
nixos/wordpress: plugins and themes as attrs
In an effort to better encode version strings and use descriptive pnames
that do not conflict with top level pkgs, we currently use
wordpress-${type}-${pname} for pname. This is good for the nix store,
but when we synthesize the wordpress derivation in our module, we reuse
this pname for the output directory.

Internally wordpress can handle this fine, since plugins must register
via php, not directory. Unfortunately, many plugins like civicrm and
wpforms-lite are designed to rely upon the name of their install
directory for homing or discovery.

As such, we should follow both the upstream convention and
services.nextcloud.extraApps and use an attribute set for these options.
This allows us to not have to deal with the implementation details of
plugins and themes, which differ from official and third party, but also
give users the option to override the install location. The only issue
is that it breaks the current api.
2023-01-27 15:24:19 +00:00

573 lines
20 KiB
Nix

{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.wordpress;
eachSite = cfg.sites;
user = "wordpress";
webserver = config.services.${cfg.webserver};
stateDir = hostName: "/var/lib/wordpress/${hostName}";
pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
pname = "wordpress-${hostName}";
version = src.version;
src = cfg.package;
installPhase = ''
mkdir -p $out
cp -r * $out/
# symlink the wordpress config
ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
# symlink uploads directory
ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
ln -s ${cfg.fontsDir} $out/share/wordpress/wp-content/fonts
# https://github.com/NixOS/nixpkgs/pull/53399
#
# Symlinking works for most plugins and themes, but Avada, for instance, fails to
# understand the symlink, causing its file path stripping to fail. This results in
# requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
# Since hard linking directories is not allowed, copying is the next best thing.
# copy additional plugin(s), theme(s) and language(s)
${concatStringsSep "\n" (mapAttrsToList (name: theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${name}") cfg.themes)}
${concatStringsSep "\n" (mapAttrsToList (name: plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${name}") cfg.plugins)}
${concatMapStringsSep "\n" (language: "cp -r ${language} $out/share/wordpress/wp-content/languages/") cfg.languages}
'';
};
mergeConfig = cfg: {
# wordpress is installed onto a read-only file system
DISALLOW_FILE_EDIT = true;
AUTOMATIC_UPDATER_DISABLED = true;
DB_NAME = cfg.database.name;
DB_HOST = "${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}";
DB_USER = cfg.database.user;
DB_CHARSET = "utf8";
# Always set DB_PASSWORD even when passwordFile is not set. This is the
# default Wordpress behaviour.
DB_PASSWORD = if (cfg.database.passwordFile != null) then { _file = cfg.database.passwordFile; } else "";
} // cfg.settings;
wpConfig = hostName: cfg: let
conf_gen = c: mapAttrsToList (k: v: "define('${k}', ${mkPhpValue v});") cfg.mergedConfig;
in pkgs.writeTextFile {
name = "wp-config-${hostName}.php";
text = ''
<?php
$table_prefix = '${cfg.database.tablePrefix}';
require_once('${stateDir hostName}/secret-keys.php');
${cfg.extraConfig}
${concatStringsSep "\n" (conf_gen cfg.mergedConfig)}
if ( !defined('ABSPATH') )
define('ABSPATH', dirname(__FILE__) . '/');
require_once(ABSPATH . 'wp-settings.php');
?>
'';
checkPhase = "${pkgs.php81}/bin/php --syntax-check $target";
};
mkPhpValue = v: let
isHasAttr = s: isAttrs v && hasAttr s v;
in
if isString v then escapeShellArg v
# NOTE: If any value contains a , (comma) this will not get escaped
else if isList v && any lib.strings.isCoercibleToString v then escapeShellArg (concatMapStringsSep "," toString v)
else if isInt v then toString v
else if isBool v then boolToString v
else if isHasAttr "_file" then "trim(file_get_contents(${lib.escapeShellArg v._file}))"
else if isHasAttr "_raw" then v._raw
else abort "The Wordpress config value ${lib.generators.toPretty {} v} can not be encoded."
;
secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ];
secretsScript = hostStateDir: ''
# The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839
grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php"
if ! test -e "${hostStateDir}/secret-keys.php"; then
umask 0177
echo "<?php" >> "${hostStateDir}/secret-keys.php"
${concatMapStringsSep "\n" (var: ''
echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
'') secretsVars}
echo "?>" >> "${hostStateDir}/secret-keys.php"
chmod 440 "${hostStateDir}/secret-keys.php"
fi
'';
siteOpts = { lib, name, config, ... }:
{
options = {
package = mkOption {
type = types.package;
default = pkgs.wordpress;
defaultText = literalExpression "pkgs.wordpress";
description = lib.mdDoc "Which WordPress package to use.";
};
uploadsDir = mkOption {
type = types.path;
default = "/var/lib/wordpress/${name}/uploads";
description = lib.mdDoc ''
This directory is used for uploads of pictures. The directory passed here is automatically
created and permissions adjusted as required.
'';
};
fontsDir = mkOption {
type = types.path;
default = "/var/lib/wordpress/${name}/fonts";
description = lib.mdDoc ''
This directory is used to download fonts from a remote location, e.g.
to host google fonts locally.
'';
};
plugins = mkOption {
type = with types; coercedTo
(listOf path)
(l: warn "setting this option with a list is deprecated"
listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l))
(attrsOf path);
default = {};
description = lib.mdDoc ''
Path(s) to respective plugin(s) which are copied from the 'plugins' directory.
::: {.note}
These plugins need to be packaged before use, see example.
:::
'';
example = literalExpression ''
{
inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin;
}
'';
};
themes = mkOption {
type = with types; coercedTo
(listOf path)
(l: warn "setting this option with a list is deprecated"
listToAttrs (map (p: nameValuePair (p.name or throw "${p} does not have a name") p) l))
(attrsOf path);
default = {};
description = lib.mdDoc ''
Path(s) to respective theme(s) which are copied from the 'theme' directory.
::: {.note}
These themes need to be packaged before use, see example.
:::
'';
example = literalExpression ''
{
inherit (pkgs.wordpressPackages.themes) responsive-theme;
}
'';
};
languages = mkOption {
type = types.listOf types.path;
default = [];
description = lib.mdDoc ''
List of path(s) to respective language(s) which are copied from the 'languages' directory.
'';
example = literalExpression ''
[(
# Let's package the German language.
# For other languages try to replace language and country code in the download URL with your desired one.
# Reference https://translate.wordpress.org for available translations and
# codes.
language-de = pkgs.stdenv.mkDerivation {
name = "language-de";
src = pkgs.fetchurl {
url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz";
# Name is required to invalidate the hash when wordpress is updated
name = "wordpress-''${pkgs.wordpress.version}-language-de"
sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0=";
};
installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/";
};
)];
'';
};
database = {
host = mkOption {
type = types.str;
default = "localhost";
description = lib.mdDoc "Database host address.";
};
port = mkOption {
type = types.port;
default = 3306;
description = lib.mdDoc "Database host port.";
};
name = mkOption {
type = types.str;
default = "wordpress";
description = lib.mdDoc "Database name.";
};
user = mkOption {
type = types.str;
default = "wordpress";
description = lib.mdDoc "Database user.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/wordpress-dbpassword";
description = lib.mdDoc ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
tablePrefix = mkOption {
type = types.str;
default = "wp_";
description = lib.mdDoc ''
The $table_prefix is the value placed in the front of your database tables.
Change the value if you want to use something other than wp_ for your database
prefix. Typically this is changed if you are installing multiple WordPress blogs
in the same database.
See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>.
'';
};
socket = mkOption {
type = types.nullOr types.path;
default = null;
defaultText = literalExpression "/run/mysqld/mysqld.sock";
description = lib.mdDoc "Path to the unix socket file to use for authentication.";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = lib.mdDoc "Create the database and database user locally.";
};
};
virtualHost = mkOption {
type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
example = literalExpression ''
{
adminAddr = "webmaster@example.org";
forceSSL = true;
enableACME = true;
}
'';
description = lib.mdDoc ''
Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
'';
};
poolConfig = mkOption {
type = with types; attrsOf (oneOf [ str int bool ]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = lib.mdDoc ''
Options for the WordPress PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
settings = mkOption {
type = types.attrsOf types.anything;
default = {};
description = lib.mdDoc ''
Structural Wordpress configuration.
Refer to <https://developer.wordpress.org/apis/wp-config-php>
for details and supported values.
'';
example = literalExpression ''
{
WP_DEFAULT_THEME = "twentytwentytwo";
WP_SITEURL = "https://example.org";
WP_HOME = "https://example.org";
WP_DEBUG = true;
WP_DEBUG_DISPLAY = true;
WPLANG = "de_DE";
FORCE_SSL_ADMIN = true;
AUTOMATIC_UPDATER_DISABLED = true;
}
'';
};
mergedConfig = mkOption {
readOnly = true;
default = mergeConfig config;
defaultText = literalExpression ''
{
DISALLOW_FILE_EDIT = true;
AUTOMATIC_UPDATER_DISABLED = true;
}
'';
description = lib.mdDoc ''
Read only representation of the final configuration.
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = lib.mdDoc ''
Any additional text to be appended to the wp-config.php
configuration file. This is a PHP script. For configuration
settings, see <https://codex.wordpress.org/Editing_wp-config.php>.
**Note**: Please pass structured settings via
`services.wordpress.sites.${name}.settings` instead.
'';
example = ''
@ini_set( 'log_errors', 'Off' );
@ini_set( 'display_errors', 'On' );
'';
};
};
config.virtualHost.hostName = mkDefault name;
};
in
{
# interface
options = {
services.wordpress = {
sites = mkOption {
type = types.attrsOf (types.submodule siteOpts);
default = {};
description = lib.mdDoc "Specification of one or more WordPress sites to serve";
};
webserver = mkOption {
type = types.enum [ "httpd" "nginx" "caddy" ];
default = "httpd";
description = lib.mdDoc ''
Whether to use apache2 or nginx for virtual host management.
Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
See [](#opt-services.nginx.virtualHosts) for further information.
Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`.
See [](#opt-services.httpd.virtualHosts) for further information.
'';
};
};
};
# implementation
config = mkIf (eachSite != {}) (mkMerge [{
assertions =
(mapAttrsToList (hostName: cfg:
{ assertion = cfg.database.createLocally -> cfg.database.user == user;
message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
}) eachSite) ++
(mapAttrsToList (hostName: cfg:
{ assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
}) eachSite);
services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
ensureUsers = mapAttrsToList (hostName: cfg:
{ name = cfg.database.user;
ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
}
) eachSite;
};
services.phpfpm.pools = mapAttrs' (hostName: cfg: (
nameValuePair "wordpress-${hostName}" {
inherit user;
group = webserver.group;
settings = {
"listen.owner" = webserver.user;
"listen.group" = webserver.group;
} // cfg.poolConfig;
}
)) eachSite;
}
(mkIf (cfg.webserver == "httpd") {
services.httpd = {
enable = true;
extraModules = [ "proxy_fcgi" ];
virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
extraConfig = ''
<Directory "${pkg hostName cfg}/share/wordpress">
<FilesMatch "\.php$">
<If "-f %{REQUEST_FILENAME}">
SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
</If>
</FilesMatch>
# standard wordpress .htaccess contents
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
DirectoryIndex index.php
Require all granted
Options +FollowSymLinks -Indexes
</Directory>
# https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
<Files wp-config.php>
Require all denied
</Files>
'';
} ]) eachSite;
};
})
{
systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
"d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
"Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
"d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
"Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
]) eachSite);
systemd.services = mkMerge [
(mapAttrs' (hostName: cfg: (
nameValuePair "wordpress-init-${hostName}" {
wantedBy = [ "multi-user.target" ];
before = [ "phpfpm-wordpress-${hostName}.service" ];
after = optional cfg.database.createLocally "mysql.service";
script = secretsScript (stateDir hostName);
serviceConfig = {
Type = "oneshot";
User = user;
Group = webserver.group;
};
})) eachSite)
(optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
httpd.after = [ "mysql.service" ];
})
];
users.users.${user} = {
group = webserver.group;
isSystemUser = true;
};
}
(mkIf (cfg.webserver == "nginx") {
services.nginx = {
enable = true;
virtualHosts = mapAttrs (hostName: cfg: {
serverName = mkDefault hostName;
root = "${pkg hostName cfg}/share/wordpress";
extraConfig = ''
index index.php;
'';
locations = {
"/" = {
priority = 200;
extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
};
"~ \\.php$" = {
priority = 500;
extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket};
fastcgi_index index.php;
include "${config.services.nginx.package}/conf/fastcgi.conf";
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
# Mitigate https://httpoxy.org/ vulnerabilities
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
'';
};
"~ /\\." = {
priority = 800;
extraConfig = "deny all;";
};
"~* /(?:uploads|files)/.*\\.php$" = {
priority = 900;
extraConfig = "deny all;";
};
"~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
priority = 1000;
extraConfig = ''
expires max;
log_not_found off;
'';
};
};
}) eachSite;
};
})
(mkIf (cfg.webserver == "caddy") {
services.caddy = {
enable = true;
virtualHosts = mapAttrs' (hostName: cfg: (
nameValuePair "http://${hostName}" {
extraConfig = ''
root * /${pkg hostName cfg}/share/wordpress
file_server
php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}
@uploads {
path_regexp path /uploads\/(.*)\.php
}
rewrite @uploads /
@wp-admin {
path not ^\/wp-admin/*
}
rewrite @wp-admin {path}/index.php?{query}
'';
}
)) eachSite;
};
})
]);
}