summary refs log blame commit diff
path: root/nixos/modules/services/web-apps/wordpress.nix
blob: 59471a739cbb71cddaa44e8aabc90c49165706b2 (plain) (tree)
1
2
3
4
5
6
7
8
9

                           
         
 

                                  
                       
                     
                                               




















































                                                                                                                                           
                                                                                                                                          
                                  

                                                                                                      










                                                                                                                      





                                   
                                                           


















                                                                                                      













                                                                                            
                

                                                                    









                                                                                               













                                                                                    
                

                                                                    


             
                    

















































                                                                                                      
                                                                      










                                                                                    
                                                                                        
                                        
             
                                                  

                                







                                                                                                      








                                                              

























                                                                                                        
                          
 




                                                                              
 




                                                                      
 





                                                                                                                            
        
 
      


                  
                                            
 









                                                                                                                                                                                          













                                                                                     

                                
                    

                                           
                            


                


                                    


                                      





















                                                                                                                        
                                            







                                                                                             
      
    
 
   
                                                                      


                                                                    







                                                                      
                                                     



                             
                                    







                                                                                
                           
                              

                          
   
 






















































                                                                                               


























                                                                                           
     
 
{ config, pkgs, lib, ... }:

with lib;

let
  cfg = config.services.wordpress;
  eachSite = cfg.sites;
  user = "wordpress";
  webserver = config.services.${cfg.webserver};
  stateDir = hostName: "/var/lib/wordpress/${hostName}";

  pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
    pname = "wordpress-${hostName}";
    version = src.version;
    src = cfg.package;

    installPhase = ''
      mkdir -p $out
      cp -r * $out/

      # symlink the wordpress config
      ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
      # symlink uploads directory
      ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads

      # https://github.com/NixOS/nixpkgs/pull/53399
      #
      # Symlinking works for most plugins and themes, but Avada, for instance, fails to
      # understand the symlink, causing its file path stripping to fail. This results in
      # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
      # Since hard linking directories is not allowed, copying is the next best thing.

      # copy additional plugin(s) and theme(s)
      ${concatMapStringsSep "\n" (theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${theme.name}") cfg.themes}
      ${concatMapStringsSep "\n" (plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${plugin.name}") cfg.plugins}
    '';
  };

  wpConfig = hostName: cfg: pkgs.writeText "wp-config-${hostName}.php" ''
    <?php
      define('DB_NAME', '${cfg.database.name}');
      define('DB_HOST', '${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}');
      define('DB_USER', '${cfg.database.user}');
      ${optionalString (cfg.database.passwordFile != null) "define('DB_PASSWORD', file_get_contents('${cfg.database.passwordFile}'));"}
      define('DB_CHARSET', 'utf8');
      $table_prefix  = '${cfg.database.tablePrefix}';

      require_once('${stateDir hostName}/secret-keys.php');

      # wordpress is installed onto a read-only file system
      define('DISALLOW_FILE_EDIT', true);
      define('AUTOMATIC_UPDATER_DISABLED', true);

      ${cfg.extraConfig}

      if ( !defined('ABSPATH') )
        define('ABSPATH', dirname(__FILE__) . '/');

      require_once(ABSPATH . 'wp-settings.php');
    ?>
  '';

  secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ];
  secretsScript = hostStateDir: ''
    # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839
    grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php"
    if ! test -e "${hostStateDir}/secret-keys.php"; then
      umask 0177
      echo "<?php" >> "${hostStateDir}/secret-keys.php"
      ${concatMapStringsSep "\n" (var: ''
        echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
      '') secretsVars}
      echo "?>" >> "${hostStateDir}/secret-keys.php"
      chmod 440 "${hostStateDir}/secret-keys.php"
    fi
  '';

  siteOpts = { lib, name, ... }:
    {
      options = {
        package = mkOption {
          type = types.package;
          default = pkgs.wordpress;
          defaultText = literalExpression "pkgs.wordpress";
          description = "Which WordPress package to use.";
        };

        uploadsDir = mkOption {
          type = types.path;
          default = "/var/lib/wordpress/${name}/uploads";
          description = ''
            This directory is used for uploads of pictures. The directory passed here is automatically
            created and permissions adjusted as required.
          '';
        };

        plugins = mkOption {
          type = types.listOf types.path;
          default = [];
          description = ''
            List of path(s) to respective plugin(s) which are copied from the 'plugins' directory.
            <note><para>These plugins need to be packaged before use, see example.</para></note>
          '';
          example = literalExpression ''
            let
              # Wordpress plugin 'embed-pdf-viewer' installation example
              embedPdfViewerPlugin = pkgs.stdenv.mkDerivation {
                name = "embed-pdf-viewer-plugin";
                # Download the theme from the wordpress site
                src = pkgs.fetchurl {
                  url = "https://downloads.wordpress.org/plugin/embed-pdf-viewer.2.0.3.zip";
                  sha256 = "1rhba5h5fjlhy8p05zf0p14c9iagfh96y91r36ni0rmk6y891lyd";
                };
                # We need unzip to build this package
                nativeBuildInputs = [ pkgs.unzip ];
                # Installing simply means copying all files to the output directory
                installPhase = "mkdir -p $out; cp -R * $out/";
              };
            # And then pass this theme to the themes list like this:
            in [ embedPdfViewerPlugin ]
          '';
        };

        themes = mkOption {
          type = types.listOf types.path;
          default = [];
          description = ''
            List of path(s) to respective theme(s) which are copied from the 'theme' directory.
            <note><para>These themes need to be packaged before use, see example.</para></note>
          '';
          example = literalExpression ''
            let
              # Let's package the responsive theme
              responsiveTheme = pkgs.stdenv.mkDerivation {
                name = "responsive-theme";
                # Download the theme from the wordpress site
                src = pkgs.fetchurl {
                  url = "https://downloads.wordpress.org/theme/responsive.3.14.zip";
                  sha256 = "0rjwm811f4aa4q43r77zxlpklyb85q08f9c8ns2akcarrvj5ydx3";
                };
                # We need unzip to build this package
                nativeBuildInputs = [ pkgs.unzip ];
                # Installing simply means copying all files to the output directory
                installPhase = "mkdir -p $out; cp -R * $out/";
              };
            # And then pass this theme to the themes list like this:
            in [ responsiveTheme ]
          '';
        };

        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 = "wordpress";
            description = "Database name.";
          };

          user = mkOption {
            type = types.str;
            default = "wordpress";
            description = "Database user.";
          };

          passwordFile = mkOption {
            type = types.nullOr types.path;
            default = null;
            example = "/run/keys/wordpress-dbpassword";
            description = ''
              A file containing the password corresponding to
              <option>database.user</option>.
            '';
          };

          tablePrefix = mkOption {
            type = types.str;
            default = "wp_";
            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 wp_ for your database
              prefix. Typically this is changed if you are installing multiple WordPress blogs
              in the same database.

              See <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php#table_prefix'/>.
            '';
          };

          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</option>.
          '';
        };

        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 WordPress PHP pool. See the documentation on <literal>php-fpm.conf</literal>
            for details on configuration directives.
          '';
        };

        extraConfig = mkOption {
          type = types.lines;
          default = "";
          description = ''
            Any additional text to be appended to the wp-config.php
            configuration file. This is a PHP script. For configuration
            settings, see <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php'/>.
          '';
          example = ''
            define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds
          '';
        };
      };

      config.virtualHost.hostName = mkDefault name;
    };
