use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, atomic::AtomicBool};
use std::time::Duration;
use serde::Deserialize;
use serde_json::value::RawValue;
use serde_json::{Value, json};
use super::forward::{Carried, Followed, Following, carry, follow};
use super::http::{Request, Response};
use super::packed::Packed;
#[path = "route_files.rs"]
mod files;
use files::file;
#[path = "route_children.rs"]
mod children;
pub(super) use children::child_given_up;
#[path = "route_responses.rs"]
mod responses;
use super::signin::{Door, Issuer};
use crate::client::{Ended, Reply};
use crate::serve::storage::Failure;
pub(super) use responses::refusal;
use responses::{Receipted, refused_by_store, with_receipt};
const SESSION: &str = "haem_session";
const STATE: &str = "haem_state";
const RETURN: &str = "haem_return";
const SIGN_IN_FOR: u64 = 600;
pub struct Site {
pub origin: String,
pub files: PathBuf,
pub stores: BTreeMap<String, PathBuf>,
pub store_children: BTreeMap<String, (String, Arc<AtomicBool>)>,
pub timeout: Duration,
pub packed: Packed,
}
const RETRY_MS: u32 = 1_000;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Verb<'a> {
method: &'a str,
#[serde(borrow)]
params: Option<&'a RawValue>,
}
#[must_use]
pub fn answer<I: Issuer>(door: &Door<I>, site: &Site, request: &Request, now: u64) -> Response {
let path = request.path.as_str();
match (request.method.as_str(), path) {
("GET", "/signin") => sign_in(door, request, now),
("GET", "/callback") => callback(door, request, now),
("POST", "/signout") => sign_out(door, site, request, now),
("GET", "/api/session") => with_session(request, |id| door.session_at(id, now))
.map_or_else(refusal, |line| Response::json(200, &line)),
("GET", "/api/haem") => {
with_session(request, |id| door.pass(id, now)).map_or_else(refusal, |_| {
let names: Vec<Value> = site
.stores
.keys()
.map(|name| children::store_line(site, name))
.collect();
Response::json(200, &json!({"stores": names}))
})
}
("POST", _) if path.starts_with("/api/haem/") => verb(door, site, request, now),
("GET", _) if path.starts_with("/api/haem/") && path.ends_with("/follow") => {
follow_head(door, site, request, now)
}
(_, _) if path.starts_with("/api/assistant/") => {
super::assistant::answer(door, site, request, now)
}
(_, _) if path.starts_with("/api/") => refusal(Failure::new(
"route_unknown",
"the door serves no such address",
)),
("GET", _) => file(site, request),
("POST", _) => refusal(Failure::new(
"route_unknown",
"the door serves no such address",
)),
_ => refusal(Failure::new(
"method_refused",
"the door takes GET and POST only",
)),
}
}
fn cookie(name: &str, value: &str, same_site: &str, max_age: u64) -> String {
format!("{name}={value}; HttpOnly; Secure; SameSite={same_site}; Path=/; Max-Age={max_age}")
}
fn sign_in<I: Issuer>(door: &Door<I>, request: &Request, now: u64) -> Response {
let back = request
.parameter("return")
.filter(|back| back.starts_with('#'))
.unwrap_or_else(|| "#/".to_owned());
match door.begin(now) {
Ok(begun) => Response::redirect(&begun.url)
.with(
"Set-Cookie",
&cookie(STATE, &begun.browser, "Lax", SIGN_IN_FOR),
)
.with(
"Set-Cookie",
&cookie(
RETURN,
&url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>(),
"Lax",
SIGN_IN_FOR,
),
),
Err(failure) => refused_sign_in("/signin", failure),
}
}
static CALLBACK_REFUSALS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn refused_callback(failure: Failure) -> Response {
refused_sign_in("/callback", failure)
}
fn refused_sign_in(path: &str, failure: Failure) -> Response {
CALLBACK_REFUSALS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
eprintln!(
"haematite door: sign-in refused at {path}: {}: {}",
failure.code, failure.message
);
refusal(failure)
}
#[cfg(test)]
pub(super) fn callback_refusals() -> u64 {
CALLBACK_REFUSALS.load(std::sync::atomic::Ordering::Relaxed)
}
fn callback<I: Issuer>(door: &Door<I>, request: &Request, now: u64) -> Response {
let (Some(code), Some(state)) = (request.parameter("code"), request.parameter("state")) else {
return refused_callback(Failure::new(
"sign_in_refused",
"Lys returned no code; sign in again",
));
};
let browser = request.cookie(STATE).unwrap_or_default();
match door.callback(&code, &state, browser, now) {
Ok(id) => {
let back = request
.cookie(RETURN)
.map(|back| {
url::form_urlencoded::parse(format!("r={back}").as_bytes())
.map(|(_, value)| value.into_owned())
.next()
.unwrap_or_default()
})
.filter(|back| back.starts_with('#'))
.unwrap_or_else(|| "#/".to_owned());
Response::redirect(&format!("/{back}"))
.with(
"Set-Cookie",
&cookie(SESSION, &id, "Strict", door.session_seconds()),
)
.with("Set-Cookie", &cookie(STATE, "", "Lax", 0))
.with("Set-Cookie", &cookie(RETURN, "", "Lax", 0))
}
Err(failure) => refused_callback(failure),
}
}
fn sign_out<I: Issuer>(door: &Door<I>, site: &Site, request: &Request, now: u64) -> Response {
if request.header("origin") != Some(site.origin.as_str()) {
return refusal(Failure::new(
"origin_refused",
"a sign-out is taken only from Studio's own origin",
));
}
if let Some(id) = request.cookie(SESSION)
&& let Err(failure) = door.sign_out_at(id, now)
{
return refusal(failure);
}
Response::no_content().with("Set-Cookie", &cookie(SESSION, "", "Strict", 0))
}
fn with_session<T>(
request: &Request,
then: impl FnOnce(&str) -> Result<T, Failure>,
) -> Result<T, Failure> {
let id = request
.cookie(SESSION)
.ok_or_else(|| Failure::new("session_unknown", "sign in to Studio first"))?;
then(id)
}
fn verb<I: Issuer>(door: &Door<I>, site: &Site, request: &Request, now: u64) -> Response {
if request.header("origin") != Some(site.origin.as_str()) {
return refusal(Failure::new(
"origin_refused",
"a verb is taken only from Studio's own origin",
));
}
let token = match with_session(request, |id| door.pass(id, now)) {
Ok(token) => token,
Err(failure) => return refusal(failure),
};
let name = request.path.trim_start_matches("/api/haem/");
let Some(socket) = site.stores.get(name) else {
return refusal(Failure::new(
"store_unknown",
"this door serves no store by that name",
));
};
if let Some(response) = child_given_up(site, name) {
return response;
}
let asked: Verb<'_> = match serde_json::from_slice(&request.body) {
Ok(asked) => asked,
Err(error) => return refusal(Failure::new("invalid_params", error.to_string())),
};
match carry(socket, site.timeout, &token, asked.method, asked.params) {
Ok(Carried::Answered(answer)) => {
Response::new(200, "application/json", answer.get().as_bytes().to_vec())
}
Ok(Carried::Refused(refused)) => refused_by_store(&refused),
Err(Ended::Unknown(why)) => {
Response::json(502, &json!({"code": "outcome_unknown", "message": why}))
}
Err(Ended::Received { why, receipt }) => with_receipt(
502,
&Receipted {
code: "outcome_unknown",
message: &why,
path: None,
receipt: receipt.raw(),
},
),
Err(Ended::NotAsked(why) | Ended::Usage(why)) => {
Response::json(502, &json!({"code": "store_not_asked", "message": why}))
}
}
}
fn follow_head<I: Issuer>(door: &Door<I>, site: &Site, request: &Request, now: u64) -> Response {
if request
.header("origin")
.is_some_and(|origin| origin != site.origin)
{
return refusal(Failure::new(
"origin_refused",
"a follow is taken only from Studio's own origin",
));
}
let token = match with_session(request, |id| door.pass(id, now)) {
Ok(token) => token,
Err(failure) => return refusal(failure),
};
let name = request
.path
.strip_prefix("/api/haem/")
.and_then(|rest| rest.strip_suffix("/follow"))
.unwrap_or_default();
let Some(socket) = site.stores.get(name) else {
return refusal(Failure::new(
"store_unknown",
"this door serves no store by that name",
));
};
if let Some(response) = child_given_up(site, name) {
return response;
}
let branch = request.parameter("branch").unwrap_or_default();
if branch.is_empty() {
return refusal(Failure::new(
"invalid_params",
"name the branch to follow: ?branch=<name>",
));
}
match follow(socket, site.timeout, &token, &branch) {
Ok(Followed::Following(following)) => {
Response::stream_with_peer("text/event-stream", move |output, peer| {
relay_watched(following, output, peer)
})
}
Ok(Followed::Refused(refused)) => refused_by_store(&refused),
Err(ended) => {
let refused = json!({"code": "store_not_asked", "message": ended.why()});
Response::json(502, &refused)
}
}
}
fn relay_watched(
following: Following,
output: &mut dyn Write,
peer: Option<&std::net::TcpStream>,
) -> io::Result<()> {
let _monitor = peer
.map(|peer| {
let store = following.disconnect_handle()?;
crate::serve::follow::Monitor::start(peer.try_clone()?, move || {
if let Err(error) = store.shutdown(std::net::Shutdown::Both) {
eprintln!("haematite follow_disconnect_failed: {error}");
}
})
})
.transpose()?;
relay(following, output)
}
fn relay(mut following: Following, output: &mut dyn Write) -> io::Result<()> {
let head = event("head", following.first.get());
if !sent(output, &format!("retry: {RETRY_MS}\n{head}"))? {
return Ok(());
}
loop {
match following.next_frame() {
Ok(Reply::Answered(head)) => {
if !sent(output, &event("head", head.get()))? {
return Ok(());
}
}
Ok(Reply::Refused(refused)) => {
return refused_event(output, &refused.code, &refused.message);
}
Err(ended) => return refused_event(output, "follow_lost", ended.why()),
}
}
}
fn refused_event(output: &mut dyn Write, code: &str, message: &str) -> io::Result<()> {
let data = json!({"code": code, "message": message}).to_string();
sent(output, &event("refused", &data))?;
Ok(())
}
pub(super) fn event(name: &str, data: &str) -> String {
let mut text = format!("event: {name}\n");
for line in data.lines() {
text.push_str("data: ");
text.push_str(line);
text.push('\n');
}
text.push('\n');
text
}
pub(super) fn sent(output: &mut dyn Write, text: &str) -> io::Result<bool> {
match output.write_all(text.as_bytes()) {
Ok(()) => {}
Err(error) if gone(&error) => return Ok(false),
Err(error) => return Err(error),
}
match output.flush() {
Ok(()) => Ok(true),
Err(error) if gone(&error) => Ok(false),
Err(error) => Err(error),
}
}
fn gone(error: &io::Error) -> bool {
use io::ErrorKind::{BrokenPipe, ConnectionAborted, ConnectionReset};
matches!(
error.kind(),
BrokenPipe | ConnectionReset | ConnectionAborted
)
}
#[cfg(test)]
#[path = "route_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "route_refusal_tests.rs"]
mod refusal_tests;
#[cfg(test)]
#[path = "route_session_tests.rs"]
mod session_tests;