use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
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::{IMMUTABLE, Packed, hashed, takes_gzip};
use super::signin::{Door, Issuer};
use crate::client::{Ended, Refusal, Reply};
use crate::serve::storage::Failure;
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 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| json!({"name": 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),
_ => 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",
));
};
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",
));
};
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
)
}
#[derive(Serialize)]
struct Receipted<'a> {
code: &'a str,
message: &'a str,
path: Option<&'a str>,
receipt: &'a RawValue,
}
fn refused_by_store(refused: &Refusal) -> Response {
let status = store_status(&refused.code);
let response = refused.verified.as_ref().map_or_else(
|| {
Response::json(
status,
&json!({"code": refused.code, "message": refused.message, "path": refused.path}),
)
},
|receipt| {
with_receipt(
status,
&Receipted {
code: &refused.code,
message: &refused.message,
path: refused.path.as_deref(),
receipt: receipt.raw(),
},
)
},
);
retry_after(response, status)
}
fn store_status(code: &str) -> u16 {
match code {
"busy"
| "busy_max_clients"
| "storage_unavailable"
| "writer_unavailable"
| "lys_could_not_be_asked"
| "lys_keys_pending"
| "busy_pending_signins" => 503,
"permission_refused" | "held_for_approval" | "origin_refused" => 403,
code if code.ends_with("_access_refused") => 403,
"branch_missing"
| "branch_unknown"
| "collection_missing"
| "document_missing"
| "store_unknown"
| "route_unknown"
| "version_missing"
| "file_unknown"
| "relation_missing"
| "counts_branch_missing" => 404,
_ => 400,
}
}
fn retry_after(response: Response, status: u16) -> Response {
if status == 503 {
response.with("Retry-After", "1")
} else {
response
}
}
fn with_receipt(status: u16, body: &Receipted<'_>) -> Response {
serde_json::to_vec(body).map_or_else(
|error| {
let message = format!(
"the store answered {}: {}; its receipt could not be written: {error}",
body.code, body.message
);
Response::json(
502,
&json!({"code": "receipt_output_failed", "message": message}),
)
},
|bytes| Response::new(status, "application/json", bytes),
)
}
pub(super) fn refusal(failure: Failure) -> Response {
let Failure { code, message, .. } = failure;
let status = match code.as_str() {
"session_unknown" | "session_expired" | "sign_in_state_unknown" => 401,
"origin_refused" => 403,
"route_unknown" | "store_unknown" => 404,
"method_refused" => 405,
_ => store_status(&code),
};
retry_after(
Response::json(status, &json!({"code": code, "message": message})),
status,
)
}
fn file(site: &Site, request: &Request) -> Response {
let (files, path) = (&site.files, request.path.as_str());
let relative = Path::new(path.trim_start_matches('/'));
let plain = relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
&& !path.contains(['%', '\\']);
let missing = || {
Response::json(
404,
&json!({"code": "file_unknown", "message": "Studio has no such file"}),
)
};
if !plain {
return missing();
}
let wanted = files.join(relative);
let (target, kind) = wanted
.extension()
.and_then(|extension| extension.to_str())
.map_or_else(
|| (files.join("index.html"), "html".to_owned()),
|extension| (wanted.clone(), extension.to_owned()),
);
let content_type = match kind.as_str() {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"woff2" => "font/woff2",
"wasm" => "application/wasm",
_ => "application/octet-stream",
};
let compressible = matches!(
kind.as_str(),
"html" | "js" | "mjs" | "css" | "json" | "map" | "svg" | "wasm"
);
let gzip = compressible && takes_gzip(request.header("accept-encoding"));
let immutable = target == wanted && hashed(path);
let packed = &site.packed;
let body = if gzip {
packed
.gzipped(&target, immutable)
.map(|bytes| bytes.to_vec())
} else {
std::fs::read(&target)
};
let Ok(body) = body else {
return missing();
};
let mut response = Response::new(200, content_type, body);
if compressible {
response = response.with("Vary", "Accept-Encoding");
}
if gzip {
response = response.with("Content-Encoding", "gzip");
}
if immutable {
response = response.cached(IMMUTABLE);
}
response
}
#[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;