1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 inherit (lib)
10 any
11 attrValues
12 flatten
13 literalExpression
14 mapAttrs
15 mapAttrs'
16 mapAttrsToList
17 mkDefault
18 mkEnableOption
19 mkIf
20 mkMerge
21 mkOption
22 mkPackageOption
23 nameValuePair
24 optionalAttrs
25 types
26 ;
27 inherit (pkgs)
28 mariadb
29 stdenv
30 writeShellScript
31 ;
32 cfg = config.services.drupal;
33 eachSite = cfg.sites;
34 user = "drupal";
35 webserver = config.services.${cfg.webserver};
36
37 pkg =
38 hostName: cfg:
39 stdenv.mkDerivation (finalAttrs: {
40 pname = "drupal-${hostName}";
41 name = "drupal-${hostName}";
42 src = cfg.package;
43
44 installPhase = ''
45 runHook preInstall
46
47 mkdir -p $out
48 cp -r * $out/
49
50 runHook postInstall
51 '';
52
53 postInstall = ''
54 ln -s ${cfg.filesDir} $out/share/php/drupal/sites/default/files
55 ln -s ${cfg.stateDir}/sites/default/settings.php $out/share/php/drupal/sites/default/settings.php
56 ln -s ${cfg.modulesDir} $out/share/php/drupal/modules
57 ln -s ${cfg.themesDir} $out/share/php/drupal/themes
58 '';
59 });
60
61 drupalSettings =
62 hostName: cfg:
63 pkgs.writeTextFile {
64 name = "settings.nixos-${hostName}.php";
65 text = ''
66 <?php
67
68 // NixOS automatically generated settings
69 $settings['file_private_path'] = '${cfg.privateFilesDir}';
70 $settings['config_sync_directory'] = '${cfg.configSyncDir}';
71
72 // Extra config
73 ${cfg.extraConfig}
74 '';
75 checkPhase = "${pkgs.php}/bin/php --syntax-check $target";
76 };
77
78 appendSettings =
79 hostName:
80 pkgs.writeTextFile {
81 name = "append-drupal-settings-${hostName}";
82 text = ''
83
84 // NixOS settings file import.
85 require dirname(__FILE__) . '/settings.nixos-${hostName}.php';
86
87 '';
88 };
89
90 siteOpts =
91 {
92 options,
93 config,
94 lib,
95 name,
96 ...
97 }:
98 {
99 options = {
100 enable = mkEnableOption "Drupal web application";
101 package = mkPackageOption pkgs "drupal" { };
102
103 filesDir = mkOption {
104 type = types.path;
105 default = "/var/lib/drupal/${name}/sites/default/files";
106 defaultText = "/var/lib/drupal/<name>/sites/default/files";
107 description = ''
108 The location of the Drupal files directory.
109 '';
110 };
111
112 privateFilesDir = mkOption {
113 type = types.path;
114 default = "/var/lib/drupal/${name}/private";
115 defaultText = "/var/lib/drupal/<name>/private";
116 description = "The location of the Drupal private files directory.";
117 };
118
119 configSyncDir = mkOption {
120 type = types.path;
121 default = "/var/lib/drupal/${name}/config/sync";
122 defaultText = "/var/lib/drupal/<name>/config/sync";
123 description = "The location of the Drupal config sync directory.";
124 };
125
126 extraConfig = mkOption {
127 type = types.lines;
128 default = "";
129 description = ''
130 Extra configuration values that you want to insert into settings.php.
131 All configuration must be written as PHP script.
132 '';
133 example = ''
134 $config['user.settings']['anonymous'] = 'Visitor';
135 $settings['entity_update_backup'] = TRUE;
136 '';
137 };
138
139 stateDir = mkOption {
140 type = types.path;
141 default = "/var/lib/drupal/${name}";
142 defaultText = "/var/lib/drupal/<name>";
143 description = "The location of the Drupal site state directory.";
144 };
145
146 modulesDir = mkOption {
147 type = types.path;
148 default = "/var/lib/drupal/${name}/modules";
149 defaultText = "/var/lib/drupal/<name>/modules";
150 description = "The location for users to install Drupal modules.";
151 };
152
153 themesDir = mkOption {
154 type = types.path;
155 default = "/var/lib/drupal/${name}/themes";
156 defaultText = "/var/lib/drupal/<name>/themes";
157 description = "The location for users to install Drupal themes.";
158 };
159
160 phpOptions = mkOption {
161 type = types.attrsOf types.str;
162 default = { };
163 description = ''
164 Options for PHP's php.ini file for this Drupal site.
165 '';
166 example = literalExpression ''
167 {
168 "opcache.interned_strings_buffer" = "8";
169 "opcache.max_accelerated_files" = "10000";
170 "opcache.memory_consumption" = "128";
171 "opcache.revalidate_freq" = "15";
172 "opcache.fast_shutdown" = "1";
173 }
174 '';
175 };
176
177 database = {
178 host = mkOption {
179 type = types.str;
180 default = "localhost";
181 description = "Database host address.";
182 };
183
184 port = mkOption {
185 type = types.port;
186 default = 3306;
187 description = "Database host port.";
188 };
189
190 name = mkOption {
191 type = types.str;
192 default = "drupal";
193 description = "Database name.";
194 };
195
196 user = mkOption {
197 type = types.str;
198 default = "drupal";
199 description = "Database user.";
200 };
201
202 passwordFile = mkOption {
203 type = types.nullOr types.path;
204 default = null;
205 example = "/run/keys/database-dbpassword";
206 description = ''
207 A file containing the password corresponding to
208 {option}`database.user`.
209 '';
210 };
211
212 tablePrefix = mkOption {
213 type = types.str;
214 default = "dp_";
215 description = ''
216 The $table_prefix is the value placed in the front of your database tables.
217 Change the value if you want to use something other than dp_ for your database
218 prefix. Typically this is changed if you are installing multiple Drupal sites
219 in the same database.
220 '';
221 };
222
223 socket = mkOption {
224 type = types.nullOr types.path;
225 default = null;
226 defaultText = literalExpression "/run/mysqld/mysqld.sock";
227 description = "Path to the unix socket file to use for authentication.";
228 };
229
230 createLocally = mkOption {
231 type = types.bool;
232 default = true;
233 description = "Create the database and database user locally.";
234 };
235 };
236
237 virtualHost = mkOption {
238 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
239 example = literalExpression ''
240 {
241 adminAddr = "webmaster@example.org";
242 forceSSL = true;
243 enableACME = true;
244 }
245 '';
246 description = ''
247 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
248 '';
249 };
250
251 poolConfig = mkOption {
252 type =
253 with types;
254 attrsOf (oneOf [
255 str
256 int
257 bool
258 ]);
259 default = {
260 "pm" = "dynamic";
261 "pm.max_children" = 32;
262 "pm.start_servers" = 2;
263 "pm.min_spare_servers" = 2;
264 "pm.max_spare_servers" = 4;
265 "pm.max_requests" = 500;
266 };
267 description = ''
268 Options for the Drupal PHP pool. See the documentation on `php-fpm.conf`
269 for details on configuration directives.
270 '';
271 };
272 };
273
274 config.virtualHost.hostName = mkDefault name;
275 };
276in
277{
278 options = {
279 services.drupal = {
280 enable = mkEnableOption "drupal";
281 package = mkPackageOption pkgs "drupal" { };
282
283 sites = mkOption {
284 type = types.attrsOf (types.submodule siteOpts);
285 default = {
286 "localhost" = {
287 enable = true;
288 };
289 };
290 description = "Specification of one or more Drupal sites to serve";
291 };
292
293 webserver = mkOption {
294 type = types.enum [
295 "nginx"
296 "caddy"
297 ];
298 default = "nginx";
299 description = ''
300 Whether to use nginx or caddy for virtual host management.
301
302 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
303 See [](#opt-services.nginx.virtualHosts) for further information.
304
305 Further caddy configuration can be done by adapting `services.caddy.virtualHosts.<name>`.
306 See [](#opt-services.caddy.virtualHosts) for further information.
307 '';
308 };
309 };
310 };
311
312 config = mkIf (cfg.enable) (mkMerge [
313 {
314
315 assertions =
316 (mapAttrsToList (hostName: cfg: {
317 assertion = cfg.database.createLocally -> cfg.database.user == user;
318 message = ''services.drupal.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
319 }) eachSite)
320 ++ (mapAttrsToList (hostName: cfg: {
321 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
322 message = ''services.drupal.sites."${hostName}".database.passwordFile cannot be specified if services.drupal.sites."${hostName}".database.createLocally is set to true.'';
323 }) eachSite);
324
325 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
326 enable = true;
327 package = mkDefault mariadb;
328 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
329 ensureUsers = mapAttrsToList (hostName: cfg: {
330 name = cfg.database.user;
331 ensurePermissions = {
332 "${cfg.database.name}.*" = "ALL PRIVILEGES";
333 };
334 }) eachSite;
335 };
336
337 services.phpfpm.pools = mapAttrs' (
338 hostName: cfg:
339 (nameValuePair "drupal-${hostName}" {
340 inherit user;
341 group = webserver.group;
342 settings = {
343 "listen.owner" = webserver.user;
344 "listen.group" = webserver.group;
345 }
346 // cfg.poolConfig;
347 })
348 ) eachSite;
349 }
350
351 {
352 systemd.tmpfiles.rules = flatten (
353 mapAttrsToList (hostName: cfg: [
354 "d '${cfg.stateDir}' 0750 ${user} ${webserver.group} - -"
355 "d '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -"
356 "Z '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -"
357 "d '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -"
358 "Z '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -"
359 "d '${cfg.privateFilesDir}' 0750 ${user} ${webserver.group} - -"
360 "d '${cfg.configSyncDir}' 0750 ${user} ${webserver.group} - -"
361 ]) eachSite
362 );
363
364 users.users.${user} = {
365 group = webserver.group;
366 isSystemUser = true;
367 };
368 }
369
370 {
371 # Run a service that prepares the state directory.
372 systemd.services = mkMerge [
373 (mapAttrs' (
374 hostName: cfg:
375 (nameValuePair "drupal-state-init-${hostName}" {
376 wantedBy = [ "multi-user.target" ];
377 before = [ "nginx.service" ];
378 after = [ "local-fs.target" ];
379
380 serviceConfig = {
381 Type = "oneshot";
382 User = "root";
383 RemainAfterExit = true;
384
385 ExecStart = writeShellScript "drupal-state-init-${hostName}" ''
386 set -e
387
388 if [ ! -d "${cfg.stateDir}/sites" ]; then
389 echo "Preparing sites directory..."
390 cp -r "${cfg.package}/share/php/drupal/sites" "${cfg.stateDir}"
391 fi
392
393 if [ ! -d "${cfg.filesDir}" ]; then
394 echo "Preparing files directory..."
395 mkdir -p "${cfg.filesDir}"
396 chown -R ${user}:${webserver.group} ${cfg.filesDir}
397 fi
398
399 settings_file="${cfg.stateDir}/sites/default/settings.php"
400 default_settings="${cfg.package}/share/php/drupal/sites/default/default.settings.php"
401
402 if [ ! -f "$settings_file" ]; then
403 echo "Preparing settings.php for ${hostName}..."
404 cp "$default_settings" "$settings_file"
405 cat < ${appendSettings hostName} >> "$settings_file"
406 chmod 644 "$settings_file"
407 fi
408
409 # Link the NixOS-managed settings file to the state directory.
410 ln -sf ${drupalSettings hostName cfg} ${cfg.stateDir}/sites/default/settings.nixos-${hostName}.php
411
412 # Set or reset file permissions so that the web user and webserver owns them.
413 chown -R ${user}:${webserver.group} ${cfg.stateDir}
414 '';
415 };
416
417 # Rerun this service if certain settings were updated
418 reloadTriggers = [
419 cfg.extraConfig
420 cfg.privateFilesDir
421 cfg.configSyncDir
422 ];
423 })
424 ) eachSite)
425
426 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
427 httpd.after = [ "mysql.service" ];
428 })
429 ];
430 }
431
432 (mkIf (cfg.webserver == "nginx") {
433 services.nginx = {
434 enable = true;
435 virtualHosts = mapAttrs (hostName: cfg: {
436 serverName = mkDefault hostName;
437 root = "${pkg hostName cfg}/share/php/drupal";
438 extraConfig = ''
439 index index.php;
440 '';
441 locations = {
442 "~ '\.php$|^/update.php'" = {
443 extraConfig = ''
444 fastcgi_split_path_info ^(.+\.php)(/.+)$;
445 fastcgi_pass unix:${config.services.phpfpm.pools."drupal-${hostName}".socket};
446 fastcgi_index index.php;
447 include "${config.services.nginx.package}/conf/fastcgi.conf";
448 fastcgi_param PATH_INFO $fastcgi_path_info;
449 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
450 # Mitigate https://httpoxy.org/ vulnerabilities
451 fastcgi_param HTTP_PROXY "";
452 fastcgi_intercept_errors off;
453 fastcgi_buffer_size 16k;
454 fastcgi_buffers 4 16k;
455 fastcgi_connect_timeout 300;
456 fastcgi_send_timeout 300;
457 fastcgi_read_timeout 300;
458 '';
459 };
460 "= /favicon.ico" = {
461 extraConfig = ''
462 log_not_found off;
463 access_log off;
464 '';
465 };
466 "= /robots.txt" = {
467 extraConfig = ''
468 allow all;
469 log_not_found off;
470 access_log off;
471 '';
472 };
473 "~ \..*/.*\.php$" = {
474 extraConfig = ''
475 return 403;
476 '';
477 };
478 "~ ^/sites/.*/private/" = {
479 extraConfig = ''
480 return 403;
481 '';
482 };
483 "~ ^/sites/[^/]+/files/.*\.php$" = {
484 extraConfig = ''
485 deny all;
486 '';
487 };
488 "~* ^/.well-known/" = {
489 extraConfig = ''
490 allow all;
491 '';
492 };
493 "/" = {
494 extraConfig = ''
495 try_files $uri /index.php?$query_string;
496 '';
497 };
498 "@rewrite" = {
499 extraConfig = ''
500 rewrite ^ /index.php;
501 '';
502 };
503 "~ /vendor/.*\.php$" = {
504 extraConfig = ''
505 deny all;
506 return 404;
507 '';
508 };
509 "~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = {
510 extraConfig = ''
511 try_files $uri @rewrite;
512 expires max;
513 log_not_found off;
514 '';
515 };
516 "~ ^/sites/.*/files/styles/" = {
517 extraConfig = ''
518 try_files $uri @rewrite;
519 '';
520 };
521 "~ ^(/[a-z\-]+)?/system/files/" = {
522 extraConfig = ''
523 try_files $uri /index.php?$query_string;
524 '';
525 };
526 };
527 }) eachSite;
528 };
529 })
530
531 (mkIf (cfg.webserver == "caddy") {
532 services.caddy = {
533 enable = true;
534 virtualHosts = mapAttrs' (
535 hostName: cfg:
536 (nameValuePair hostName {
537 extraConfig = ''
538 root * ${pkg hostName cfg}/share/php/drupal
539 file_server
540
541 encode zstd gzip
542 php_fastcgi unix/${config.services.phpfpm.pools."drupal-${hostName}".socket}
543 '';
544 })
545 ) cfg.sites;
546 };
547 })
548
549 ]);
550}