// The route-level checks, as moonapi hoops.
//
// One hoop per option that can refuse a request, in the order the options were
// asked for. A hoop sees the whole context — params, headers and the buffered
// body — so none of them needs a second read of the stream.
///|
/// The reply a refused route sends.
fn refusal_reply(
status : Int32,
headers : Array[(String, String)],
text : String,
) -> @route.Reply {
@route.Reply::Buffered(
@http.Response::new(status, headers, @utf8.encode(text)),
)
}
///|
/// `401 Unauthorized` with the `WWW-Authenticate` challenge RFC 7235 requires for
/// a bearer-protected resource.
fn unauthorized_reply() -> @route.Reply {
refusal_reply(
401,
[
("content-type", "text/plain; charset=utf-8"),
("www-authenticate", "Bearer"),
],
"401 Unauthorized",
)
}
///|
/// `403 Forbidden`.
fn forbidden_reply() -> @route.Reply {
refusal_reply(
403,
[("content-type", "text/plain; charset=utf-8")],
"403 Forbidden",
)
}
///|
/// `413 Payload Too Large`.
fn too_large_reply() -> @route.Reply {
refusal_reply(
413,
[("content-type", "text/plain; charset=utf-8")],
"413 Payload Too Large",
)
}
///|
/// `503 Service Unavailable`, the answer a deadline or an open breaker gives.
fn unavailable_reply(body : String) -> @route.Reply {
refusal_reply(503, [("content-type", "text/plain; charset=utf-8")], body)
}
///|
/// Whether `token` verifies against the route's current secret, or against the
/// one a rotation is retiring, at `now_secs`.
pub fn jwt_accepts(
setting : JwtRoute,
token : String?,
now_secs : Int64,
) -> Bool {
guard setting.enabled else { true }
match token {
Some(token) =>
if @handler.jwt_authorized(Some(token), setting.secret, now_secs) {
true
} else {
match setting.prev_secret {
Some(previous) =>
@handler.jwt_authorized(Some(token), previous, now_secs)
None => false
}
}
None => false
}
}
///|
/// The JWT hoop: refuse the route unless the bearer token verifies.
pub fn jwt_hoop(setting : JwtRoute, clock : @clock.Clock) -> @route.Hoop {
(ctx, inner) => {
if jwt_accepts(setting, ctx.bearer_token(), clock.now() / 1000L) {
inner(ctx)
} else {
unauthorized_reply()
}
}
}
///|
/// The timeout hoop: run the rest of the chain under a budget, and answer `503`
/// when it runs out. The deadline is enforced here rather than on the response
/// path, so a handler that never returns is abandoned instead of hanging the
/// request forever.
pub fn timeout_hoop(budget_ms : Int64) -> @route.Hoop {
(ctx, inner) => {
let budget = budget_ms.to_int()
match @async.with_timeout_opt(budget, () => inner(ctx)) {
Some(reply) => reply
None => unavailable_reply("service timeout")
}
}
}
///|
/// The max-bytes hoop: refuse a body whose declared length exceeds `limit`.
pub fn max_bytes_hoop(limit : Int32) -> @route.Hoop {
(ctx, inner) => {
match ctx.request.header("content-length") {
Some(value) =>
match @handler.parse_i64(value) {
Some(declared) =>
if declared > limit.to_int64() {
too_large_reply()
} else {
inner(ctx)
}
None => inner(ctx)
}
None => inner(ctx)
}
}
}
///|
/// The signature hoop: verify the `X-Content-Security` MAC and timestamp on this
/// route. A payload the envelope declares encrypted is *not* decrypted here — a
/// hoop cannot rewrite the request — so a route that takes encrypted bodies wants
/// the service-level `content_security` layer, which can.
pub fn signature_hoop(
verifier : @handler.SignatureVerifier,
clock : @clock.Clock,
) -> @route.Hoop {
(ctx, inner) => {
let input : @handler.SignatureInput = {
verb: ctx.request.http_method,
path: ctx.request.path,
query: @utf8.decode(ctx.request.query_string[:]),
header: ctx.request.header("x-content-security"),
body: ctx.request.body,
signed_uri: ctx.request.header("x-request-uri"),
}
let accepted = try {
let _ = @handler.verify_signature(verifier, input, clock.now())
true
} catch {
_ => false
}
if accepted || !verifier.strict() {
inner(ctx)
} else {
forbidden_reply()
}
}
}
///|
/// The SSE hoop: stamp the event-stream media type, no caching and a kept-alive
/// connection onto a buffered response. A streamed reply declares its own headers
/// and is left alone.
pub fn sse_hoop() -> @route.Hoop {
(ctx, inner) => {
match inner(ctx) {
@route.Reply::Buffered(resp) =>
@route.Reply::Buffered(
@http.Response::new(
resp.status,
[
("content-type", "text/event-stream"),
("cache-control", "no-cache"),
("connection", "keep-alive"),
..resp.headers,
],
resp.body,
),
)
other => other
}
}
}
///|
/// The priority hoop: draw this route from `shedder`. A refused call answers
/// `503`, the status go-zero's shedding interceptor uses.
pub fn priority_hoop(shedder : @shedder.Shedder) -> @route.Hoop {
(ctx, inner) => {
match shedder.allow() {
Some(ticket) => {
let reply = inner(ctx)
ticket.pass()
reply
}
None => unavailable_reply("503 Service Unavailable")
}
}
}
///|
/// The hoops `options` asks for, outermost first: signature, JWT, body cap,
/// deadline, priority shedding, then the SSE stamp. Checks that refuse come
/// before the ones that only measure, so a refused request costs nothing.
pub fn hoops_of(options : RouteOptions) -> Array[@route.Hoop] {
let out : Array[@route.Hoop] = []
match options.signature {
Some(verifier) => out.push(signature_hoop(verifier, options.clock))
None => ()
}
if options.jwt.enabled {
out.push(jwt_hoop(options.jwt, options.clock))
}
if options.max_bytes > 0 {
out.push(max_bytes_hoop(options.max_bytes))
}
if options.timeout_ms > 0L {
out.push(timeout_hoop(options.timeout_ms))
}
match options.priority_shedder {
Some(shedder) => out.push(priority_hoop(shedder))
None => ()
}
if options.sse {
out.push(sse_hoop())
}
out
}