in
{
  # interface
  options = {
    services.wordpress = {

      sites = mkOption {
        type = types.attrsOf (types.submodule siteOpts);
        default = {};
        description = "Specification of one or more WordPress sites to serve";
      };

      webserver = mkOption {
        type = types.enum [ "httpd" "nginx" "caddy" ];
        default = "httpd";
        description = ''
          Whether to use apache2 or nginx for virtual host management.

          Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.&lt;name&gt;</literal>.
          See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.

          Further apache2 configuration can be done by adapting <literal>services.httpd.virtualHosts.&lt;name&gt;</literal>.
          See <xref linkend="opt-services.httpd.virtualHosts"/> for further information.
        '';
      };

    };
  };

  # implementation
  config = mkIf (eachSite != {}) (mkMerge [{

    assertions =
      (mapAttrsToList (hostName: cfg:
        { assertion = cfg.database.createLocally -> cfg.database.user == user;
          message = ''services.wordpress.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.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
        }) eachSite);


    services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
      enable = true;
      package = mkDefault pkgs.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 "wordpress-${hostName}" {
        inherit user;
        group = webserver.group;
        settings = {
          "listen.owner" = webserver.user;
          "listen.group" = webserver.group;
        } // cfg.poolConfig;
      }
    )) eachSite;

  }

  (mkIf (cfg.webserver == "httpd") {
    services.httpd = {
      enable = true;
      extraModules = [ "proxy_fcgi" ];
      virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
        documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
        extraConfig = ''
          <Directory "${pkg hostName cfg}/share/wordpress">
            <FilesMatch "\.php$">
              <If "-f %{REQUEST_FILENAME}">
                SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
              </If>
            </FilesMatch>

            # standard wordpress .htaccess contents
            <IfModule mod_rewrite.c>
              RewriteEngine On
              RewriteBase /
              RewriteRule ^index\.php$ - [L]
              RewriteCond %{REQUEST_FILENAME} !-f
              RewriteCond %{REQUEST_FILENAME} !-d
              RewriteRule . /index.php [L]
            </IfModule>

            DirectoryIndex index.php
            Require all granted
            Options +FollowSymLinks -Indexes
          </Directory>

          # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
          <Files wp-config.php>
            Require all denied
          </Files>
        '';
      } ]) eachSite;
    };
  })

  {
    systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
      "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
      "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
      "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
    ]) eachSite);

    systemd.services = mkMerge [
      (mapAttrs' (hostName: cfg: (
        nameValuePair "wordpress-init-${hostName}" {
          wantedBy = [ "multi-user.target" ];
          before = [ "phpfpm-wordpress-${hostName}.service" ];
          after = optional cfg.database.createLocally "mysql.service";
          script = secretsScript (stateDir hostName);

          serviceConfig = {
            Type = "oneshot";
            User = user;
            Group = webserver.group;
          };
      })) eachSite)

      (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
        httpd.after = [ "mysql.service" ];
      })
    ];

    users.users.${user} = {
      group = webserver.group;
      isSystemUser = true;
    };
  }

  (mkIf (cfg.webserver == "nginx") {
    services.nginx = {
      enable = true;
      virtualHosts = mapAttrs (hostName: cfg: {
        serverName = mkDefault hostName;
        root = "${pkg hostName cfg}/share/wordpress";
        extraConfig = ''
          index index.php;
        '';
        locations = {
          "/" = {
            priority = 200;
            extraConfig = ''
              try_files $uri $uri/ /index.php$is_args$args;
            '';
          };
          "~ \\.php$" = {
            priority = 500;
            extraConfig = ''
              fastcgi_split_path_info ^(.+\.php)(/.+)$;
              fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${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;
            '';
          };
          "~ /\\." = {
            priority = 800;
            extraConfig = "deny all;";
          };
          "~* /(?:uploads|files)/.*\\.php$" = {
            priority = 900;
            extraConfig = "deny all;";
          };
          "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
            priority = 1000;
            extraConfig = ''
              expires max;
              log_not_found off;
            '';
          };
        };
      }) eachSite;
    };
  })

  (mkIf (cfg.webserver == "caddy") {
    services.caddy = {
      enable = true;
      virtualHosts = mapAttrs' (hostName: cfg: (
        nameValuePair "http://${hostName}" {
          extraConfig = ''
            root    * /${pkg hostName cfg}/share/wordpress
            file_server

            php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}

            @uploads {
              path_regexp path /uploads\/(.*)\.php
            }
            rewrite @uploads /

            @wp-admin {
              path  not ^\/wp-admin/*
            }
            rewrite @wp-admin {path}/index.php?{query}
          '';
        }
      )) eachSite;
    };
  })


  ]);
}