nixpkgs/nixos/modules/services/web-apps/mediawiki.nix
2024-04-22 11:02:52 -07:00

643 lines
23 KiB
Nix

{ config, pkgs, lib, ... }:
let
inherit (lib) mkDefault mkEnableOption mkPackageOption mkForce mkIf mkMerge mkOption;
inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionals optionalString types;
cfg = config.services.mediawiki;
fpm = config.services.phpfpm.pools.mediawiki;
user = "mediawiki";
group =
if cfg.webserver == "apache" then
config.services.httpd.group
else if cfg.webserver == "nginx" then
config.services.nginx.group
else "mediawiki";
cacheDir = "/var/cache/mediawiki";
stateDir = "/var/lib/mediawiki";
# https://www.mediawiki.org/wiki/Compatibility
php = pkgs.php81;
pkg = pkgs.stdenv.mkDerivation rec {
pname = "mediawiki-full";
inherit (src) version;
src = cfg.package;
installPhase = ''
mkdir -p $out
cp -r * $out/
# try removing directories before symlinking to allow overwriting any builtin extension or skin
${concatStringsSep "\n" (mapAttrsToList (k: v: ''
rm -rf $out/share/mediawiki/skins/${k}
ln -s ${v} $out/share/mediawiki/skins/${k}
'') cfg.skins)}
${concatStringsSep "\n" (mapAttrsToList (k: v: ''
rm -rf $out/share/mediawiki/extensions/${k}
ln -s ${if v != null then v else "$src/share/mediawiki/extensions/${k}"} $out/share/mediawiki/extensions/${k}
'') cfg.extensions)}
'';
};
mediawikiScripts = pkgs.runCommand "mediawiki-scripts" {
nativeBuildInputs = [ pkgs.makeWrapper ];
preferLocalBuild = true;
} ''
mkdir -p $out/bin
for i in changePassword.php createAndPromote.php userOptions.php edit.php nukePage.php update.php; do
makeWrapper ${php}/bin/php $out/bin/mediawiki-$(basename $i .php) \
--set MEDIAWIKI_CONFIG ${mediawikiConfig} \
--add-flags ${pkg}/share/mediawiki/maintenance/$i
done
'';
dbAddr = if cfg.database.socket == null then
"${cfg.database.host}:${toString cfg.database.port}"
else if cfg.database.type == "mysql" then
"${cfg.database.host}:${cfg.database.socket}"
else if cfg.database.type == "postgres" then
"${cfg.database.socket}"
else
throw "Unsupported database type: ${cfg.database.type} for socket: ${cfg.database.socket}";
mediawikiConfig = pkgs.writeText "LocalSettings.php" ''
<?php
# Protect against web entry
if ( !defined( 'MEDIAWIKI' ) ) {
exit;
}
$wgSitename = "${cfg.name}";
$wgMetaNamespace = false;
## The URL base path to the directory containing the wiki;
## defaults for all runtime URL paths are based off of this.
## For more information on customizing the URLs
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
## https://www.mediawiki.org/wiki/Manual:Short_URL
$wgScriptPath = "${lib.optionalString (cfg.webserver == "nginx") "/w"}";
## The protocol and server name to use in fully-qualified URLs
$wgServer = "${cfg.url}";
## The URL path to static resources (images, scripts, etc.)
$wgResourceBasePath = $wgScriptPath;
${lib.optionalString (cfg.webserver == "nginx") ''
$wgArticlePath = "/wiki/$1";
$wgUsePathInfo = true;
''}
## The URL path to the logo. Make sure you change this from the default,
## or else you'll overwrite your logo when you upgrade!
$wgLogo = "$wgResourceBasePath/resources/assets/wiki.png";
## UPO means: this is also a user preference option
$wgEnableEmail = true;
$wgEnableUserEmail = true; # UPO
$wgPasswordSender = "${cfg.passwordSender}";
$wgEnotifUserTalk = false; # UPO
$wgEnotifWatchlist = false; # UPO
$wgEmailAuthentication = true;
## Database settings
$wgDBtype = "${cfg.database.type}";
$wgDBserver = "${dbAddr}";
$wgDBport = "${toString cfg.database.port}";
$wgDBname = "${cfg.database.name}";
$wgDBuser = "${cfg.database.user}";
${optionalString (cfg.database.passwordFile != null) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"}
${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) ''
# MySQL specific settings
$wgDBprefix = "${cfg.database.tablePrefix}";
''}
${optionalString (cfg.database.type == "mysql") ''
# MySQL table options to use during installation or update
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
''}
## Shared memory settings
$wgMainCacheType = CACHE_NONE;
$wgMemCachedServers = [];
${optionalString (cfg.uploadsDir != null) ''
$wgEnableUploads = true;
$wgUploadDirectory = "${cfg.uploadsDir}";
''}
$wgUseImageMagick = true;
$wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert";
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
$wgUseInstantCommons = false;
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
# about this MediaWiki instance. The Wikimedia Foundation shares this data
# with MediaWiki developers to help guide future development efforts.
$wgPingback = true;
## If you use ImageMagick (or any other shell command) on a
## Linux server, this will need to be set to the name of an
## available UTF-8 locale
$wgShellLocale = "C.UTF-8";
## Set $wgCacheDirectory to a writable directory on the web server
## to make your wiki go slightly faster. The directory should not
## be publicly accessible from the web.
$wgCacheDirectory = "${cacheDir}";
# Site language code, should be one of the list in ./languages/data/Names.php
$wgLanguageCode = "en";
$wgSecretKey = file_get_contents("${stateDir}/secret.key");
# Changing this will log out all existing sessions.
$wgAuthenticationTokenVersion = "";
## For attaching licensing metadata to pages, and displaying an
## appropriate copyright notice / icon. GNU Free Documentation
## License and Creative Commons licenses are supported so far.
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
$wgRightsUrl = "";
$wgRightsText = "";
$wgRightsIcon = "";
# Path to the GNU diff3 utility. Used for conflict resolution.
$wgDiff = "${pkgs.diffutils}/bin/diff";
$wgDiff3 = "${pkgs.diffutils}/bin/diff3";
# Enabled skins.
${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)}
# Enabled extensions.
${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)}
# End of automatically generated settings.
# Add more configuration options below.
${cfg.extraConfig}
'';
withTrailingSlash = str: if lib.hasSuffix "/" str then str else "${str}/";
in
{
# interface
options = {
services.mediawiki = {
enable = mkEnableOption "MediaWiki";
package = mkPackageOption pkgs "mediawiki" { };
finalPackage = mkOption {
type = types.package;
readOnly = true;
default = pkg;
defaultText = literalExpression "pkg";
description = ''
The final package used by the module. This is the package that will have extensions and skins installed.
'';
};
name = mkOption {
type = types.str;
default = "MediaWiki";
example = "Foobar Wiki";
description = "Name of the wiki.";
};
url = mkOption {
type = types.str;
default =
if cfg.webserver == "apache" then
"${if cfg.httpd.virtualHost.addSSL || cfg.httpd.virtualHost.forceSSL || cfg.httpd.virtualHost.onlySSL then "https" else "http"}://${cfg.httpd.virtualHost.hostName}"
else if cfg.webserver == "nginx" then
let
hasSSL = host: host.forceSSL || host.addSSL;
in
"${if hasSSL config.services.nginx.virtualHosts.${cfg.nginx.hostName} then "https" else "http"}://${cfg.nginx.hostName}"
else
"http://localhost";
defaultText = ''
if "mediawiki uses ssl" then "{"https" else "http"}://''${cfg.hostName}" else "http://localhost";
'';
example = "https://wiki.example.org";
description = "URL of the wiki.";
};
uploadsDir = mkOption {
type = types.nullOr types.path;
default = "${stateDir}/uploads";
description = ''
This directory is used for uploads of pictures. The directory passed here is automatically
created and permissions adjusted as required.
'';
};
passwordFile = mkOption {
type = types.path;
description = ''
A file containing the initial password for the administrator account "admin".
'';
example = "/run/keys/mediawiki-password";
};
passwordSender = mkOption {
type = types.str;
default =
if cfg.webserver == "apache" then
if cfg.httpd.virtualHost.adminAddr != null then
cfg.httpd.virtualHost.adminAddr
else
config.services.httpd.adminAddr else "root@localhost";
defaultText = literalExpression ''
if cfg.webserver == "apache" then
if cfg.httpd.virtualHost.adminAddr != null then
cfg.httpd.virtualHost.adminAddr
else
config.services.httpd.adminAddr else "root@localhost"
'';
description = "Contact address for password reset.";
};
skins = mkOption {
default = {};
type = types.attrsOf types.path;
description = ''
Attribute set of paths whose content is copied to the {file}`skins`
subdirectory of the MediaWiki installation in addition to the default skins.
'';
};
extensions = mkOption {
default = {};
type = types.attrsOf (types.nullOr types.path);
description = ''
Attribute set of paths whose content is copied to the {file}`extensions`
subdirectory of the MediaWiki installation and enabled in configuration.
Use `null` instead of path to enable extensions that are part of MediaWiki.
'';
example = literalExpression ''
{
Matomo = pkgs.fetchzip {
url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz";
sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b";
};
ParserFunctions = null;
}
'';
};
webserver = mkOption {
type = types.enum [ "apache" "none" "nginx" ];
default = "apache";
description = "Webserver to use.";
};
database = {
type = mkOption {
type = types.enum [ "mysql" "postgres" "mssql" "oracle" ];
default = "mysql";
description = "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers.";
};
host = mkOption {
type = types.str;
default = "localhost";
description = "Database host address.";
};
port = mkOption {
type = types.port;
default = if cfg.database.type == "mysql" then 3306 else 5432;
defaultText = literalExpression "3306";
description = "Database host port.";
};
name = mkOption {
type = types.str;
default = "mediawiki";
description = "Database name.";
};
user = mkOption {
type = types.str;
default = "mediawiki";
description = "Database user.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/mediawiki-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
tablePrefix = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If you only have access to a single database and wish to install more than
one version of MediaWiki, or have other applications that also use the
database, you can give the table names a unique prefix to stop any naming
conflicts or confusion.
See <https://www.mediawiki.org/wiki/Manual:$wgDBprefix>.
'';
};
socket = mkOption {
type = types.nullOr types.path;
default = if (cfg.database.type == "mysql" && cfg.database.createLocally) then
"/run/mysqld/mysqld.sock"
else if (cfg.database.type == "postgres" && cfg.database.createLocally) then
"/run/postgresql"
else
null;
defaultText = literalExpression "/run/mysqld/mysqld.sock";
description = "Path to the unix socket file to use for authentication.";
};
createLocally = mkOption {
type = types.bool;
default = cfg.database.type == "mysql" || cfg.database.type == "postgres";
defaultText = literalExpression "true";
description = ''
Create the database and database user locally.
This currently only applies if database type "mysql" is selected.
'';
};
};
nginx.hostName = mkOption {
type = types.str;
example = literalExpression ''wiki.example.com'';
default = "localhost";
description = ''
The hostname to use for the nginx virtual host.
This is used to generate the nginx configuration.
'';
};
httpd.virtualHost = mkOption {
type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
example = literalExpression ''
{
hostName = "mediawiki.example.org";
adminAddr = "webmaster@example.org";
forceSSL = true;
enableACME = true;
}
'';
description = ''
Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
See [](#opt-services.httpd.virtualHosts) for further information.
'';
};
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 = ''
Options for the MediaWiki PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
extraConfig = mkOption {
type = types.lines;
description = ''
Any additional text to be appended to MediaWiki's
LocalSettings.php configuration file. For configuration
settings, see <https://www.mediawiki.org/wiki/Manual:Configuration_settings>.
'';
default = "";
example = ''
$wgEnableEmail = false;
'';
};
};
};
imports = [
(lib.mkRenamedOptionModule [ "services" "mediawiki" "virtualHost" ] [ "services" "mediawiki" "httpd" "virtualHost" ])
];
# implementation
config = mkIf cfg.enable {
assertions = [
{ assertion = cfg.database.createLocally -> (cfg.database.type == "mysql" || cfg.database.type == "postgres");
message = "services.mediawiki.createLocally is currently only supported for database type 'mysql' and 'postgres'";
}
{ assertion = cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true";
}
{ assertion = cfg.database.createLocally -> cfg.database.socket != null;
message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true";
}
{ assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true";
}
];
services.mediawiki.skins = {
MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook";
Timeless = "${cfg.package}/share/mediawiki/skins/Timeless";
Vector = "${cfg.package}/share/mediawiki/skins/Vector";
};
services.mysql = mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.database.user;
ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
}];
};
services.postgresql = mkIf (cfg.database.type == "postgres" && cfg.database.createLocally) {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.database.user;
ensureDBOwnership = true;
}];
};
services.phpfpm.pools.mediawiki = {
inherit user group;
phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}";
phpPackage = php;
settings = (if (cfg.webserver == "apache") then {
"listen.owner" = config.services.httpd.user;
"listen.group" = config.services.httpd.group;
} else if (cfg.webserver == "nginx") then {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
} else {
"listen.owner" = user;
"listen.group" = group;
}) // cfg.poolConfig;
};
services.httpd = lib.mkIf (cfg.webserver == "apache") {
enable = true;
extraModules = [ "proxy_fcgi" ];
virtualHosts.${cfg.httpd.virtualHost.hostName} = mkMerge [
cfg.httpd.virtualHost
{
documentRoot = mkForce "${pkg}/share/mediawiki";
extraConfig = ''
<Directory "${pkg}/share/mediawiki">
<FilesMatch "\.php$">
<If "-f %{REQUEST_FILENAME}">
SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
</If>
</FilesMatch>
Require all granted
DirectoryIndex index.php
AllowOverride All
</Directory>
'' + optionalString (cfg.uploadsDir != null) ''
Alias "/images" "${cfg.uploadsDir}"
<Directory "${cfg.uploadsDir}">
Require all granted
</Directory>
'';
}
];
};
# inspired by https://www.mediawiki.org/wiki/Manual:Short_URL/Nginx
services.nginx = lib.mkIf (cfg.webserver == "nginx") {
enable = true;
virtualHosts.${config.services.mediawiki.nginx.hostName} = {
root = "${pkg}/share/mediawiki";
locations = {
"~ ^/w/(index|load|api|thumb|opensearch_desc|rest|img_auth)\\.php$".extraConfig = ''
rewrite ^/w/(.*) /$1 break;
include ${config.services.nginx.package}/conf/fastcgi.conf;
fastcgi_index index.php;
fastcgi_pass unix:${config.services.phpfpm.pools.mediawiki.socket};
'';
"/w/images/".alias = withTrailingSlash cfg.uploadsDir;
# Deny access to deleted images folder
"/w/images/deleted".extraConfig = ''
deny all;
'';
# MediaWiki assets (usually images)
"~ ^/w/resources/(assets|lib|src)".extraConfig = ''
rewrite ^/w(/.*) $1 break;
add_header Cache-Control "public";
expires 7d;
'';
# Assets, scripts and styles from skins and extensions
"~ ^/w/(skins|extensions)/.+\\.(css|js|gif|jpg|jpeg|png|svg|wasm|ttf|woff|woff2)$".extraConfig = ''
rewrite ^/w(/.*) $1 break;
add_header Cache-Control "public";
expires 7d;
'';
# Handling for Mediawiki REST API, see [[mw:API:REST_API]]
"/w/rest.php/".tryFiles = "$uri $uri/ /w/rest.php?$query_string";
# Handling for the article path (pretty URLs)
"/wiki/".extraConfig = ''
rewrite ^/wiki/(?<pagename>.*)$ /w/index.php;
'';
# Explicit access to the root website, redirect to main page (adapt as needed)
"= /".extraConfig = ''
return 301 /wiki/;
'';
# Every other entry point will be disallowed.
# Add specific rules for other entry points/images as needed above this
"/".extraConfig = ''
return 404;
'';
};
};
};
systemd.tmpfiles.rules = [
"d '${stateDir}' 0750 ${user} ${group} - -"
"d '${cacheDir}' 0750 ${user} ${group} - -"
] ++ optionals (cfg.uploadsDir != null) [
"d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
"Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
];
systemd.services.mediawiki-init = {
wantedBy = [ "multi-user.target" ];
before = [ "phpfpm-mediawiki.service" ];
after = optional (cfg.database.type == "mysql" && cfg.database.createLocally) "mysql.service"
++ optional (cfg.database.type == "postgres" && cfg.database.createLocally) "postgresql.service";
script = ''
if ! test -e "${stateDir}/secret.key"; then
tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key
fi
echo "exit( wfGetDB( DB_MASTER )->tableExists( 'user' ) ? 1 : 0 );" | \
${php}/bin/php ${pkg}/share/mediawiki/maintenance/eval.php --conf ${mediawikiConfig} && \
${php}/bin/php ${pkg}/share/mediawiki/maintenance/install.php \
--confpath /tmp \
--scriptpath / \
--dbserver ${lib.escapeShellArg dbAddr} \
--dbport ${toString cfg.database.port} \
--dbname ${lib.escapeShellArg cfg.database.name} \
${optionalString (cfg.database.tablePrefix != null) "--dbprefix ${lib.escapeShellArg cfg.database.tablePrefix}"} \
--dbuser ${lib.escapeShellArg cfg.database.user} \
${optionalString (cfg.database.passwordFile != null) "--dbpassfile ${lib.escapeShellArg cfg.database.passwordFile}"} \
--passfile ${lib.escapeShellArg cfg.passwordFile} \
--dbtype ${cfg.database.type} \
${lib.escapeShellArg cfg.name} \
admin
${php}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick
'';
serviceConfig = {
Type = "oneshot";
User = user;
Group = group;
PrivateTmp = true;
};
};
systemd.services.httpd.after = optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "mysql") "mysql.service"
++ optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "postgres") "postgresql.service";
users.users.${user} = {
inherit group;
isSystemUser = true;
};
users.groups.${group} = {};
environment.systemPackages = [ mediawikiScripts ];
};
}