diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 29be462d..69e40433 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -8,8 +8,6 @@ use EE\Model\Cron; use EE\Model\Site; use EE\Model\Option; -use EE\Model\Auth; -use EE\Model\Whitelist; use Symfony\Component\Filesystem\Filesystem; use function EE\Site\Cloner\Utils\check_site_access; use function EE\Site\Cloner\Utils\copy_site_db; @@ -28,6 +26,8 @@ use function EE\Site\Utils\get_site_info; use function EE\Site\Utils\reload_global_nginx_proxy; use function EE\Site\Utils\get_parent_of_alias; +use function EE\Site\Utils\split_alias_domains; +use function EE\Site\Utils\validate_alias_domains; /** * Base class for Site command @@ -350,45 +350,18 @@ protected function delete_site( $level, $site_url, $site_fs_path, $db_data = [] if ( $level > 4 ) { if ( $this->site_data['site_ssl'] ) { \EE::log( 'Removing ssl certs and other config files.' ); - $crt_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.crt"; - $key_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.key"; - $pem_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.chain.pem"; - $conf_certs = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/certs/$site_url"; - $conf_var = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/var/$site_url"; - $htpasswd_file = EE_ROOT_DIR . "/services/nginx-proxy/htpasswd/$site_url"; - - $delete_files = [ $conf_certs, $conf_var, $crt_file, $key_file, $pem_file, $htpasswd_file ]; - try { - $this->fs->remove( $delete_files ); - } catch ( \Exception $e ) { - \EE::warning( $e ); - } - } + $crt_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.crt"; + $key_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.key"; + $pem_file = EE_ROOT_DIR . "/services/nginx-proxy/certs/$site_url.chain.pem"; + $conf_certs = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/certs/$site_url"; + $conf_var = EE_ROOT_DIR . "/services/nginx-proxy/acme-conf/var/$site_url"; - $site_auth_file = EE_ROOT_DIR . '/services/nginx-proxy/htpasswd/' . $site_url; - if ( $this->fs->exists( $site_auth_file ) ) { + $delete_files = [ $conf_certs, $conf_var, $crt_file, $key_file, $pem_file ]; try { - $this->fs->remove( $site_auth_file ); + $this->fs->remove( $delete_files ); } catch ( \Exception $e ) { \EE::warning( $e ); } - reload_global_nginx_proxy(); - } - - $whitelists = Whitelist::where( [ - 'site_url' => $site_url, - ] ); - - foreach ( $whitelists as $whitelist ) { - $whitelist->delete(); - } - - $auths = Auth::where( [ - 'site_url' => $site_url, - ] ); - - foreach ( $auths as $auth ) { - $auth->delete(); } if ( Site::find( $site_url )->delete() ) { @@ -522,6 +495,7 @@ protected function update_alias_domains( $args, $assoc_args ) { $add_domains = get_flag_value( $assoc_args, 'add-alias-domains', false ); $delete_domains = get_flag_value( $assoc_args, 'delete-alias-domains', false ); + $pre_hook_fired = false; try { @@ -529,25 +503,21 @@ protected function update_alias_domains( $args, $assoc_args ) { $array_data = (array) $this->site_data; $this->site_data = reset( $array_data ); - // Validate data. - $existing_alias_domains = []; - $domains_to_add = []; - $domains_to_delete = []; + // Drop blanks so that e.g. `b.com,` never stores an empty alias domain. + $existing_alias_domains = split_alias_domains( (string) $this->site_data['alias_domains'] ); + $domains_to_add = split_alias_domains( $add_domains ); + $domains_to_delete = split_alias_domains( $delete_domains ); - if ( ! empty( $this->site_data['alias_domains'] ) ) { - $existing_alias_domains = explode( ',', $this->site_data['alias_domains'] ); - } - if ( ! empty( $add_domains ) ) { - $domains_to_add = explode( ',', $add_domains ); - } - if ( ! empty( $delete_domains ) ) { - $domains_to_delete = explode( ',', $delete_domains ); + if ( empty( $domains_to_add ) && empty( $domains_to_delete ) ) { + EE::error( 'Please provide at least one alias domain to add or delete.' ); } + validate_alias_domains( $domains_to_add ); + $already_added_domains = array_intersect( $existing_alias_domains, $domains_to_add ); - $domains_to_add = array_diff( $domains_to_add, $existing_alias_domains ); + $domains_to_add = array_values( array_diff( $domains_to_add, $existing_alias_domains ) ); - if ( empty( $domains_to_add ) && $add_domains ) { + if ( empty( $domains_to_add ) && ! empty( $already_added_domains ) ) { $already_added_domains = implode( ',', $already_added_domains ); EE::error( "Alias domains: $already_added_domains is/are already present on the site." ); } @@ -583,6 +553,18 @@ protected function update_alias_domains( $args, $assoc_args ) { $final_alias_domains = array_merge( $existing_alias_domains, $domains_to_add ); $final_alias_domains = array_diff( $final_alias_domains, $domains_to_delete ); + // Set before firing, so a callback that throws still gets the failure hook to undo its partial work. + $pre_hook_fired = true; + + /** + * Execute before the new alias domains of a site are served by the proxy. + * Note: This can be used by package commands to set up per-domain config the proxy needs from the first request. + * + * @param string $site_url Url of site whose alias domains change. + * @param array $domains_to_add Alias domains that are being added. + */ + \EE::do_hook( 'site_alias_domains_before_update', $this->site_data['site_url'], $domains_to_add ); + $this->site_data['alias_domains'] = implode( ',', $final_alias_domains ); $is_ssl = $this->site_data['site_ssl'] ? true : false; $preferred_ssl_challenge = get_preferred_ssl_challenge( get_domains_of_site( $this->site_data['site_url'] ) ); @@ -591,6 +573,16 @@ protected function update_alias_domains( $args, $assoc_args ) { $this->dump_docker_compose_yml( [ 'nohttps' => $nohttps ] ); \EE_DOCKER::docker_compose_up( $this->site_data['site_fs_path'], [ 'nginx' ] ); } catch ( \Exception $e ) { + if ( $pre_hook_fired ) { + /** + * Execute when an alias domains update is aborted after `site_alias_domains_before_update`. + * Note: The site keeps its old alias domains, so this can be used to undo what was set up for the new ones. + * + * @param string $site_url Url of site whose alias domains update failed. + * @param array $domains_to_add Alias domains that were not added after all. + */ + \EE::do_hook( 'site_alias_domains_update_failed', $site->site_url, $domains_to_add ); + } EE::error( $e->getMessage() ); } @@ -620,6 +612,7 @@ protected function update_alias_domains( $args, $assoc_args ) { } catch ( \Exception $e ) { EE::warning( 'Certificate could not be issued. Reverting back to original state.' ); $this->enable( [ $this->site_data['site_url'] ], [ 'refresh' => 'true' ] ); + \EE::do_hook( 'site_alias_domains_update_failed', $site->site_url, $domains_to_add ); EE::error( $e->getMessage() ); } } elseif ( 'custom' === $this->site_data['site_ssl'] ) { @@ -653,6 +646,17 @@ protected function update_alias_domains( $args, $assoc_args ) { ]; $this->update_proxy_cache( $args, $assoc_args ); } + + /** + * Execute after the alias domains of a site have been updated. + * Note: This can be used by package commands to sync their per-domain config. + * + * @param string $site_url Url of site whose alias domains changed. + * @param array $domains_to_add Alias domains that were added. + * @param array $domains_to_delete Alias domains that were removed. + */ + \EE::do_hook( 'site_alias_domains_updated', $this->site_data['site_url'], $domains_to_add, $domains_to_delete ); + delem_log( 'site alias domains update end' ); } diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index d7afe955..886abf4b 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -741,6 +741,71 @@ function check_alias_in_db( $domains ) { } } +/** + * Splits a comma separated list of alias domains, trimming them and dropping blank entries. + * + * @param string|bool $domains Comma separated alias domains, as passed to the alias domain flags. + * + * @return array + */ +function split_alias_domains( $domains ) { + + // A flag passed without a value is `true`, which would otherwise become the alias domain `1`. + if ( ! is_string( $domains ) ) { + return []; + } + + return array_values( array_filter( array_map( 'trim', explode( ',', $domains ) ), 'strlen' ) ); +} + +/** + * Checks whether a name is one of the global proxy file names (e.g. auth-command's htpasswd and ACL files), in any case. + * + * @param string $name File name. + * + * @return bool + */ +function is_reserved_proxy_file_name( $name ) { + + return in_array( strtolower( (string) $name ), [ 'default', 'default_admin_tools' ], true ); +} + +/** + * Checks whether an alias domain is a plain hostname or `*.hostname` that is safe to use as a proxy file name. + * + * @param string $domain Alias domain. + * + * @return bool + */ +function is_valid_alias_domain( $domain ) { + + // No leading `_`, so an alias can't take over the `_wildcard.` files of another site. + $label = '[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?'; + + return is_string( $domain ) + && 1 === preg_match( '/^(?:\*\.)?' . $label . '(?:\.' . $label . ')*$/D', $domain ) + && ! is_reserved_proxy_file_name( $domain ); +} + +/** + * Exits with an error listing the alias domains that are not a plain hostname or `*.hostname`. + * + * @param array $domains Alias domains. + */ +function validate_alias_domains( $domains ) { + + $invalid = array_filter( + $domains, + function ( $domain ) { + return ! is_valid_alias_domain( $domain ); + } + ); + + if ( ! empty( $invalid ) ) { + \EE::error( sprintf( 'Invalid alias domain(s): %s. An alias domain must be a hostname or `*.hostname` whose labels use letters, digits, `-` and `_`, do not start with `-` or `_` (a leading `_` is reserved for proxy files like `_wildcard.`) and do not end with `-`. It can not be `default` or `default_admin_tools`.', implode( ', ', $invalid ) ) ); + } +} + /** * 'sysctl' parameters for docker-compose file. * diff --git a/src/site-type/HTML.php b/src/site-type/HTML.php index 39869625..c773ca9e 100644 --- a/src/site-type/HTML.php +++ b/src/site-type/HTML.php @@ -12,6 +12,8 @@ use function EE\Site\Utils\get_public_dir; use function EE\Site\Utils\get_webroot; use function EE\Site\Utils\check_alias_in_db; +use function EE\Site\Utils\split_alias_domains; +use function EE\Site\Utils\validate_alias_domains; use function EE\Utils\get_flag_value; /** @@ -116,9 +118,10 @@ public function create( $args, $assoc_args ) { \EE::error( sprintf( "Site %1\$s already exists. If you want to re-create it please delete the older one using:\n`ee site delete %1\$s`", $this->site_data['site_url'] ) ); } - $alias_domains = \EE\Utils\get_flag_value( $assoc_args, 'alias-domains', '' ); + $alias_domains = split_alias_domains( \EE\Utils\get_flag_value( $assoc_args, 'alias-domains', '' ) ); + validate_alias_domains( $alias_domains ); - $alias_domain_to_check = explode( ',', $alias_domains ); + $alias_domain_to_check = $alias_domains; $alias_domain_to_check[] = $this->site_data['site_url']; check_alias_in_db( $alias_domain_to_check ); @@ -127,17 +130,7 @@ public function create( $args, $assoc_args ) { $this->skip_status_check = \EE\Utils\get_flag_value( $assoc_args, 'skip-status-check' ); $this->site_data['site_container_fs_path'] = get_public_dir( $assoc_args ); - $this->site_data['alias_domains'] = $this->site_data['site_url']; - $this->site_data['alias_domains'] .= ','; - if ( ! empty( $alias_domains ) ) { - $comma_seprated_domains = explode( ',', $alias_domains ); - foreach ( $comma_seprated_domains as $domain ) { - $trimmed_domain = trim( $domain ); - $this->site_data['alias_domains'] .= $trimmed_domain . ','; - } - } - - $this->site_data['alias_domains'] = substr( $this->site_data['alias_domains'], 0, - 1 ); + $this->site_data['alias_domains'] = implode( ',', array_merge( [ $this->site_data['site_url'] ], $alias_domains ) ); $this->site_data['site_ssl'] = get_value_if_flag_isset( $assoc_args, 'ssl', 'le' ); if ( 'custom' === $this->site_data['site_ssl'] ) {