///|
priv suberror SignatureRejected {
SignatureRejected
}
///|
priv struct ResponseChunks {
chunks : Array[Bytes]
mut cursor : Int32
}
///|
impl @hmodel.BodyStream for ResponseChunks with fn read_some(self) {
while self.cursor < self.chunks.length() {
let chunk = self.chunks[self.cursor]
self.cursor += 1
if !chunk.is_empty() {
return Some(chunk)
}
}
None
}
///|
impl @hmodel.BodyStream for ResponseChunks with fn close(self) {
self.cursor = self.chunks.length()
}
///|
fn response_body(body : @app.InteractionHttpBody) -> @hmodel.Body {
match body {
Empty => Empty
Bytes(bytes) => Bytes(bytes)
Chunks(chunks) => {
let length = chunks.fold(init=0L, (sum, chunk) => {
sum + chunk.length().to_int64()
})
Stream(
@hmodel.StreamBody::new(
ResponseChunks::{ chunks: chunks.copy(), cursor: 0, },
length~,
),
)
}
}
}
///|
fn text_response(status : Int32, text : String) -> @hmodel.Response {
@hmodel.Response::new(
status,
headers=@hmodel.Headers::of([("content-type", "text/plain; charset=utf-8")]),
body=Bytes(@utf8.encode(text)),
)
}
///|
/// Build the signed interaction endpoint for a native server or a Fetch host.
/// The caller keeps the task group owning `endpoint` alive for deferred work.
/// Signature verification consumes the exact bounded bytes, before dispatch.
fn interaction_handler(
endpoint : @app.InteractionEndpoint,
verifier : @verify.InteractionVerifier,
path~ : String = "/",
deadline_ms~ : Int32 = 2500,
max_body_bytes~ : Int32 = 1048576,
warn~ : (@app.AppWarning) -> Unit = _ => (),
) -> @hmodel.Handler raise @endpoint.DefinitionError {
let security = @endpoint.Input::map3(
@endpoint.signature_header("x-signature-ed25519"),
@endpoint.signature_header("x-signature-timestamp"),
@endpoint.raw_body(),
(signature, timestamp, body) => (signature, timestamp, body),
value => value,
)
let route = @endpoint.endpoint
.post(path)
.security(security)
.max_body_bytes(max_body_bytes)
.output(@endpoint.stream_body_out().and_status())
.serve(
credentials => {
let (signature, timestamp, body) = credentials
if !verifier.verify(signature~, timestamp~, body~) {
raise SignatureRejected
}
Ok(credentials)
},
(credentials, _) => {
let (signature, timestamp, body) = credentials
let response = endpoint.handle_signed_http(
{
http_method: "POST",
signature: Some(signature),
timestamp: Some(timestamp),
body,
},
verifier,
deadline_ms~,
)
Ok(
(
(response.content_type, response_body(response.body)),
response.status,
),
)
},
)
let router = @router.Router::new([route], options={
..@router.ServerOptions::default(),
max_body_bytes,
reject: (_, _) => text_response(404, "not found"),
decode_failure: context => {
if context.failures.any(f => {
f.atom.name == "x-signature-ed25519" ||
f.atom.name == "x-signature-timestamp"
}) {
Some(text_response(401, "missing signature"))
} else if context.failures.any(f => {
f.failure is @schema.DecodeFailure(kind=TooLarge(..), ..)
}) {
Some(text_response(413, "request body too large"))
} else if context.failures.any(f => f.atom.location is RawBody) {
warn(
@app.Executor(
source="http",
message="interaction HTTP request failed: invalid request body",
),
)
Some(text_response(400, "invalid request body"))
} else {
Some(text_response(401, "missing signature"))
}
},
on_unhandled: (_, error) => {
match error {
SignatureRejected => Some(text_response(401, "invalid signature"))
_ => {
warn(
@app.Executor(
source="http",
message="interaction HTTP request failed: \{Repr(error)}",
),
)
Some(text_response(500, "internal server error"))
}
}
},
})
request => {
if request.meth != Post {
text_response(405, "method not allowed")
} else {
router.dispatch(request)
}
}
}