{ config, lib, pkgs, ... }: let inherit (lib) any attrValues flatten getExe literalExpression mapAttrs mapAttrs' mapAttrsToList mkDefault mkEnableOption mkIf mkMerge mkOption mkPackageOption nameValuePair optionalAttrs optionalString types ; inherit (lib.strings) removePrefix removeSuffix ; inherit (pkgs) mariadb stdenv ; cfg = config.services.drupal; eachSite = cfg.sites; user = "drupal"; webserver = config.services.${cfg.webserver}; pkg = hostName: cfg: stdenv.mkDerivation (finalAttrs: { pname = "drupal-${hostName}"; name = "drupal-${hostName}"; src = cfg.package; buildInputs = [ pkgs.rsync ]; installPhase = '' runHook preInstall mkdir -p $out EXCLUDES="--exclude=${removePrefix "/" cfg.webRoot}/sites --exclude=sites" if [ ! -z "${cfg.configRoot}" ]; then EXCLUDES="$EXCLUDES --exclude=${removePrefix "/" cfg.configRoot}" fi rsync -aq * $out/ $EXCLUDES runHook postInstall ''; postInstall = '' ln -s ${cfg.stateDir}/sites $out/share/php/${cfg.package.pname}${cfg.webRoot} ln -s ${cfg.modulesDir} $out/share/php/${cfg.package.pname}${cfg.webRoot}/modules/nixos-modules ln -s ${cfg.themesDir} $out/share/php/${cfg.package.pname}${cfg.webRoot}/themes/nixos-themes ''; }); sites = hostName: cfg: stdenv.mkDerivation (finalAttrs: { pname = "drupal-sites-${hostName}"; name = "drupal-sites-${hostName}"; src = cfg.package; buildInputs = with pkgs; [ rsync ]; installPhase = '' runHook preInstall mkdir -p $out/sites rsync -a ./share/php/${cfg.package.pname}${cfg.webRoot}/sites/* $out/sites/ runHook postInstall ''; }); configSync = hostName: cfg: optionalString (cfg.configRoot != "") ( stdenv.mkDerivation (finalAttrs: { pname = "drupal-config-${hostName}"; name = "drupal-config-${hostName}"; src = cfg.package; buildInputs = with pkgs; [ rsync ]; installPhase = '' runHook preInstall mkdir -p $out/config rsync -a ./share/php/${cfg.package.pname}${cfg.configRoot}/* $out/config/ runHook postInstall ''; }) ); drupalSettings = hostName: cfg: pkgs.writeTextFile { name = "settings.nixos-${hostName}.php"; text = '' # Override the handler again if we're run later in the evaluation list. SetHandler Drupal_Security_Do_Not_Remove_See_SA_2013_003 # If we know how to do it safely, disable the PHP engine entirely. php_flag engine off ''; }; stateDirManage = hostName: cfg: pkgs.writeShellApplication { name = "drupal-state-init-${hostName}"; excludeShellChecks = [ "SC2194" "SC2157" ]; runtimeInputs = with pkgs; [ rsync ]; text = '' echo "Updating the sites directory for ${hostName}..." rsync -auq "${sites hostName cfg}/sites/" "${cfg.stateDir}/sites/" \ --exclude "*/files" \ --delete-before if [ ! -z "${cfg.configRoot}" ]; then echo "Updating config sync directory for ${hostName}..." rsync -auq "${configSync hostName cfg}/config/" "${removeSuffix "/sync" cfg.configSyncDir}" \ --delete-before fi if [ ! -d "${cfg.filesDir}" ]; then echo "Preparing files directory..." mkdir -p "${cfg.filesDir}" chown -R ${user}:${webserver.group} ${cfg.filesDir} fi case ${cfg.filesDir} in ${cfg.stateDir}/sites*) echo "Files directory is in sites directory. Skipping optional link!";; *) ln -sf "${cfg.filesDir}" "${cfg.stateDir}/sites/default/files";; esac if [ ! -f "${cfg.privateFilesDir}/.htaccess" ]; then echo "Linking .htaccess file for private files directory..." ln -s "${privateFilesHtAccess}" "${cfg.privateFilesDir}/.htaccess" fi echo "Preparing settings.php for ${hostName}..." settings_file="${cfg.stateDir}/sites/default/settings.php" if [ ! -f "$settings_file" ]; then default_settings_file="${cfg.stateDir}/sites/default/default.settings.php"; cp "$default_settings_file" "$settings_file" fi if ! grep -qF "require dirname(__FILE__) . '/settings.nixos-${hostName}.php';" ${cfg.stateDir}/sites/default/settings.php; then echo "Appending NixOS generated settings..." cat < ${appendSettings hostName} >> "$settings_file" fi # Link the NixOS-managed settings file to the state directory. ln -sf ${drupalSettings hostName cfg} ${cfg.stateDir}/sites/default/settings.nixos-${hostName}.php # Set or reset file permissions so that the web user and webserver owns them. chown -R ${user}:${webserver.group} ${cfg.stateDir} ''; }; siteOpts = { options, config, lib, name, ... }: { options = { enable = mkEnableOption "Drupal web application"; package = mkPackageOption pkgs "drupal" { }; filesDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}/sites/default/files"; defaultText = "/var/lib/drupal//sites/default/files"; description = '' The location of the Drupal files directory. Many of the files in this directory are variable, so they must be located in a location writeable by users of the webgroup. ''; }; privateFilesDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}/private"; defaultText = "/var/lib/drupal//private"; description = "The location of the Drupal private files directory."; }; configSyncDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}/config/sync"; defaultText = "/var/lib/drupal//config/sync"; description = '' The location of the user-managed Drupal config sync directory. Drupal will both read from and write to this directory when executing configuration management operations. This option differs from the `configRoot` option, which this service uses to discover the location of the config sync directory in the package's source code. ''; }; webRoot = mkOption { type = types.str; default = ""; description = '' An optional path string with a leading slash indicating the location of the Drupal webroot in your package's source code. The path relative to the project root directory. ''; example = "/web"; }; configRoot = mkOption { type = types.str; default = ""; description = '' An optional path string with a leading slash indicating the location of the config sync directory on the Drupal package. Your package will probably have a config sync directory if it has been significantly customized. The path must be relative to the project root directory. This option differs from the `configSyncDir` option, which tells this service where to create a user-writeable config directory on NixOS. ''; example = "/config"; }; extraConfig = mkOption { type = types.lines; default = ""; description = '' Extra configuration values that you want to insert into settings.php. All configuration must be written as PHP script. ''; example = '' $config['user.settings']['anonymous'] = 'Visitor'; $settings['entity_update_backup'] = TRUE; ''; }; stateDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}"; defaultText = "/var/lib/drupal/"; description = '' The location of the user-managed Drupal site state directory. This directory will contain the settings and configuration files for your Drupal instance. It may also contain your files directory if the `filesDir` option remains unchanged. Many of the files in this directory are variable, so they must be located in a location writeable by users of the webgroup. ''; }; modulesDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}/modules"; defaultText = "/var/lib/drupal//modules"; description = '' The location for users to manually install Drupal modules. Note: in most instances, it is preferable to install modules using composer, or to package them with your source code repository, if you are using a custom Drupal. ''; }; themesDir = mkOption { type = types.path; default = "/var/lib/drupal/${name}/themes"; defaultText = "/var/lib/drupal//themes"; description = '' The location for users to manually install Drupal themes. Note: in most instances, it is preferable to install themes using composer, or to package them with your source code repository, if you are using a custom Drupal. ''; }; phpOptions = mkOption { type = types.attrsOf types.str; default = { }; description = '' Options for PHP's php.ini file for this Drupal site. ''; example = literalExpression '' { "opcache.interned_strings_buffer" = "8"; "opcache.max_accelerated_files" = "10000"; "opcache.memory_consumption" = "128"; "opcache.revalidate_freq" = "15"; "opcache.fast_shutdown" = "1"; } ''; }; database = { host = mkOption { type = types.str; default = "localhost"; description = "Database host address."; }; port = mkOption { type = types.port; default = 3306; description = "Database host port."; }; name = mkOption { type = types.str; default = "drupal"; description = "Database name."; }; user = mkOption { type = types.str; default = "drupal"; description = "Database user."; }; passwordFile = mkOption { type = types.nullOr types.path; default = null; example = "/run/keys/database-dbpassword"; description = '' A file containing the password corresponding to {option}`database.user`. ''; }; tablePrefix = mkOption { type = types.str; default = "dp_"; description = '' 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 dp_ for your database prefix. Typically this is changed if you are installing multiple Drupal sites in the same database. ''; }; socket = mkOption { type = types.nullOr types.path; default = null; defaultText = literalExpression "/run/mysqld/mysqld.sock"; description = "Path to the unix socket file to use for authentication."; }; createLocally = mkOption { type = types.bool; default = true; description = "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 = '' 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 = '' Options for the Drupal PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives. ''; }; }; config.virtualHost.hostName = mkDefault name; }; in { options = { services.drupal = { enable = mkEnableOption "drupal"; package = mkPackageOption pkgs "drupal" { }; sites = mkOption { type = types.attrsOf (types.submodule siteOpts); default = { "localhost" = { enable = true; }; }; description = "Specification of one or more Drupal sites to serve"; }; webserver = mkOption { type = types.enum [ "nginx" "caddy" ]; default = "nginx"; description = '' Whether to use nginx or caddy for virtual host management. Further nginx configuration can be done by adapting `services.nginx.virtualHosts.`. See [](#opt-services.nginx.virtualHosts) for further information. Further caddy configuration can be done by adapting `services.caddy.virtualHosts.`. See [](#opt-services.caddy.virtualHosts) for further information. ''; }; }; }; config = mkIf (cfg.enable) (mkMerge [ { assertions = (mapAttrsToList (hostName: cfg: { assertion = cfg.database.createLocally -> cfg.database.user == user; message = ''services.drupal.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.drupal.sites."${hostName}".database.passwordFile cannot be specified if services.drupal.sites."${hostName}".database.createLocally is set to true.''; }) eachSite); services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) { enable = true; package = mkDefault 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 "drupal-${hostName}" { inherit user; group = webserver.group; settings = { "listen.owner" = webserver.user; "listen.group" = webserver.group; } // cfg.poolConfig; }) ) eachSite; } { systemd.tmpfiles.rules = flatten ( mapAttrsToList (hostName: cfg: [ "d '${cfg.stateDir}' 0750 ${user} ${webserver.group} - -" "d '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" "Z '${cfg.modulesDir}' 0750 ${user} ${webserver.group} - -" "d '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" "Z '${cfg.themesDir}' 0750 ${user} ${webserver.group} - -" "d '${cfg.privateFilesDir}' 0750 ${user} ${webserver.group} - -" "d '${cfg.filesDir}' 0750 ${user} ${webserver.group} - -" "Z '${cfg.filesDir}' 0750 ${user} ${webserver.group} - -" "d '${cfg.configSyncDir}' 0750 ${user} ${webserver.group} - -" ]) eachSite ); users.users.${user} = { group = webserver.group; isSystemUser = true; }; } { # Run a service that prepares the state directory. systemd.services = mkMerge [ (mapAttrs' ( hostName: cfg: (nameValuePair "drupal-state-init-${hostName}" { wantedBy = [ "multi-user.target" ]; before = [ "nginx.service" ]; after = [ "local-fs.target" ]; serviceConfig = { Type = "oneshot"; User = "root"; RemainAfterExit = true; ExecStart = getExe (stateDirManage hostName cfg); }; # Rerun this service if certain settings were updated reloadTriggers = [ cfg.extraConfig cfg.privateFilesDir cfg.filesDir cfg.stateDir cfg.configSyncDir ]; }) ) eachSite) (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) { httpd.after = [ "mysql.service" ]; }) ]; } (mkIf (cfg.webserver == "nginx") { services.nginx = { enable = true; virtualHosts = mapAttrs (hostName: cfg: { serverName = mkDefault hostName; root = "${pkg hostName cfg}/share/php/${cfg.package.pname}${cfg.webRoot}"; extraConfig = '' index index.php index.htm index.html; ''; locations = { "~ '\\.php$|^/update\\.php'" = { extraConfig = '' fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass unix:${config.services.phpfpm.pools."drupal-${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; ''; }; "= /favicon.ico" = { extraConfig = '' log_not_found off; access_log off; ''; }; "= /robots.txt" = { extraConfig = '' allow all; log_not_found off; access_log off; ''; }; "~ \\..*/.*\\.php$" = { extraConfig = '' return 403; ''; }; "~ ^/sites/.*/private/" = { extraConfig = '' return 403; ''; }; "~ ^/sites/[^/]+/files/.*\\.php$" = { extraConfig = '' deny all; ''; }; "~* ^/.well-known/" = { extraConfig = '' allow all; ''; }; "/" = { extraConfig = '' try_files $uri /index.php?$query_string; ''; }; "@rewrite" = { extraConfig = '' rewrite ^ /index.php; ''; }; "~ /vendor/.*\\.php$" = { extraConfig = '' deny all; return 404; ''; }; "~* \\.(js|css|png|jpg|jpeg|gif|ico|svg)$" = { extraConfig = '' try_files $uri @rewrite; expires max; log_not_found off; ''; }; "~ ^/sites/.*/files/styles/" = { extraConfig = '' alias ${cfg.filesDir}/; try_files $uri @rewrite; ''; }; "^~ /sites/.*/files/" = { extraConfig = '' alias ${cfg.filesDir}/; try_files $uri @rewrite; ''; }; "~ ^(/[a-z\\-]+)?/system/files/" = { extraConfig = '' alias ${cfg.privateFilesDir}/; try_files $uri /index.php?$query_string; ''; }; }; }) eachSite; }; }) (mkIf (cfg.webserver == "caddy") { services.caddy = { enable = true; virtualHosts = mapAttrs' ( hostName: cfg: (nameValuePair hostName { extraConfig = '' root * ${pkg hostName cfg}/share/php/${cfg.package.pname}${cfg.webRoot} file_server root /sites/*/files ${cfg.filesDir} encode zstd gzip php_fastcgi unix/${config.services.phpfpm.pools."drupal-${hostName}".socket} ''; }) ) cfg.sites; }; }) ]); }