1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 inherit (lib)
10 mkDefault
11 mkEnableOption
12 mkPackageOption
13 mkForce
14 mkIf
15 mkMerge
16 mkOption
17 types
18 ;
19 inherit (lib)
20 concatStringsSep
21 literalExpression
22 mapAttrsToList
23 optional
24 optionalString
25 ;
26
27 cfg = config.services.moodle;
28 fpm = config.services.phpfpm.pools.moodle;
29
30 user = "moodle";
31 group = config.services.httpd.group;
32 stateDir = "/var/lib/moodle";
33
34 moodleConfig = pkgs.writeText "config.php" ''
35 <?php // Moodle configuration file
36
37 unset($CFG);
38 global $CFG;
39 $CFG = new stdClass();
40
41 $CFG->dbtype = '${
42 {
43 mysql = "mariadb";
44 pgsql = "pgsql";
45 }
46 .${cfg.database.type}
47 }';
48 $CFG->dblibrary = 'native';
49 $CFG->dbhost = '${cfg.database.host}';
50 $CFG->dbname = '${cfg.database.name}';
51 $CFG->dbuser = '${cfg.database.user}';
52 ${optionalString (
53 cfg.database.passwordFile != null
54 ) "$CFG->dbpass = file_get_contents('${cfg.database.passwordFile}');"}
55 $CFG->prefix = 'mdl_';
56 $CFG->dboptions = array (
57 'dbpersist' => 0,
58 'dbport' => '${toString cfg.database.port}',
59 ${optionalString (cfg.database.socket != null) "'dbsocket' => '${cfg.database.socket}',"}
60 'dbcollation' => 'utf8mb4_unicode_ci',
61 );
62
63 $CFG->wwwroot = '${
64 if cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL then
65 "https"
66 else
67 "http"
68 }://${cfg.virtualHost.hostName}';
69 $CFG->dataroot = '${stateDir}';
70 $CFG->admin = 'admin';
71
72 $CFG->directorypermissions = 02777;
73 $CFG->disableupdateautodeploy = true;
74
75 $CFG->pathtogs = '${pkgs.ghostscript}/bin/gs';
76 $CFG->pathtophp = '${phpExt}/bin/php';
77 $CFG->pathtodu = '${pkgs.coreutils}/bin/du';
78 $CFG->aspellpath = '${pkgs.aspell}/bin/aspell';
79 $CFG->pathtodot = '${pkgs.graphviz}/bin/dot';
80
81 ${cfg.extraConfig}
82
83 require_once('${cfg.package}/share/moodle/lib/setup.php');
84
85 // There is no php closing tag in this file,
86 // it is intentional because it prevents trailing whitespace problems!
87 '';
88
89 mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
90 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
91
92 phpExt = pkgs.php83.buildEnv {
93 extensions =
94 { all, ... }:
95 with all;
96 [
97 iconv
98 mbstring
99 curl
100 openssl
101 tokenizer
102 soap
103 ctype
104 zip
105 gd
106 simplexml
107 dom
108 intl
109 sqlite3
110 pgsql
111 pdo_sqlite
112 pdo_pgsql
113 pdo_odbc
114 pdo_mysql
115 pdo
116 mysqli
117 session
118 zlib
119 xmlreader
120 fileinfo
121 filter
122 opcache
123 exif
124 sodium
125 ];
126 extraConfig = "max_input_vars = 5000";
127 };
128in
129{
130 # interface
131 options.services.moodle = {
132 enable = mkEnableOption "Moodle web application";
133
134 package = mkPackageOption pkgs "moodle" { };
135
136 initialPassword = mkOption {
137 type = types.str;
138 example = "correcthorsebatterystaple";
139 description = ''
140 Specifies the initial password for the admin, i.e. the password assigned if the user does not already exist.
141 The password specified here is world-readable in the Nix store, so it should be changed promptly.
142 '';
143 };
144
145 database = {
146 type = mkOption {
147 type = types.enum [
148 "mysql"
149 "pgsql"
150 ];
151 default = "mysql";
152 description = "Database engine to use.";
153 };
154
155 host = mkOption {
156 type = types.str;
157 default = "localhost";
158 description = "Database host address.";
159 };
160
161 port = mkOption {
162 type = types.port;
163 description = "Database host port.";
164 default =
165 {
166 mysql = 3306;
167 pgsql = 5432;
168 }
169 .${cfg.database.type};
170 defaultText = literalExpression "3306";
171 };
172
173 name = mkOption {
174 type = types.str;
175 default = "moodle";
176 description = "Database name.";
177 };
178
179 user = mkOption {
180 type = types.str;
181 default = "moodle";
182 description = "Database user.";
183 };
184
185 passwordFile = mkOption {
186 type = types.nullOr types.path;
187 default = null;
188 example = "/run/keys/moodle-dbpassword";
189 description = ''
190 A file containing the password corresponding to
191 {option}`database.user`.
192 '';
193 };
194
195 socket = mkOption {
196 type = types.nullOr types.path;
197 default =
198 if mysqlLocal then
199 "/run/mysqld/mysqld.sock"
200 else if pgsqlLocal then
201 "/run/postgresql"
202 else
203 null;
204 defaultText = literalExpression "/run/mysqld/mysqld.sock";
205 description = "Path to the unix socket file to use for authentication.";
206 };
207
208 createLocally = mkOption {
209 type = types.bool;
210 default = true;
211 description = "Create the database and database user locally.";
212 };
213 };
214
215 virtualHost = mkOption {
216 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
217 example = literalExpression ''
218 {
219 hostName = "moodle.example.org";
220 adminAddr = "webmaster@example.org";
221 forceSSL = true;
222 enableACME = true;
223 }
224 '';
225 description = ''
226 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
227 See [](#opt-services.httpd.virtualHosts) for further information.
228 '';
229 };
230
231 poolConfig = mkOption {
232 type =
233 with types;
234 attrsOf (oneOf [
235 str
236 int
237 bool
238 ]);
239 default = {
240 "pm" = "dynamic";
241 "pm.max_children" = 32;
242 "pm.start_servers" = 2;
243 "pm.min_spare_servers" = 2;
244 "pm.max_spare_servers" = 4;
245 "pm.max_requests" = 500;
246 };
247 description = ''
248 Options for the Moodle PHP pool. See the documentation on `php-fpm.conf`
249 for details on configuration directives.
250 '';
251 };
252
253 extraConfig = mkOption {
254 type = types.lines;
255 default = "";
256 description = ''
257 Any additional text to be appended to the config.php
258 configuration file. This is a PHP script. For configuration
259 details, see <https://docs.moodle.org/37/en/Configuration_file>.
260 '';
261 example = ''
262 $CFG->disableupdatenotifications = true;
263 '';
264 };
265 };
266
267 # implementation
268 config = mkIf cfg.enable {
269
270 assertions = [
271 {
272 assertion =
273 cfg.database.createLocally -> cfg.database.user == user && cfg.database.user == cfg.database.name;
274 message = "services.moodle.database.user must be set to ${user} if services.moodle.database.createLocally is set true";
275 }
276 {
277 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
278 message = "a password cannot be specified if services.moodle.database.createLocally is set to true";
279 }
280 ];
281
282 services.mysql = mkIf mysqlLocal {
283 enable = true;
284 package = mkDefault pkgs.mariadb;
285 ensureDatabases = [ cfg.database.name ];
286 ensureUsers = [
287 {
288 name = cfg.database.user;
289 ensurePermissions = {
290 "${cfg.database.name}.*" =
291 "SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER";
292 };
293 }
294 ];
295 };
296
297 services.postgresql = mkIf pgsqlLocal {
298 enable = true;
299 ensureDatabases = [ cfg.database.name ];
300 ensureUsers = [
301 {
302 name = cfg.database.user;
303 ensureDBOwnership = true;
304 }
305 ];
306 };
307
308 services.phpfpm.pools.moodle = {
309 inherit user group;
310 phpPackage = phpExt;
311 phpEnv.MOODLE_CONFIG = "${moodleConfig}";
312 phpOptions = ''
313 zend_extension = opcache.so
314 opcache.enable = 1
315 max_input_vars = 5000
316 '';
317 settings = {
318 "listen.owner" = config.services.httpd.user;
319 "listen.group" = config.services.httpd.group;
320 } // cfg.poolConfig;
321 };
322
323 services.httpd = {
324 enable = true;
325 adminAddr = mkDefault cfg.virtualHost.adminAddr;
326 extraModules = [ "proxy_fcgi" ];
327 virtualHosts.${cfg.virtualHost.hostName} = mkMerge [
328 cfg.virtualHost
329 {
330 documentRoot = mkForce "${cfg.package}/share/moodle";
331 extraConfig = ''
332 <Directory "${cfg.package}/share/moodle">
333 <FilesMatch "\.php$">
334 <If "-f %{REQUEST_FILENAME}">
335 SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
336 </If>
337 </FilesMatch>
338 Options -Indexes
339 DirectoryIndex index.php
340 </Directory>
341 '';
342 }
343 ];
344 };
345
346 systemd.tmpfiles.settings."10-moodle".${stateDir}.d = {
347 inherit user group;
348 mode = "0750";
349 };
350
351 systemd.services.moodle-init = {
352 wantedBy = [ "multi-user.target" ];
353 before = [ "phpfpm-moodle.service" ];
354 after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
355 environment.MOODLE_CONFIG = moodleConfig;
356 script = ''
357 ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/check_database_schema.php && rc=$? || rc=$?
358
359 [ "$rc" == 1 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/upgrade.php \
360 --non-interactive \
361 --allow-unstable
362
363 [ "$rc" == 2 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/install_database.php \
364 --agree-license \
365 --adminpass=${cfg.initialPassword}
366
367 true
368 '';
369 serviceConfig = {
370 User = user;
371 Group = group;
372 Type = "oneshot";
373 };
374 };
375
376 systemd.services.moodle-cron = {
377 description = "Moodle cron service";
378 after = [ "moodle-init.service" ];
379 environment.MOODLE_CONFIG = moodleConfig;
380 serviceConfig = {
381 User = user;
382 Group = group;
383 ExecStart = "${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/cron.php";
384 };
385 };
386
387 systemd.timers.moodle-cron = {
388 description = "Moodle cron timer";
389 wantedBy = [ "timers.target" ];
390 timerConfig = {
391 OnCalendar = "minutely";
392 };
393 };
394
395 systemd.services.httpd.after =
396 optional mysqlLocal "mysql.service"
397 ++ optional pgsqlLocal "postgresql.service";
398
399 users.users.${user} = {
400 group = group;
401 isSystemUser = true;
402 };
403 };
404}