lib.packagesFromDirectoryRecursive: Improved documentation (#359898)
[NixPkgs.git] / nixos / modules / services / web-apps / mediawiki.nix
blobeeb8d03f1902dcc2db3e4906afe04d5418d55348
1 { config, pkgs, lib, ... }:
3 let
5   inherit (lib) mkDefault mkEnableOption mkPackageOption mkForce mkIf mkMerge mkOption;
6   inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionals optionalString types;
8   cfg = config.services.mediawiki;
9   fpm = config.services.phpfpm.pools.mediawiki;
10   user = "mediawiki";
11   group =
12     if cfg.webserver == "apache" then
13       config.services.httpd.group
14     else if cfg.webserver == "nginx" then
15       config.services.nginx.group
16     else "mediawiki";
18   cacheDir = "/var/cache/mediawiki";
19   stateDir = "/var/lib/mediawiki";
21   # https://www.mediawiki.org/wiki/Compatibility
22   php = pkgs.php82;
24   pkg = pkgs.stdenv.mkDerivation rec {
25     pname = "mediawiki-full";
26     inherit (src) version;
27     src = cfg.package;
29     installPhase = ''
30       mkdir -p $out
31       cp -r * $out/
33       # try removing directories before symlinking to allow overwriting any builtin extension or skin
34       ${concatStringsSep "\n" (mapAttrsToList (k: v: ''
35         rm -rf $out/share/mediawiki/skins/${k}
36         ln -s ${v} $out/share/mediawiki/skins/${k}
37       '') cfg.skins)}
39       ${concatStringsSep "\n" (mapAttrsToList (k: v: ''
40         rm -rf $out/share/mediawiki/extensions/${k}
41         ln -s ${if v != null then v else "$src/share/mediawiki/extensions/${k}"} $out/share/mediawiki/extensions/${k}
42       '') cfg.extensions)}
43     '';
44   };
46   mediawikiScripts = pkgs.runCommand "mediawiki-scripts" {
47     nativeBuildInputs = [ pkgs.makeWrapper ];
48     preferLocalBuild = true;
49   } ''
50     mkdir -p $out/bin
51     for i in changePassword.php createAndPromote.php resetUserEmail.php userOptions.php edit.php nukePage.php update.php; do
52       makeWrapper ${php}/bin/php $out/bin/mediawiki-$(basename $i .php) \
53         --set MEDIAWIKI_CONFIG ${mediawikiConfig} \
54         --add-flags ${pkg}/share/mediawiki/maintenance/$i
55     done
56   '';
58   dbAddr = if cfg.database.socket == null then
59     "${cfg.database.host}:${toString cfg.database.port}"
60   else if cfg.database.type == "mysql" then
61     "${cfg.database.host}:${cfg.database.socket}"
62   else if cfg.database.type == "postgres" then
63     "${cfg.database.socket}"
64   else
65     throw "Unsupported database type: ${cfg.database.type} for socket: ${cfg.database.socket}";
67   mediawikiConfig = pkgs.writeTextFile {
68     name = "LocalSettings.php";
69     checkPhase = ''
70       ${php}/bin/php --syntax-check "$target"
71     '';
72     text = ''
73       <?php
74         # Protect against web entry
75         if ( !defined( 'MEDIAWIKI' ) ) {
76           exit;
77         }
79         $wgSitename = "${cfg.name}";
80         $wgMetaNamespace = false;
82         ## The URL base path to the directory containing the wiki;
83         ## defaults for all runtime URL paths are based off of this.
84         ## For more information on customizing the URLs
85         ## (like /w/index.php/Page_title to /wiki/Page_title) please see:
86         ## https://www.mediawiki.org/wiki/Manual:Short_URL
87         $wgScriptPath = "${lib.optionalString (cfg.webserver == "nginx") "/w"}";
89         ## The protocol and server name to use in fully-qualified URLs
90         $wgServer = "${cfg.url}";
92         ## The URL path to static resources (images, scripts, etc.)
93         $wgResourceBasePath = $wgScriptPath;
95         ${lib.optionalString (cfg.webserver == "nginx") ''
96           $wgArticlePath = "/wiki/$1";
97           $wgUsePathInfo = true;
98         ''}
100         ## The URL path to the logo.  Make sure you change this from the default,
101         ## or else you'll overwrite your logo when you upgrade!
102         $wgLogo = "$wgResourceBasePath/resources/assets/wiki.png";
104         ## UPO means: this is also a user preference option
106         $wgEnableEmail = true;
107         $wgEnableUserEmail = true; # UPO
109         $wgPasswordSender = "${cfg.passwordSender}";
111         $wgEnotifUserTalk = false; # UPO
112         $wgEnotifWatchlist = false; # UPO
113         $wgEmailAuthentication = true;
115         ## Database settings
116         $wgDBtype = "${cfg.database.type}";
117         $wgDBserver = "${dbAddr}";
118         $wgDBport = "${toString cfg.database.port}";
119         $wgDBname = "${cfg.database.name}";
120         $wgDBuser = "${cfg.database.user}";
121         ${optionalString (cfg.database.passwordFile != null) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"}
123         ${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) ''
124           # MySQL specific settings
125           $wgDBprefix = "${cfg.database.tablePrefix}";
126         ''}
128         ${optionalString (cfg.database.type == "mysql") ''
129           # MySQL table options to use during installation or update
130           $wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
131         ''}
133         ## Shared memory settings
134         $wgMainCacheType = CACHE_NONE;
135         $wgMemCachedServers = [];
137         ${optionalString (cfg.uploadsDir != null) ''
138           $wgEnableUploads = true;
139           $wgUploadDirectory = "${cfg.uploadsDir}";
140         ''}
142         $wgUseImageMagick = true;
143         $wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert";
145         # InstantCommons allows wiki to use images from https://commons.wikimedia.org
146         $wgUseInstantCommons = false;
148         # Periodically send a pingback to https://www.mediawiki.org/ with basic data
149         # about this MediaWiki instance. The Wikimedia Foundation shares this data
150         # with MediaWiki developers to help guide future development efforts.
151         $wgPingback = true;
153         ## If you use ImageMagick (or any other shell command) on a
154         ## Linux server, this will need to be set to the name of an
155         ## available UTF-8 locale
156         $wgShellLocale = "C.UTF-8";
158         ## Set $wgCacheDirectory to a writable directory on the web server
159         ## to make your wiki go slightly faster. The directory should not
160         ## be publicly accessible from the web.
161         $wgCacheDirectory = "${cacheDir}";
163         # Site language code, should be one of the list in ./languages/data/Names.php
164         $wgLanguageCode = "en";
166         $wgSecretKey = file_get_contents("${stateDir}/secret.key");
168         # Changing this will log out all existing sessions.
169         $wgAuthenticationTokenVersion = "";
171         ## For attaching licensing metadata to pages, and displaying an
172         ## appropriate copyright notice / icon. GNU Free Documentation
173         ## License and Creative Commons licenses are supported so far.
174         $wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
175         $wgRightsUrl = "";
176         $wgRightsText = "";
177         $wgRightsIcon = "";
179         # Path to the GNU diff3 utility. Used for conflict resolution.
180         $wgDiff = "${pkgs.diffutils}/bin/diff";
181         $wgDiff3 = "${pkgs.diffutils}/bin/diff3";
183         # Enabled skins.
184         ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)}
186         # Enabled extensions.
187         ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)}
190         # End of automatically generated settings.
191         # Add more configuration options below.
193         ${cfg.extraConfig}
194       '';
195     };
197   withTrailingSlash = str: if lib.hasSuffix "/" str then str else "${str}/";
200   # interface
201   options = {
202     services.mediawiki = {
204       enable = mkEnableOption "MediaWiki";
206       package = mkPackageOption pkgs "mediawiki" { };
208       finalPackage = mkOption {
209         type = types.package;
210         readOnly = true;
211         default = pkg;
212         defaultText = literalExpression "pkg";
213         description = ''
214           The final package used by the module. This is the package that will have extensions and skins installed.
215         '';
216       };
218       name = mkOption {
219         type = types.str;
220         default = "MediaWiki";
221         example = "Foobar Wiki";
222         description = "Name of the wiki.";
223       };
225       url = mkOption {
226         type = types.str;
227         default =
228           if cfg.webserver == "apache" then
229             "${if cfg.httpd.virtualHost.addSSL || cfg.httpd.virtualHost.forceSSL || cfg.httpd.virtualHost.onlySSL then "https" else "http"}://${cfg.httpd.virtualHost.hostName}"
230           else if cfg.webserver == "nginx" then
231             let
232               hasSSL = host: host.forceSSL || host.addSSL;
233             in
234             "${if hasSSL config.services.nginx.virtualHosts.${cfg.nginx.hostName} then "https" else "http"}://${cfg.nginx.hostName}"
235           else
236             "http://localhost";
237         defaultText = ''
238           if "mediawiki uses ssl" then "{"https" else "http"}://''${cfg.hostName}" else "http://localhost";
239         '';
240         example = "https://wiki.example.org";
241         description = "URL of the wiki.";
242       };
244       uploadsDir = mkOption {
245         type = types.nullOr types.path;
246         default = "${stateDir}/uploads";
247         description = ''
248           This directory is used for uploads of pictures. The directory passed here is automatically
249           created and permissions adjusted as required.
250         '';
251       };
253       passwordFile = mkOption {
254         type = types.path;
255         description = ''
256           A file containing the initial password for the administrator account "admin".
257         '';
258         example = "/run/keys/mediawiki-password";
259       };
261       passwordSender = mkOption {
262         type = types.str;
263         default =
264           if cfg.webserver == "apache" then
265             if cfg.httpd.virtualHost.adminAddr != null then
266               cfg.httpd.virtualHost.adminAddr
267             else
268               config.services.httpd.adminAddr else "root@localhost";
269         defaultText = literalExpression ''
270           if cfg.webserver == "apache" then
271             if cfg.httpd.virtualHost.adminAddr != null then
272               cfg.httpd.virtualHost.adminAddr
273             else
274               config.services.httpd.adminAddr else "root@localhost"
275         '';
276         description = "Contact address for password reset.";
277       };
279       skins = mkOption {
280         default = {};
281         type = types.attrsOf types.path;
282         description = ''
283           Attribute set of paths whose content is copied to the {file}`skins`
284           subdirectory of the MediaWiki installation in addition to the default skins.
285         '';
286       };
288       extensions = mkOption {
289         default = {};
290         type = types.attrsOf (types.nullOr types.path);
291         description = ''
292           Attribute set of paths whose content is copied to the {file}`extensions`
293           subdirectory of the MediaWiki installation and enabled in configuration.
295           Use `null` instead of path to enable extensions that are part of MediaWiki.
296         '';
297         example = literalExpression ''
298           {
299             Matomo = pkgs.fetchzip {
300               url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz";
301               sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b";
302             };
303             ParserFunctions = null;
304           }
305         '';
306       };
308       webserver = mkOption {
309         type = types.enum [ "apache" "none" "nginx" ];
310         default = "apache";
311         description = "Webserver to use.";
312       };
314       database = {
315         type = mkOption {
316           type = types.enum [ "mysql" "postgres" "mssql" "oracle" ];
317           default = "mysql";
318           description = "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers.";
319         };
321         host = mkOption {
322           type = types.str;
323           default = "localhost";
324           description = "Database host address.";
325         };
327         port = mkOption {
328           type = types.port;
329           default = if cfg.database.type == "mysql" then 3306 else 5432;
330           defaultText = literalExpression "3306";
331           description = "Database host port.";
332         };
334         name = mkOption {
335           type = types.str;
336           default = "mediawiki";
337           description = "Database name.";
338         };
340         user = mkOption {
341           type = types.str;
342           default = "mediawiki";
343           description = "Database user.";
344         };
346         passwordFile = mkOption {
347           type = types.nullOr types.path;
348           default = null;
349           example = "/run/keys/mediawiki-dbpassword";
350           description = ''
351             A file containing the password corresponding to
352             {option}`database.user`.
353           '';
354         };
356         tablePrefix = mkOption {
357           type = types.nullOr types.str;
358           default = null;
359           description = ''
360             If you only have access to a single database and wish to install more than
361             one version of MediaWiki, or have other applications that also use the
362             database, you can give the table names a unique prefix to stop any naming
363             conflicts or confusion.
364             See <https://www.mediawiki.org/wiki/Manual:$wgDBprefix>.
365           '';
366         };
368         socket = mkOption {
369           type = types.nullOr types.path;
370           default = if (cfg.database.type == "mysql" && cfg.database.createLocally) then
371               "/run/mysqld/mysqld.sock"
372             else if (cfg.database.type == "postgres" && cfg.database.createLocally) then
373               "/run/postgresql"
374             else
375               null;
376           defaultText = literalExpression "/run/mysqld/mysqld.sock";
377           description = "Path to the unix socket file to use for authentication.";
378         };
380         createLocally = mkOption {
381           type = types.bool;
382           default = cfg.database.type == "mysql" || cfg.database.type == "postgres";
383           defaultText = literalExpression "true";
384           description = ''
385             Create the database and database user locally.
386             This currently only applies if database type "mysql" is selected.
387           '';
388         };
389       };
391       nginx.hostName = mkOption {
392         type = types.str;
393         example = literalExpression ''wiki.example.com'';
394         default = "localhost";
395         description = ''
396           The hostname to use for the nginx virtual host.
397           This is used to generate the nginx configuration.
398         '';
399       };
401       httpd.virtualHost = mkOption {
402         type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
403         example = literalExpression ''
404           {
405             hostName = "mediawiki.example.org";
406             adminAddr = "webmaster@example.org";
407             forceSSL = true;
408             enableACME = true;
409           }
410         '';
411         description = ''
412           Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
413           See [](#opt-services.httpd.virtualHosts) for further information.
414         '';
415       };
417       poolConfig = mkOption {
418         type = with types; attrsOf (oneOf [ str int bool ]);
419         default = {
420           "pm" = "dynamic";
421           "pm.max_children" = 32;
422           "pm.start_servers" = 2;
423           "pm.min_spare_servers" = 2;
424           "pm.max_spare_servers" = 4;
425           "pm.max_requests" = 500;
426         };
427         description = ''
428           Options for the MediaWiki PHP pool. See the documentation on `php-fpm.conf`
429           for details on configuration directives.
430         '';
431       };
433       extraConfig = mkOption {
434         type = types.lines;
435         description = ''
436           Any additional text to be appended to MediaWiki's
437           LocalSettings.php configuration file. For configuration
438           settings, see <https://www.mediawiki.org/wiki/Manual:Configuration_settings>.
439         '';
440         default = "";
441         example = ''
442           $wgEnableEmail = false;
443         '';
444       };
446     };
447   };
449   imports = [
450     (lib.mkRenamedOptionModule [ "services" "mediawiki" "virtualHost" ] [ "services" "mediawiki" "httpd" "virtualHost" ])
451   ];
453   # implementation
454   config = mkIf cfg.enable {
456     assertions = [
457       { assertion = cfg.database.createLocally -> (cfg.database.type == "mysql" || cfg.database.type == "postgres");
458         message = "services.mediawiki.createLocally is currently only supported for database type 'mysql' and 'postgres'";
459       }
460       { assertion = cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
461         message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true";
462       }
463       { assertion = cfg.database.createLocally -> cfg.database.socket != null;
464         message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true";
465       }
466       { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
467         message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true";
468       }
469     ];
471     services.mediawiki.skins = {
472       MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook";
473       Timeless = "${cfg.package}/share/mediawiki/skins/Timeless";
474       Vector = "${cfg.package}/share/mediawiki/skins/Vector";
475     };
477     services.mysql = mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) {
478       enable = true;
479       package = mkDefault pkgs.mariadb;
480       ensureDatabases = [ cfg.database.name ];
481       ensureUsers = [{
482         name = cfg.database.user;
483         ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
484       }];
485     };
487     services.postgresql = mkIf (cfg.database.type == "postgres" && cfg.database.createLocally) {
488       enable = true;
489       ensureDatabases = [ cfg.database.name ];
490       ensureUsers = [{
491         name = cfg.database.user;
492         ensureDBOwnership = true;
493       }];
494     };
496     services.phpfpm.pools.mediawiki = {
497       inherit user group;
498       phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}";
499       phpPackage = php;
500       settings = (if (cfg.webserver == "apache") then {
501         "listen.owner" = config.services.httpd.user;
502         "listen.group" = config.services.httpd.group;
503       } else if (cfg.webserver == "nginx") then {
504         "listen.owner" = config.services.nginx.user;
505         "listen.group" = config.services.nginx.group;
506       } else {
507         "listen.owner" = user;
508         "listen.group" = group;
509       }) // cfg.poolConfig;
510     };
512     services.httpd = lib.mkIf (cfg.webserver == "apache") {
513       enable = true;
514       extraModules = [ "proxy_fcgi" ];
515       virtualHosts.${cfg.httpd.virtualHost.hostName} = mkMerge [
516         cfg.httpd.virtualHost
517         {
518           documentRoot = mkForce "${pkg}/share/mediawiki";
519           extraConfig = ''
520             <Directory "${pkg}/share/mediawiki">
521               <FilesMatch "\.php$">
522                 <If "-f %{REQUEST_FILENAME}">
523                   SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
524                 </If>
525               </FilesMatch>
527               Require all granted
528               DirectoryIndex index.php
529               AllowOverride All
530             </Directory>
531           '' + optionalString (cfg.uploadsDir != null) ''
532             Alias "/images" "${cfg.uploadsDir}"
533             <Directory "${cfg.uploadsDir}">
534               Require all granted
535             </Directory>
536           '';
537         }
538       ];
539     };
540     # inspired by https://www.mediawiki.org/wiki/Manual:Short_URL/Nginx
541     services.nginx = lib.mkIf (cfg.webserver == "nginx") {
542       enable = true;
543       virtualHosts.${config.services.mediawiki.nginx.hostName} = {
544         root = "${pkg}/share/mediawiki";
545         locations = {
546           "~ ^/w/(index|load|api|thumb|opensearch_desc|rest|img_auth)\\.php$".extraConfig = ''
547             rewrite ^/w/(.*) /$1 break;
548             include ${config.services.nginx.package}/conf/fastcgi.conf;
549             fastcgi_index index.php;
550             fastcgi_pass unix:${config.services.phpfpm.pools.mediawiki.socket};
551           '';
552           "/w/images/".alias = withTrailingSlash cfg.uploadsDir;
553           # Deny access to deleted images folder
554           "/w/images/deleted".extraConfig = ''
555             deny all;
556           '';
557           # MediaWiki assets (usually images)
558           "~ ^/w/resources/(assets|lib|src)".extraConfig = ''
559             rewrite ^/w(/.*) $1 break;
560             add_header Cache-Control "public";
561             expires 7d;
562           '';
563           # Assets, scripts and styles from skins and extensions
564           "~ ^/w/(skins|extensions)/.+\\.(css|js|gif|jpg|jpeg|png|svg|wasm|ttf|woff|woff2)$".extraConfig = ''
565             rewrite ^/w(/.*) $1 break;
566             add_header Cache-Control "public";
567             expires 7d;
568           '';
570           # Handling for Mediawiki REST API, see [[mw:API:REST_API]]
571           "/w/rest.php/".tryFiles = "$uri $uri/ /w/rest.php?$query_string";
573           # Handling for the article path (pretty URLs)
574           "/wiki/".extraConfig = ''
575             rewrite ^/wiki/(?<pagename>.*)$ /w/index.php;
576           '';
578           # Explicit access to the root website, redirect to main page (adapt as needed)
579           "= /".extraConfig = ''
580             return 301 /wiki/;
581           '';
583           # Every other entry point will be disallowed.
584           # Add specific rules for other entry points/images as needed above this
585           "/".extraConfig = ''
586              return 404;
587           '';
588         };
589       };
590     };
592     systemd.tmpfiles.rules = [
593       "d '${stateDir}' 0750 ${user} ${group} - -"
594       "d '${cacheDir}' 0750 ${user} ${group} - -"
595     ] ++ optionals (cfg.uploadsDir != null) [
596       "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
597       "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
598     ];
600     systemd.services.mediawiki-init = {
601       wantedBy = [ "multi-user.target" ];
602       before = [ "phpfpm-mediawiki.service" ];
603       after = optional (cfg.database.type == "mysql" && cfg.database.createLocally) "mysql.service"
604               ++ optional (cfg.database.type == "postgres" && cfg.database.createLocally) "postgresql.service";
605       script = ''
606         if ! test -e "${stateDir}/secret.key"; then
607           tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key
608         fi
610         echo "exit( wfGetDB( DB_MASTER )->tableExists( 'user' ) ? 1 : 0 );" | \
611         ${php}/bin/php ${pkg}/share/mediawiki/maintenance/eval.php --conf ${mediawikiConfig} && \
612         ${php}/bin/php ${pkg}/share/mediawiki/maintenance/install.php \
613           --confpath /tmp \
614           --scriptpath / \
615           --dbserver ${lib.escapeShellArg dbAddr} \
616           --dbport ${toString cfg.database.port} \
617           --dbname ${lib.escapeShellArg cfg.database.name} \
618           ${optionalString (cfg.database.tablePrefix != null) "--dbprefix ${lib.escapeShellArg cfg.database.tablePrefix}"} \
619           --dbuser ${lib.escapeShellArg cfg.database.user} \
620           ${optionalString (cfg.database.passwordFile != null) "--dbpassfile ${lib.escapeShellArg cfg.database.passwordFile}"} \
621           --passfile ${lib.escapeShellArg cfg.passwordFile} \
622           --dbtype ${cfg.database.type} \
623           ${lib.escapeShellArg cfg.name} \
624           admin
626         ${php}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick
627       '';
629       serviceConfig = {
630         Type = "oneshot";
631         User = user;
632         Group = group;
633         PrivateTmp = true;
634       };
635     };
637     systemd.services.httpd.after = optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "mysql") "mysql.service"
638       ++ optional (cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "postgres") "postgresql.service";
640     users.users.${user} = {
641       inherit group;
642       isSystemUser = true;
643     };
644     users.groups.${group} = {};
646     environment.systemPackages = [ mediawikiScripts ];
647   };