diff --git a/lang/en b/lang/en index 5070c67..5cec2d8 100644 --- a/lang/en +++ b/lang/en @@ -78,6 +78,7 @@ redirect_eupstream=A balancer named $1 already exists redirect_eurl=Not a valid URL or UDS : $1 redirect_eurlhost=Hostname in URL cannot be resolved : $1 redirect_eclash=A location for $1 already exists +redirect_ephd=Nginx only allows alias destination directories under $1 links_anlog=Nginx Access Log links_enlog=Nginx Error Log diff --git a/virtual_feature.pl b/virtual_feature.pl index 8d8c4b2..1c641a4 100644 --- a/virtual_feature.pl +++ b/virtual_feature.pl @@ -1793,6 +1793,165 @@ sub unquote_web_redirect_path_re return $path; } +# make_web_alias_rewrite(path, destination-uri, regexp, exact) +# Returns the regex and replacement for an internal directory alias. The +# generated rule will not rewrite its own internal destination a second time. +sub make_web_alias_rewrite +{ +my ($path, $dest, $regexp, $exact) = @_; +my $trailingslash = $path ne '/' && $path =~ m{/$}; +my $matchpath = $path; +$matchpath =~ s{/$}{} if ($trailingslash); +my $pathbase = $path; +$pathbase =~ s{/+\z}{} if ($pathbase ne '/'); +my $destslash = $dest ne '/' && $dest =~ m{/$}; +$dest =~ s{/+\z}{} if ($dest ne '/'); + +my $re; +if ($path eq '/') { + my @exclude = ('\\.well-known(?:/|$)'); + if ($dest ne '/') { + (my $destpath = $dest) =~ s{^/}{}; + push(@exclude, "e_web_redirect_path_re($destpath). + '(?:/|$)'); + } + $re = '^/(?!'.join('|', @exclude).')'; + } +else { + $re = '^'."e_web_redirect_path_re($matchpath); + $re .= '/' if ($trailingslash); + if (index($dest, $pathbase.'/') == 0) { + my $destpath = substr($dest, length($pathbase)+1); + $re .= ($trailingslash ? '(?!' : '(?!/'). + "e_web_redirect_path_re($destpath). + '(?:/|$))'; + } + } + +if ($exact) { + # The empty group identifies alias rules that do not need a path capture. + $re .= '(?:)$'; + } +elsif ($regexp) { + # Keep the same marker on aliases that intentionally discard sub-paths. + $re .= $path eq '/' || $trailingslash ? '.*(?:)$' : + '(?:/.*)?(?:)$'; + } +else { + $re .= $path eq '/' ? '(.*)$' : + $trailingslash ? '(.*)$' : '(?:/(.*))?$'; + } + +my $replacement; +if ($regexp || $exact) { + $replacement = $dest; + $replacement .= '/' if ($dest ne '/' && $destslash); + } +else { + $replacement = ($dest eq '/' ? '' : $dest).'/'.'$1'; + } +return ($re, $replacement); +} + +# parse_web_alias_rewrite(&rewrite) +# Parses only the exact rule shape emitted by make_web_alias_rewrite. +sub parse_web_alias_rewrite +{ +my ($r) = @_; +return undef if (!$r->{'words'}->[2] || + $r->{'words'}->[2] ne 'break' || + $r->{'words'}->[1] !~ m{^/}); + +my $dest = $r->{'words'}->[1]; +my $keepsubpaths = $dest =~ s{/\$1\z}{}; +$dest = '/' if ($keepsubpaths && $dest eq ''); +my $pathre = $r->{'words'}->[0]; +my $path; +my ($regexp, $exact); + +# The root form always starts with the well-known exclusion. +if ($pathre =~ m{^\^/\(\?!\\\.well-known\(\?:/\|\$\)}) { + $path = '/'; + if (!$keepsubpaths) { + if ($pathre =~ /\.\*\(\?:\)\$\z/) { + $regexp = 1; + } + elsif ($pathre =~ /\(\?:\)\$\z/) { + $exact = 1; + } + else { + return undef; + } + } + } +else { + my $quoted = $pathre; + my $trailingslash; + if ($keepsubpaths) { + if (!($quoted =~ s/\(\?:\/\(\.\*\)\)\?\$\z//)) { + $quoted =~ s/\(\.\*\)\$\z// || return undef; + $trailingslash = 1; + } + } + elsif ($quoted =~ s/\(\?:\/\.\*\)\?\(\?:\)\$\z//) { + $regexp = 1; + } + elsif ($quoted =~ s/\.\*\(\?:\)\$\z//) { + $regexp = 1; + $trailingslash = 1; + } + elsif ($quoted =~ s/\(\?:\)\$\z//) { + $exact = 1; + } + else { + return undef; + } + $quoted =~ s/(?{'words'}->[1] ne $expected_dest); +return { 'path' => $path, + 'dest' => $dest, + 'regexp' => $regexp, + 'exact' => $exact, + 'object' => $r, + '_alias' => 1 }; +} + +# find_web_alias_container(&server, [root-only]) +# Returns the first top-level rewrite or if block containing one of the +# aliases generated above. Redirects must be inserted before this object, +# because an alias using break stops later server-level rewrite processing. +sub find_web_alias_container +{ +my ($server, $rootonly) = @_; +foreach my $member (@{$server->{'members'}}) { + my $rewrite; + if ($member->{'name'} eq 'rewrite') { + $rewrite = $member; + } + elsif ($member->{'name'} eq 'if') { + ($rewrite) = &nginx::find('rewrite', $member); + } + my $alias = $rewrite && &parse_web_alias_rewrite($rewrite); + return $member if ($alias && (!$rootonly || $alias->{'path'} eq '/')); + } +return undef; +} + # feature_list_web_redirects(&domain) # Finds redirects from rewrite directives in the Nginx config sub feature_list_web_redirects @@ -1850,9 +2009,9 @@ sub feature_list_web_redirects } } foreach my $r (@rewrites) { - my $redirect; + my $redirect = &parse_web_alias_rewrite($r); my $pathre = $r->{'words'}->[0]; - if ($r->{'words'}->[2] && + if (!$redirect && $r->{'words'}->[2] && $r->{'words'}->[2] =~ /break|redirect|permanent/ && $r->{'words'}->[0] !~ /\^\/\(\?\!\.well\-known\)/) { # Regular redirect. Strip our known suffix first; what remains @@ -1919,12 +2078,26 @@ sub feature_list_web_redirects } } if ($redirect) { + my $parsed_alias = delete($redirect->{'_alias'}); if ($r->{'words'}->[1] =~ /^(http|https):/) { # Redirect to a specific URL $redirect->{'dest'} = &replace_apache_vars( $redirect->{'dest'}, 0); $redirect->{'alias'} = 0; } + elsif ($parsed_alias) { + # Internal URI generated for a directory alias + $redirect->{'dest'} = $phd.$redirect->{'dest'}; + $redirect->{'alias'} = 1; + delete($redirect->{'code'}); + } + elsif ($r->{'words'}->[2] eq 'break' && + $redirect->{'dest'} =~ /^\Q$phd\E(?:\/|$)/) { + # Legacy directory aliases used an absolute filesystem path + # directly as the rewrite replacement. + $redirect->{'alias'} = 1; + delete($redirect->{'code'}); + } elsif ($r->{'words'}->[2] eq 'permanent' || $r->{'words'}->[2] eq 'redirect' || $r->{'words'}->[2] eq 'break') { @@ -1960,7 +2133,15 @@ sub feature_create_web_redirect return &text('redirect_efind', $d->{'dom'}) if (!$server); my $phd = &virtual_server::public_html_dir($d); my $dest = $redirect->{'dest'}; -if ($dest =~ /^(http|https|\$scheme):/) { +if ($redirect->{'alias'}) { + # A directory alias destination is a filesystem path, while an Nginx + # rewrite replacement is a URI. Convert destinations below the domain's + # document root to the corresponding internal URI. + $dest =~ s/^\Q$phd\E(?=\/|$)// || + return &text('redirect_ephd', $phd); + $dest = '/' if ($dest eq ''); + } +elsif ($dest =~ /^(http|https|\$scheme):/) { $dest = &replace_apache_vars($dest, 1); } @@ -1994,18 +2175,28 @@ sub feature_create_web_redirect last; } } - &nginx::save_directive($server, [ ], [ $i ]); + my $before = &find_web_alias_container($server); + &nginx::save_directive($server, [ ], [ $i ], $before); &nginx::flush_config_file_lines(); &nginx::unlock_all_config_files(); &virtual_server::register_post_action(\&print_apply_nginx); return undef; } -# Nginx rewrite rules take PCRE regexes. Quote the user-entered path -# literally, then append our capture/exact suffix below. -my $re = $redirect->{'path'}; -if ($re !~ /\^\/\(\?\!\.well\-known\)/) { - $re = '^'."e_web_redirect_path_re($re); +# Nginx rewrite rules take PCRE regexes. Directory aliases need a specialized +# internal rule that avoids recursive rewrites. Redirect paths are quoted +# literally, then their capture or exact suffix is appended below. +my $re; +if ($redirect->{'alias'}) { + ($re, $dest) = &make_web_alias_rewrite( + $redirect->{'path'}, $dest, + $redirect->{'regexp'}, $redirect->{'exact'}); + } +else { + $re = $redirect->{'path'}; + if ($re !~ /\^\/\(\?\!\.well\-known\)/) { + $re = '^'."e_web_redirect_path_re($re); + } } my @c = !$redirect->{'code'} && $redirect->{'alias'} ? ( 'break' ) : !$redirect->{'code'} && !$redirect->{'alias'} ? ( 'redirect' ) : @@ -2014,7 +2205,8 @@ sub feature_create_web_redirect my $r = { 'name' => 'rewrite', 'words' => [ $re, $dest, @c ], }; -if ($re !~ /\^\/\(\?\!\.well\-known\)/) { +if (!$redirect->{'alias'} && + $re !~ /\^\/\(\?\!\.well\-known\)/) { if ($redirect->{'regexp'}) { # All sub-directories go to same dest path $r->{'words'}->[0] .= "(.*)"; @@ -2043,9 +2235,30 @@ sub feature_create_web_redirect $r->{'words'}->[1] .= "?"; } &nginx::lock_all_config_files(); +if ($redirect->{'alias'}) { + # try_files can clear $fastcgi_path_info. Do not pass an empty PATH_INFO + # value to PHP, so applications can fall back to REQUEST_URI. + my @params = &nginx::find("fastcgi_param", $server); + my $changed; + foreach my $param (@params) { + if ($param->{'words'}->[0] eq 'PATH_INFO' && + $param->{'words'}->[1] eq '$fastcgi_path_info' && + (!defined($param->{'words'}->[2]) || + $param->{'words'}->[2] ne 'if_not_empty')) { + $param->{'words'}->[2] = 'if_not_empty'; + $changed++; + } + } + &nginx::save_directive($server, "fastcgi_param", \@params) + if ($changed); + } +my $before = !$redirect->{'alias'} ? + &find_web_alias_container($server) : + $redirect->{'path'} ne '/' ? + &find_web_alias_container($server, 1) : undef; if ($redirect->{'http'} && $redirect->{'https'}) { # Can just go at top level - &nginx::save_directive($server, [ ], [ $r ]); + &nginx::save_directive($server, [ ], [ $r ], $before); } else { # Put under an 'if' statement @@ -2054,7 +2267,7 @@ sub feature_create_web_redirect 'type' => 1, 'members' => [ $r ], 'words' => [ '$scheme', '=', $s ] }; - &nginx::save_directive($server, [ ], [ $i ]); + &nginx::save_directive($server, [ ], [ $i ], $before); } &nginx::flush_config_file_lines(); &nginx::unlock_all_config_files();