///|
/// A body-free plan, computed without acquiring or inspecting a live stream.
priv struct RedirectPlan {
head : @http.RequestHead
drop_body : Bool
}
///|
/// The request head to send after a response, or None when it is
/// not a redirect to follow.
fn plan_redirect(
request : @http.RequestHead,
response : @http.ResponseHead,
replayable~ : Bool,
followed~ : Int32,
max_redirects~ : Int32,
allow_downgrade~ : Bool,
credential_headers~ : Array[String],
) -> RedirectPlan? raise RedirectError {
guard redirect_status(response.status) &&
response.headers.get("location") is Some(location) else {
return None
}
let (from, to) = redirect_target(request.url, location)
if from.scheme == Some("https") &&
to.scheme == Some("http") &&
!allow_downgrade {
raise Downgrade(location=to.to_string())
}
if followed >= max_redirects {
raise TooManyRedirects(max=max_redirects, location=to.to_string())
}
let http_method = redirect_method(response.status, request.meth.to_string())
let drop_body = http_method != request.meth.to_string()
if !drop_body && !replayable {
raise UnreplayableBody(location=to.to_string())
}
let headers = redirect_headers(
request.headers,
from~,
to~,
drop_body~,
credential_headers~,
)
Some({
head: {
meth: @http.Method::parse(http_method),
url: to.without_fragment().to_string(),
headers,
},
drop_body,
})
}
///|
/// Whether `status` is a redirect this step follows. 300 leaves the choice to
/// the user, 304 is a cache answer and 305 and 306 are retired (RFC 9110
/// ยง15.4).
fn redirect_status(status : Int32) -> Bool {
status is (301 | 302 | 303 | 307 | 308)
}
///|
/// The method of the redirected request: 303 makes everything but HEAD a
/// GET, 301 and 302 make POST a GET, the rest keep theirs (Fetch, httpx).
fn redirect_method(status : Int32, http_method : String) -> String {
let upper = http_method.to_upper()
match status {
303 if upper != "GET" && upper != "HEAD" => "GET"
301 | 302 if upper == "POST" => "GET"
_ => http_method
}
}
///|
/// The request URL and the `Location` resolved against it, refused unless the
/// result is an absolute `http` or `https` URL with a host.
fn redirect_target(
request_url : String,
location : String,
) -> (@http.Url, @http.Url) raise RedirectError {
let from = @http.Url::parse(request_url) catch {
@http.UrlError::Invalid(reason) =>
raise BadLocation(location~, reason="request URL: " + reason)
}
guard from.scheme is Some("http" | "https") else {
raise BadLocation(location~, reason="request URL is not absolute http(s)")
}
let to = from.resolve(
@http.Url::parse(location) catch {
@http.UrlError::Invalid(reason) => raise BadLocation(location~, reason~)
},
)
guard to.scheme is Some("http" | "https") else {
raise BadLocation(location~, reason="not an http or https URL")
}
guard to.host is Some(host) && host != "" else {
raise BadLocation(location~, reason="no host")
}
(from, to)
}
///|
/// The headers of the redirected request: without the body's headers when
/// the body is dropped, and without `credential_headers` (lower-case) when the
/// origin changes, except on a same-host upgrade from `http` to `https` on
/// default ports.
fn redirect_headers(
headers : @http.Headers,
from~ : @http.Url,
to~ : @http.Url,
drop_body~ : Bool,
credential_headers~ : Array[String],
) -> @http.Headers {
let removed = []
if drop_body {
removed.push_iter(body_headers.iter())
}
if !from.same_origin(to) {
removed.push("host")
if !secure_upgrade(from, to) {
removed.push_iter(credential_headers.iter())
}
}
@http.Headers::of(
headers.iter().filter(pair => !removed.contains(pair.0)).collect(),
)
}
///|
let body_headers : Array[String] = [
"content-length", "content-type", "transfer-encoding", "content-encoding", "content-language",
"content-location",
]
///|
/// Whether `to` is `from` moved from `http` on port 80 to `https` on port 443
/// of the same host (httpx's `_is_https_redirect`).
fn secure_upgrade(from : @http.Url, to : @http.Url) -> Bool {
from.host == to.host &&
from.scheme == Some("http") &&
from.port_or_default() == Some(80) &&
to.scheme == Some("https") &&
to.port_or_default() == Some(443)
}