atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
2ea5ef81f040ab9608f3421508d03314efb7f6d8
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git

Blame: src/http.rs

a9b016e4b248atagen2026-10-10 08:12 UTC1
use std::{
a9b016e4b248atagen2026-10-10 08:12 UTC2
    collections::HashMap,
a9b016e4b248atagen2026-10-10 08:12 UTC3
    convert::Infallible,
a9b016e4b248atagen2026-10-10 08:12 UTC4
    fmt::Write as _,
dfa38cf4cd4batagen2026-10-10 09:17 UTC5
    io::Write as _,
a9b016e4b248atagen2026-10-10 08:12 UTC6
    sync::{Arc, Mutex},
a9b016e4b248atagen2026-10-10 08:12 UTC7
    time::Instant,
a9b016e4b248atagen2026-10-10 08:12 UTC8
};
a9b016e4b248atagen2026-10-10 08:12 UTC9
a9b016e4b248atagen2026-10-10 08:12 UTC10
use axum::{
a9b016e4b248atagen2026-10-10 08:12 UTC11
    Json, Router,
a9b016e4b248atagen2026-10-10 08:12 UTC12
    body::{Body, Bytes},
a9b016e4b248atagen2026-10-10 08:12 UTC13
    extract::{Path, Query, Request, State},
a9b016e4b248atagen2026-10-10 08:12 UTC14
    http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header},
a9b016e4b248atagen2026-10-10 08:12 UTC15
    middleware::{self, Next},
99524cf9f17catagen2026-10-10 10:53 UTC16
    response::{Html, IntoResponse, Redirect, Response},
a9b016e4b248atagen2026-10-10 08:12 UTC17
    routing::any,
a9b016e4b248atagen2026-10-10 08:12 UTC18
};
dfa38cf4cd4batagen2026-10-10 09:17 UTC19
use flate2::{Compression, write::GzEncoder};
dfa38cf4cd4batagen2026-10-10 09:17 UTC20
use hyper_util::rt::TokioIo;
a9b016e4b248atagen2026-10-10 08:12 UTC21
use rand::Rng;
99524cf9f17catagen2026-10-10 10:53 UTC22
use serde::Deserialize;
a9b016e4b248atagen2026-10-10 08:12 UTC23
use serde_json::{Value, json};
dfa38cf4cd4batagen2026-10-10 09:17 UTC24
use tokio::io::AsyncWriteExt;
99524cf9f17catagen2026-10-10 10:53 UTC25
use tower::ServiceExt;
a9b016e4b248atagen2026-10-10 08:12 UTC26
a9b016e4b248atagen2026-10-10 08:12 UTC27
use crate::{
a9b016e4b248atagen2026-10-10 08:12 UTC28
    game::{
dfa38cf4cd4batagen2026-10-10 09:17 UTC29
        ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,
dfa38cf4cd4batagen2026-10-10 09:17 UTC30
        PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,
a9b016e4b248atagen2026-10-10 08:12 UTC31
    },
99524cf9f17catagen2026-10-10 10:53 UTC32
    problem::{PROBLEM_BASE, Problem, reason},
99524cf9f17catagen2026-10-10 10:53 UTC33
    secret,
a9b016e4b248atagen2026-10-10 08:12 UTC34
};
a9b016e4b248atagen2026-10-10 08:12 UTC35
a9b016e4b248atagen2026-10-10 08:12 UTC36
const REL_BASE: &str = "https://jelq.dev/rels/";
a9b016e4b248atagen2026-10-10 08:12 UTC37
const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict";
a9b016e4b248atagen2026-10-10 08:12 UTC38
const THERAPY: &str = "Your options are listed in the Allow header. There are more of them than you think. You could also try being honest. It is not in the Allow header because it is not a method.";
dfa38cf4cd4batagen2026-10-10 09:17 UTC39
const MAX_URI: usize = 512;
dfa38cf4cd4batagen2026-10-10 09:17 UTC40
const MAX_HEADER: usize = 1024;
a9b016e4b248atagen2026-10-10 08:12 UTC41
a9b016e4b248atagen2026-10-10 08:12 UTC42
#[derive(Default)]
a9b016e4b248atagen2026-10-10 08:12 UTC43
pub struct AppState {
a9b016e4b248atagen2026-10-10 08:12 UTC44
    runs: Mutex<HashMap<String, Run>>,
a9b016e4b248atagen2026-10-10 08:12 UTC45
}
a9b016e4b248atagen2026-10-10 08:12 UTC46
a9b016e4b248atagen2026-10-10 08:12 UTC47
impl AppState {
a9b016e4b248atagen2026-10-10 08:12 UTC48
    pub fn insert(&self, run: Run) {
a9b016e4b248atagen2026-10-10 08:12 UTC49
        self.runs.lock().unwrap().insert(run.id.clone(), run);
a9b016e4b248atagen2026-10-10 08:12 UTC50
    }
a9b016e4b248atagen2026-10-10 08:12 UTC51
}
a9b016e4b248atagen2026-10-10 08:12 UTC52
a9b016e4b248atagen2026-10-10 08:12 UTC53
type Shared = Arc<AppState>;
a9b016e4b248atagen2026-10-10 08:12 UTC54
dfa38cf4cd4batagen2026-10-10 09:17 UTC55
#[derive(Clone)]
dfa38cf4cd4batagen2026-10-10 09:17 UTC56
struct Compress;
dfa38cf4cd4batagen2026-10-10 09:17 UTC57
dfa38cf4cd4batagen2026-10-10 09:17 UTC58
/// Which household hazards stand between the request and the run.
dfa38cf4cd4batagen2026-10-10 09:17 UTC59
#[derive(Clone, Copy, PartialEq, Eq)]
dfa38cf4cd4batagen2026-10-10 09:17 UTC60
enum Scope {
dfa38cf4cd4batagen2026-10-10 09:17 UTC61
    Home,
dfa38cf4cd4batagen2026-10-10 09:17 UTC62
    Pockets,
dfa38cf4cd4batagen2026-10-10 09:17 UTC63
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC64
a9b016e4b248atagen2026-10-10 08:12 UTC65
pub fn app() -> Router {
a9b016e4b248atagen2026-10-10 08:12 UTC66
    router(Arc::default())
a9b016e4b248atagen2026-10-10 08:12 UTC67
}
a9b016e4b248atagen2026-10-10 08:12 UTC68
a9b016e4b248atagen2026-10-10 08:12 UTC69
pub fn router(state: Shared) -> Router {
99524cf9f17catagen2026-10-10 10:53 UTC70
    let api = api(state);
99524cf9f17catagen2026-10-10 10:53 UTC71
    let ui = Router::new()
99524cf9f17catagen2026-10-10 10:53 UTC72
        .route("/ui", any(|| async { Redirect::permanent("/ui/") }))
99524cf9f17catagen2026-10-10 10:53 UTC73
        .route("/ui/", any(ui))
99524cf9f17catagen2026-10-10 10:53 UTC74
        .route("/ui/send", any(ui_send))
99524cf9f17catagen2026-10-10 10:53 UTC75
        .with_state(api.clone());
99524cf9f17catagen2026-10-10 10:53 UTC76
    api.merge(ui)
99524cf9f17catagen2026-10-10 10:53 UTC77
}
99524cf9f17catagen2026-10-10 10:53 UTC78
99524cf9f17catagen2026-10-10 10:53 UTC79
fn api(state: Shared) -> Router {
a9b016e4b248atagen2026-10-10 08:12 UTC80
    Router::new()
99524cf9f17catagen2026-10-10 10:53 UTC81
        .route("/", any(index))
a9b016e4b248atagen2026-10-10 08:12 UTC82
        .route("/runs", any(runs))
a9b016e4b248atagen2026-10-10 08:12 UTC83
        .route("/runs/{id}", any(run))
a9b016e4b248atagen2026-10-10 08:12 UTC84
        .route("/runs/{id}/length", any(length))
a9b016e4b248atagen2026-10-10 08:12 UTC85
        .route("/runs/{id}/foreskin", any(foreskin))
a9b016e4b248atagen2026-10-10 08:12 UTC86
        .route("/runs/{id}/inventory", any(inventory))
a9b016e4b248atagen2026-10-10 08:12 UTC87
        .route("/runs/{id}/inventory/{item}", any(item))
a9b016e4b248atagen2026-10-10 08:12 UTC88
        .route("/runs/{id}/techniques", any(techniques))
a9b016e4b248atagen2026-10-10 08:12 UTC89
        .route("/runs/{id}/drawer", any(drawer))
a9b016e4b248atagen2026-10-10 08:12 UTC90
        .route("/runs/{id}/branches/{choice}", any(branch))
dfa38cf4cd4batagen2026-10-10 09:17 UTC91
        .route("/runs/{id}/floor", any(floor))
dfa38cf4cd4batagen2026-10-10 09:17 UTC92
        .route("/runs/{id}/shop", any(shop))
dfa38cf4cd4batagen2026-10-10 09:17 UTC93
        .route("/runs/{id}/shop/{item}", any(buy))
dfa38cf4cd4batagen2026-10-10 09:17 UTC94
        .route("/runs/{id}/portal", any(portal))
dfa38cf4cd4batagen2026-10-10 09:17 UTC95
        .route("/runs/{id}/elder", any(elder))
dfa38cf4cd4batagen2026-10-10 09:17 UTC96
        .route("/runs/{id}/grip", any(grip))
a9b016e4b248atagen2026-10-10 08:12 UTC97
        .route("/runs/{id}/diagnosis", any(diagnosis))
dfa38cf4cd4batagen2026-10-10 09:17 UTC98
        .route("/forum/threads/{year}", any(thread))
a9b016e4b248atagen2026-10-10 08:12 UTC99
        .route("/graveyard", any(graveyard))
a9b016e4b248atagen2026-10-10 08:12 UTC100
        .route("/leaderboard", any(leaderboard))
a9b016e4b248atagen2026-10-10 08:12 UTC101
        .fallback(not_found)
a9b016e4b248atagen2026-10-10 08:12 UTC102
        .layer(middleware::from_fn(chrome))
a9b016e4b248atagen2026-10-10 08:12 UTC103
        .with_state(state)
a9b016e4b248atagen2026-10-10 08:12 UTC104
}
a9b016e4b248atagen2026-10-10 08:12 UTC105
dfa38cf4cd4batagen2026-10-10 09:17 UTC106
fn reject(request: &Request) -> Option<Problem> {
dfa38cf4cd4batagen2026-10-10 09:17 UTC107
    if request.version() < Version::HTTP_11 {
dfa38cf4cd4batagen2026-10-10 09:17 UTC108
        return Some(Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC109
            StatusCode::HTTP_VERSION_NOT_SUPPORTED,
dfa38cf4cd4batagen2026-10-10 09:17 UTC110
            "pdf",
dfa38cf4cd4batagen2026-10-10 09:17 UTC111
            "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC112
        ));
dfa38cf4cd4batagen2026-10-10 09:17 UTC113
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC114
    if request.uri().to_string().len() > MAX_URI {
dfa38cf4cd4batagen2026-10-10 09:17 UTC115
        return Some(Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC116
            StatusCode::URI_TOO_LONG,
dfa38cf4cd4batagen2026-10-10 09:17 UTC117
            "uri-too-long",
dfa38cf4cd4batagen2026-10-10 09:17 UTC118
            "The URI is longer than anything else here.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC119
        ));
dfa38cf4cd4batagen2026-10-10 09:17 UTC120
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC121
    if request.headers().values().any(|v| v.len() > MAX_HEADER) {
dfa38cf4cd4batagen2026-10-10 09:17 UTC122
        return Some(Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC123
            StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC124
            "head-too-large",
dfa38cf4cd4batagen2026-10-10 09:17 UTC125
            "Head too large.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC126
        ));
dfa38cf4cd4batagen2026-10-10 09:17 UTC127
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC128
    let expectation = request.headers().get(header::EXPECT)?;
dfa38cf4cd4batagen2026-10-10 09:17 UTC129
    if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") {
dfa38cf4cd4batagen2026-10-10 09:17 UTC130
        return None;
dfa38cf4cd4batagen2026-10-10 09:17 UTC131
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC132
    Some(Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC133
        StatusCode::EXPECTATION_FAILED,
dfa38cf4cd4batagen2026-10-10 09:17 UTC134
        "expectations",
dfa38cf4cd4batagen2026-10-10 09:17 UTC135
        format!(
dfa38cf4cd4batagen2026-10-10 09:17 UTC136
            "Expect: {} cannot be met.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC137
            expectation.to_str().unwrap_or("that")
dfa38cf4cd4batagen2026-10-10 09:17 UTC138
        ),
dfa38cf4cd4batagen2026-10-10 09:17 UTC139
    ))
dfa38cf4cd4batagen2026-10-10 09:17 UTC140
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC141
dfa38cf4cd4batagen2026-10-10 09:17 UTC142
async fn compress(response: Response) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC143
    let (mut parts, body) = response.into_parts();
dfa38cf4cd4batagen2026-10-10 09:17 UTC144
    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC145
        return Response::from_parts(parts, Body::empty());
dfa38cf4cd4batagen2026-10-10 09:17 UTC146
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC147
    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
dfa38cf4cd4batagen2026-10-10 09:17 UTC148
    if encoder.write_all(&bytes).is_err() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC149
        return Response::from_parts(parts, Body::from(bytes));
dfa38cf4cd4batagen2026-10-10 09:17 UTC150
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC151
    let Ok(gzipped) = encoder.finish() else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC152
        return Response::from_parts(parts, Body::from(bytes));
dfa38cf4cd4batagen2026-10-10 09:17 UTC153
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC154
    parts
dfa38cf4cd4batagen2026-10-10 09:17 UTC155
        .headers
dfa38cf4cd4batagen2026-10-10 09:17 UTC156
        .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
dfa38cf4cd4batagen2026-10-10 09:17 UTC157
    parts.headers.remove(header::CONTENT_LENGTH);
dfa38cf4cd4batagen2026-10-10 09:17 UTC158
    parts
dfa38cf4cd4batagen2026-10-10 09:17 UTC159
        .headers
dfa38cf4cd4batagen2026-10-10 09:17 UTC160
        .append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
dfa38cf4cd4batagen2026-10-10 09:17 UTC161
    Response::from_parts(parts, Body::from(gzipped))
dfa38cf4cd4batagen2026-10-10 09:17 UTC162
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC163
a9b016e4b248atagen2026-10-10 08:12 UTC164
async fn chrome(request: Request, next: Next) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC165
    let gzip = header_str(request.headers(), header::ACCEPT_ENCODING)
dfa38cf4cd4batagen2026-10-10 09:17 UTC166
        .is_some_and(|e| e.to_ascii_lowercase().contains("gzip"));
dfa38cf4cd4batagen2026-10-10 09:17 UTC167
    let mut response = match reject(&request) {
dfa38cf4cd4batagen2026-10-10 09:17 UTC168
        Some(problem) => problem.into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC169
        None => next.run(request).await,
dfa38cf4cd4batagen2026-10-10 09:17 UTC170
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC171
    if gzip && response.extensions().get::<Compress>().is_some() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC172
        response = compress(response).await;
dfa38cf4cd4batagen2026-10-10 09:17 UTC173
    }
a9b016e4b248atagen2026-10-10 08:12 UTC174
    let headers = response.headers_mut();
a9b016e4b248atagen2026-10-10 08:12 UTC175
    headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9"));
a9b016e4b248atagen2026-10-10 08:12 UTC176
    headers.insert("x-powered-by", HeaderValue::from_static("blood"));
a9b016e4b248atagen2026-10-10 08:12 UTC177
    headers.insert(
a9b016e4b248atagen2026-10-10 08:12 UTC178
        header::CACHE_CONTROL,
a9b016e4b248atagen2026-10-10 08:12 UTC179
        HeaderValue::from_static("no-store, private"),
a9b016e4b248atagen2026-10-10 08:12 UTC180
    );
a9b016e4b248atagen2026-10-10 08:12 UTC181
    response
a9b016e4b248atagen2026-10-10 08:12 UTC182
}
a9b016e4b248atagen2026-10-10 08:12 UTC183
a9b016e4b248atagen2026-10-10 08:12 UTC184
fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
a9b016e4b248atagen2026-10-10 08:12 UTC185
    headers.get(name).and_then(|v| v.to_str().ok())
a9b016e4b248atagen2026-10-10 08:12 UTC186
}
a9b016e4b248atagen2026-10-10 08:12 UTC187
a9b016e4b248atagen2026-10-10 08:12 UTC188
fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
a9b016e4b248atagen2026-10-10 08:12 UTC189
    headers
a9b016e4b248atagen2026-10-10 08:12 UTC190
        .get_all(header::COOKIE)
a9b016e4b248atagen2026-10-10 08:12 UTC191
        .iter()
a9b016e4b248atagen2026-10-10 08:12 UTC192
        .filter_map(|v| v.to_str().ok())
a9b016e4b248atagen2026-10-10 08:12 UTC193
        .flat_map(|v| v.split(';'))
a9b016e4b248atagen2026-10-10 08:12 UTC194
        .filter_map(|pair| pair.trim().split_once('='))
a9b016e4b248atagen2026-10-10 08:12 UTC195
        .find(|(key, _)| *key == name)
a9b016e4b248atagen2026-10-10 08:12 UTC196
        .map(|(_, value)| value)
a9b016e4b248atagen2026-10-10 08:12 UTC197
}
a9b016e4b248atagen2026-10-10 08:12 UTC198
a9b016e4b248atagen2026-10-10 08:12 UTC199
fn unsupported(method: &Method, allow: &'static str) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC200
    if *method == Method::CONNECT {
a9b016e4b248atagen2026-10-10 08:12 UTC201
        return StatusCode::NOT_IMPLEMENTED.into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC202
    }
a9b016e4b248atagen2026-10-10 08:12 UTC203
    let known = matches!(
a9b016e4b248atagen2026-10-10 08:12 UTC204
        method.as_str(),
a9b016e4b248atagen2026-10-10 08:12 UTC205
        "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ"
a9b016e4b248atagen2026-10-10 08:12 UTC206
    );
177535ac8abfatagen2026-10-10 10:39 UTC207
    if method.as_str() == "BREW" {
177535ac8abfatagen2026-10-10 10:39 UTC208
        return Problem::new(
177535ac8abfatagen2026-10-10 10:39 UTC209
            StatusCode::IM_A_TEAPOT,
177535ac8abfatagen2026-10-10 10:39 UTC210
            "brew",
177535ac8abfatagen2026-10-10 10:39 UTC211
            "The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.",
177535ac8abfatagen2026-10-10 10:39 UTC212
        )
177535ac8abfatagen2026-10-10 10:39 UTC213
        .into_response();
177535ac8abfatagen2026-10-10 10:39 UTC214
    }
177535ac8abfatagen2026-10-10 10:39 UTC215
    if method.as_str().eq_ignore_ascii_case("jelq") {
177535ac8abfatagen2026-10-10 10:39 UTC216
        return Problem::new(
177535ac8abfatagen2026-10-10 10:39 UTC217
            StatusCode::NOT_IMPLEMENTED,
177535ac8abfatagen2026-10-10 10:39 UTC218
            "not-implemented",
177535ac8abfatagen2026-10-10 10:39 UTC219
            format!(
177535ac8abfatagen2026-10-10 10:39 UTC220
                "The server does not implement {method}. Methods are case-sensitive. JELQ is shouted for a reason."
177535ac8abfatagen2026-10-10 10:39 UTC221
            ),
177535ac8abfatagen2026-10-10 10:39 UTC222
        )
177535ac8abfatagen2026-10-10 10:39 UTC223
        .into_response();
177535ac8abfatagen2026-10-10 10:39 UTC224
    }
a9b016e4b248atagen2026-10-10 08:12 UTC225
    if !known {
a9b016e4b248atagen2026-10-10 08:12 UTC226
        return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC227
            StatusCode::NOT_IMPLEMENTED,
a9b016e4b248atagen2026-10-10 08:12 UTC228
            "not-implemented",
a9b016e4b248atagen2026-10-10 08:12 UTC229
            format!("The server does not implement {method}."),
a9b016e4b248atagen2026-10-10 08:12 UTC230
        )
a9b016e4b248atagen2026-10-10 08:12 UTC231
        .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC232
    }
a9b016e4b248atagen2026-10-10 08:12 UTC233
    Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC234
        StatusCode::METHOD_NOT_ALLOWED,
a9b016e4b248atagen2026-10-10 08:12 UTC235
        "method-not-allowed",
a9b016e4b248atagen2026-10-10 08:12 UTC236
        format!("{method} is not supported here."),
a9b016e4b248atagen2026-10-10 08:12 UTC237
    )
a9b016e4b248atagen2026-10-10 08:12 UTC238
    .header(header::ALLOW, allow)
a9b016e4b248atagen2026-10-10 08:12 UTC239
    .into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC240
}
a9b016e4b248atagen2026-10-10 08:12 UTC241
a9b016e4b248atagen2026-10-10 08:12 UTC242
fn options(allow: &'static str, patchable: bool) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC243
    let mut response = (
a9b016e4b248atagen2026-10-10 08:12 UTC244
        StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC245
        [(header::ALLOW, allow)],
a9b016e4b248atagen2026-10-10 08:12 UTC246
        Json(json!({ "detail": THERAPY })),
a9b016e4b248atagen2026-10-10 08:12 UTC247
    )
a9b016e4b248atagen2026-10-10 08:12 UTC248
        .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC249
    if patchable {
a9b016e4b248atagen2026-10-10 08:12 UTC250
        response.headers_mut().insert(
a9b016e4b248atagen2026-10-10 08:12 UTC251
            "accept-patch",
a9b016e4b248atagen2026-10-10 08:12 UTC252
            HeaderValue::from_static("application/json-patch+json"),
a9b016e4b248atagen2026-10-10 08:12 UTC253
        );
a9b016e4b248atagen2026-10-10 08:12 UTC254
    }
a9b016e4b248atagen2026-10-10 08:12 UTC255
    response
a9b016e4b248atagen2026-10-10 08:12 UTC256
}
a9b016e4b248atagen2026-10-10 08:12 UTC257
a9b016e4b248atagen2026-10-10 08:12 UTC258
fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC259
    let mut message = format!("{method} {uri} {version:?}\r\n");
a9b016e4b248atagen2026-10-10 08:12 UTC260
    for (name, value) in headers {
a9b016e4b248atagen2026-10-10 08:12 UTC261
        let _ = write!(
a9b016e4b248atagen2026-10-10 08:12 UTC262
            message,
a9b016e4b248atagen2026-10-10 08:12 UTC263
            "{name}: {}\r\n",
a9b016e4b248atagen2026-10-10 08:12 UTC264
            value.to_str().unwrap_or("<binary>")
a9b016e4b248atagen2026-10-10 08:12 UTC265
        );
a9b016e4b248atagen2026-10-10 08:12 UTC266
    }
a9b016e4b248atagen2026-10-10 08:12 UTC267
    message.push_str("\r\n");
a9b016e4b248atagen2026-10-10 08:12 UTC268
    (
a9b016e4b248atagen2026-10-10 08:12 UTC269
        StatusCode::OK,
a9b016e4b248atagen2026-10-10 08:12 UTC270
        [(header::CONTENT_TYPE, "message/http")],
a9b016e4b248atagen2026-10-10 08:12 UTC271
        message,
a9b016e4b248atagen2026-10-10 08:12 UTC272
    )
a9b016e4b248atagen2026-10-10 08:12 UTC273
        .into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC274
}
a9b016e4b248atagen2026-10-10 08:12 UTC275
a9b016e4b248atagen2026-10-10 08:12 UTC276
fn links(run: &Run) -> String {
a9b016e4b248atagen2026-10-10 08:12 UTC277
    let id = &run.id;
a9b016e4b248atagen2026-10-10 08:12 UTC278
    let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")];
a9b016e4b248atagen2026-10-10 08:12 UTC279
    for rel in [
dfa38cf4cd4batagen2026-10-10 09:17 UTC280
        "floor",
a9b016e4b248atagen2026-10-10 08:12 UTC281
        "length",
a9b016e4b248atagen2026-10-10 08:12 UTC282
        "foreskin",
a9b016e4b248atagen2026-10-10 08:12 UTC283
        "inventory",
a9b016e4b248atagen2026-10-10 08:12 UTC284
        "techniques",
a9b016e4b248atagen2026-10-10 08:12 UTC285
        "drawer",
dfa38cf4cd4batagen2026-10-10 09:17 UTC286
        "grip",
a9b016e4b248atagen2026-10-10 08:12 UTC287
        "diagnosis",
a9b016e4b248atagen2026-10-10 08:12 UTC288
    ] {
a9b016e4b248atagen2026-10-10 08:12 UTC289
        links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\""));
a9b016e4b248atagen2026-10-10 08:12 UTC290
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC291
    if run.floor.shop_open() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC292
        links.push(format!("</runs/{id}/shop>; rel=\"{REL_BASE}shop\""));
dfa38cf4cd4batagen2026-10-10 09:17 UTC293
    }
a9b016e4b248atagen2026-10-10 08:12 UTC294
    links.join(", ")
a9b016e4b248atagen2026-10-10 08:12 UTC295
}
a9b016e4b248atagen2026-10-10 08:12 UTC296
a9b016e4b248atagen2026-10-10 08:12 UTC297
fn finish(run: &Run, mut response: Response) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC298
    let status = response.status();
dfa38cf4cd4batagen2026-10-10 09:17 UTC299
    if status == StatusCode::NOT_MODIFIED {
a9b016e4b248atagen2026-10-10 08:12 UTC300
        return response;
a9b016e4b248atagen2026-10-10 08:12 UTC301
    }
a9b016e4b248atagen2026-10-10 08:12 UTC302
    let mut stats = vec![
a9b016e4b248atagen2026-10-10 08:12 UTC303
        (
a9b016e4b248atagen2026-10-10 08:12 UTC304
            "x-self-reported-length",
a9b016e4b248atagen2026-10-10 08:12 UTC305
            format!("{:.2}", run.self_reported()),
a9b016e4b248atagen2026-10-10 08:12 UTC306
        ),
a9b016e4b248atagen2026-10-10 08:12 UTC307
        ("x-girth", format!("{:.2}", run.girth)),
a9b016e4b248atagen2026-10-10 08:12 UTC308
        (
a9b016e4b248atagen2026-10-10 08:12 UTC309
            "x-foreskin",
a9b016e4b248atagen2026-10-10 08:12 UTC310
            run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")),
a9b016e4b248atagen2026-10-10 08:12 UTC311
        ),
a9b016e4b248atagen2026-10-10 08:12 UTC312
        ("x-eq", run.eq.to_string()),
a9b016e4b248atagen2026-10-10 08:12 UTC313
        ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)),
a9b016e4b248atagen2026-10-10 08:12 UTC314
        (
a9b016e4b248atagen2026-10-10 08:12 UTC315
            "x-suspicion",
a9b016e4b248atagen2026-10-10 08:12 UTC316
            format!("{}/{SUSPICION_LIMIT}", run.suspicion),
a9b016e4b248atagen2026-10-10 08:12 UTC317
        ),
a9b016e4b248atagen2026-10-10 08:12 UTC318
        ("x-shame", run.total_shame().to_string()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC319
        ("x-cash", run.cash.to_string()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC320
        ("x-floor", run.floor.number().to_string()),
a9b016e4b248atagen2026-10-10 08:12 UTC321
        ("link", links(run)),
a9b016e4b248atagen2026-10-10 08:12 UTC322
    ];
dfa38cf4cd4batagen2026-10-10 09:17 UTC323
    if let Some(observer) = run.observed {
dfa38cf4cd4batagen2026-10-10 09:17 UTC324
        stats.push(("x-observed-by", observer.into()));
a9b016e4b248atagen2026-10-10 08:12 UTC325
    }
a9b016e4b248atagen2026-10-10 08:12 UTC326
    if run.mom_present {
a9b016e4b248atagen2026-10-10 08:12 UTC327
        stats.push(("via", "1.1 mom".into()));
a9b016e4b248atagen2026-10-10 08:12 UTC328
        stats.push(("vary", "Mom".into()));
a9b016e4b248atagen2026-10-10 08:12 UTC329
    }
a9b016e4b248atagen2026-10-10 08:12 UTC330
    // RFC 9111 obsoleted Warning, which is why nobody heeds it.
a9b016e4b248atagen2026-10-10 08:12 UTC331
    if run.eq > 70 && run.ended.is_none() {
a9b016e4b248atagen2026-10-10 08:12 UTC332
        stats.push(("warning", "199 - \"it is turning purple\"".into()));
a9b016e4b248atagen2026-10-10 08:12 UTC333
    }
a9b016e4b248atagen2026-10-10 08:12 UTC334
    stats.push((
a9b016e4b248atagen2026-10-10 08:12 UTC335
        "set-cookie",
99524cf9f17catagen2026-10-10 10:53 UTC336
        format!(
99524cf9f17catagen2026-10-10 10:53 UTC337
            "shame={}; {COOKIE_ATTRIBUTES}",
99524cf9f17catagen2026-10-10 10:53 UTC338
            secret::sign("shame", &run.total_shame().to_string())
99524cf9f17catagen2026-10-10 10:53 UTC339
        ),
a9b016e4b248atagen2026-10-10 08:12 UTC340
    ));
dfa38cf4cd4batagen2026-10-10 09:17 UTC341
    if run.items.ruler {
99524cf9f17catagen2026-10-10 10:53 UTC342
        stats.push((
99524cf9f17catagen2026-10-10 10:53 UTC343
            "set-cookie",
99524cf9f17catagen2026-10-10 10:53 UTC344
            format!("ruler={}; {COOKIE_ATTRIBUTES}", secret::sign("ruler", "1")),
99524cf9f17catagen2026-10-10 10:53 UTC345
        ));
a9b016e4b248atagen2026-10-10 08:12 UTC346
    }
a9b016e4b248atagen2026-10-10 08:12 UTC347
    let headers = response.headers_mut();
a9b016e4b248atagen2026-10-10 08:12 UTC348
    for (name, value) in stats {
a9b016e4b248atagen2026-10-10 08:12 UTC349
        headers.append(name, HeaderValue::from_str(&value).expect("visible ascii"));
a9b016e4b248atagen2026-10-10 08:12 UTC350
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC351
    let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT;
dfa38cf4cd4batagen2026-10-10 09:17 UTC352
    if run.items.ring && !bodiless {
dfa38cf4cd4batagen2026-10-10 09:17 UTC353
        response.extensions_mut().insert(Compress);
dfa38cf4cd4batagen2026-10-10 09:17 UTC354
    }
a9b016e4b248atagen2026-10-10 08:12 UTC355
    response
a9b016e4b248atagen2026-10-10 08:12 UTC356
}
a9b016e4b248atagen2026-10-10 08:12 UTC357
a9b016e4b248atagen2026-10-10 08:12 UTC358
fn gone(run: &Run) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC359
    let ending = run.ended.as_ref().expect("only called on ended runs");
a9b016e4b248atagen2026-10-10 08:12 UTC360
    Problem::new(StatusCode::GONE, "run-ended", "This run has ended.")
a9b016e4b248atagen2026-10-10 08:12 UTC361
        .with("cause", format!("{PROBLEM_BASE}{}", ending.cause))
a9b016e4b248atagen2026-10-10 08:12 UTC362
        .with("turn", ending.turn)
a9b016e4b248atagen2026-10-10 08:12 UTC363
        .with("graveyard", "/graveyard")
a9b016e4b248atagen2026-10-10 08:12 UTC364
        .into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC365
}
a9b016e4b248atagen2026-10-10 08:12 UTC366
dfa38cf4cd4batagen2026-10-10 09:17 UTC367
fn captive_portal(run: &Run) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC368
    Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC369
        StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
dfa38cf4cd4batagen2026-10-10 09:17 UTC370
        "captive-portal",
dfa38cf4cd4batagen2026-10-10 09:17 UTC371
        "The network requires authentication. The hotel would like you to agree to some terms.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC372
    )
dfa38cf4cd4batagen2026-10-10 09:17 UTC373
    .with("portal", format!("/runs/{}/portal", run.id))
dfa38cf4cd4batagen2026-10-10 09:17 UTC374
    .header(
dfa38cf4cd4batagen2026-10-10 09:17 UTC375
        header::LINK,
dfa38cf4cd4batagen2026-10-10 09:17 UTC376
        format!("</runs/{}/portal>; rel=\"{REL_BASE}portal\"", run.id),
dfa38cf4cd4batagen2026-10-10 09:17 UTC377
    )
dfa38cf4cd4batagen2026-10-10 09:17 UTC378
    .into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC379
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC380
a9b016e4b248atagen2026-10-10 08:12 UTC381
fn with_run(
a9b016e4b248atagen2026-10-10 08:12 UTC382
    state: &AppState,
a9b016e4b248atagen2026-10-10 08:12 UTC383
    id: &str,
a9b016e4b248atagen2026-10-10 08:12 UTC384
    headers: &HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC385
    scope: Scope,
a9b016e4b248atagen2026-10-10 08:12 UTC386
    handle: impl FnOnce(&mut Run) -> Response,
a9b016e4b248atagen2026-10-10 08:12 UTC387
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC388
    let mut runs = state.runs.lock().unwrap();
a9b016e4b248atagen2026-10-10 08:12 UTC389
    let Some(run) = runs.get_mut(id) else {
a9b016e4b248atagen2026-10-10 08:12 UTC390
        return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC391
            StatusCode::NOT_FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC392
            "no-such-run",
a9b016e4b248atagen2026-10-10 08:12 UTC393
            format!("There is no run {id}. There may never have been."),
a9b016e4b248atagen2026-10-10 08:12 UTC394
        )
a9b016e4b248atagen2026-10-10 08:12 UTC395
        .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC396
    };
a9b016e4b248atagen2026-10-10 08:12 UTC397
    if run.ended.is_some() {
a9b016e4b248atagen2026-10-10 08:12 UTC398
        return gone(run);
a9b016e4b248atagen2026-10-10 08:12 UTC399
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC400
    run.observed = None;
dfa38cf4cd4batagen2026-10-10 09:17 UTC401
    if scope == Scope::Home {
dfa38cf4cd4batagen2026-10-10 09:17 UTC402
        if run.floor == Floor::Hotel && !run.wifi_accepted {
dfa38cf4cd4batagen2026-10-10 09:17 UTC403
            return finish(run, captive_portal(run));
dfa38cf4cd4batagen2026-10-10 09:17 UTC404
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC405
        if run.floor.has_mom()
dfa38cf4cd4batagen2026-10-10 09:17 UTC406
            && run.mom_present
dfa38cf4cd4batagen2026-10-10 09:17 UTC407
            && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION))
dfa38cf4cd4batagen2026-10-10 09:17 UTC408
        {
dfa38cf4cd4batagen2026-10-10 09:17 UTC409
            return finish(run, problem.into_response());
dfa38cf4cd4batagen2026-10-10 09:17 UTC410
        }
a9b016e4b248atagen2026-10-10 08:12 UTC411
    }
a9b016e4b248atagen2026-10-10 08:12 UTC412
    let response = handle(run);
a9b016e4b248atagen2026-10-10 08:12 UTC413
    finish(run, response)
a9b016e4b248atagen2026-10-10 08:12 UTC414
}
a9b016e4b248atagen2026-10-10 08:12 UTC415
a9b016e4b248atagen2026-10-10 08:12 UTC416
fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC417
    match result {
a9b016e4b248atagen2026-10-10 08:12 UTC418
        Ok(ok) => ok.into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC419
        Err(err) => err.into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC420
    }
a9b016e4b248atagen2026-10-10 08:12 UTC421
}
a9b016e4b248atagen2026-10-10 08:12 UTC422
a9b016e4b248atagen2026-10-10 08:12 UTC423
async fn runs(
a9b016e4b248atagen2026-10-10 08:12 UTC424
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC425
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC426
    Query(query): Query<HashMap<String, String>>,
a9b016e4b248atagen2026-10-10 08:12 UTC427
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC428
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC429
    if method != Method::POST {
a9b016e4b248atagen2026-10-10 08:12 UTC430
        return unsupported(&method, "POST");
a9b016e4b248atagen2026-10-10 08:12 UTC431
    }
99524cf9f17catagen2026-10-10 10:53 UTC432
    let seeded = query.contains_key("seed");
a9b016e4b248atagen2026-10-10 08:12 UTC433
    let seed = match query.get("seed").map(|s| s.parse::<u64>()) {
a9b016e4b248atagen2026-10-10 08:12 UTC434
        None => rand::rng().random(),
a9b016e4b248atagen2026-10-10 08:12 UTC435
        Some(Ok(seed)) => seed,
a9b016e4b248atagen2026-10-10 08:12 UTC436
        Some(Err(_)) => {
a9b016e4b248atagen2026-10-10 08:12 UTC437
            return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC438
                StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC439
                "bad-seed",
a9b016e4b248atagen2026-10-10 08:12 UTC440
                "The seed must be an unsigned 64-bit integer.",
a9b016e4b248atagen2026-10-10 08:12 UTC441
            )
a9b016e4b248atagen2026-10-10 08:12 UTC442
            .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC443
        }
a9b016e4b248atagen2026-10-10 08:12 UTC444
    };
a9b016e4b248atagen2026-10-10 08:12 UTC445
    let shame = cookie(&headers, "shame")
99524cf9f17catagen2026-10-10 10:53 UTC446
        .and_then(|s| secret::verify("shame", s))
a9b016e4b248atagen2026-10-10 08:12 UTC447
        .and_then(|s| s.parse().ok())
a9b016e4b248atagen2026-10-10 08:12 UTC448
        .unwrap_or(0);
a9b016e4b248atagen2026-10-10 08:12 UTC449
    let id = format!("{:08x}", rand::rng().random::<u32>());
99524cf9f17catagen2026-10-10 10:53 UTC450
    let mut run = Run::new(id.clone(), secret::mix_seed(seed), shame);
99524cf9f17catagen2026-10-10 10:53 UTC451
    run.seeded = seeded;
a9b016e4b248atagen2026-10-10 08:12 UTC452
    let response = (
a9b016e4b248atagen2026-10-10 08:12 UTC453
        StatusCode::CREATED,
a9b016e4b248atagen2026-10-10 08:12 UTC454
        [
a9b016e4b248atagen2026-10-10 08:12 UTC455
            (header::LOCATION, format!("/runs/{id}")),
a9b016e4b248atagen2026-10-10 08:12 UTC456
            (header::ETAG, run.etag()),
a9b016e4b248atagen2026-10-10 08:12 UTC457
            (HeaderName::from_static("x-seed"), seed.to_string()),
a9b016e4b248atagen2026-10-10 08:12 UTC458
        ],
a9b016e4b248atagen2026-10-10 08:12 UTC459
        Json(run.view()),
a9b016e4b248atagen2026-10-10 08:12 UTC460
    )
a9b016e4b248atagen2026-10-10 08:12 UTC461
        .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC462
    let response = finish(&run, response);
a9b016e4b248atagen2026-10-10 08:12 UTC463
    state.insert(run);
a9b016e4b248atagen2026-10-10 08:12 UTC464
    response
a9b016e4b248atagen2026-10-10 08:12 UTC465
}
a9b016e4b248atagen2026-10-10 08:12 UTC466
a9b016e4b248atagen2026-10-10 08:12 UTC467
#[allow(clippy::too_many_arguments)]
a9b016e4b248atagen2026-10-10 08:12 UTC468
async fn run(
a9b016e4b248atagen2026-10-10 08:12 UTC469
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC470
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC471
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC472
    uri: Uri,
a9b016e4b248atagen2026-10-10 08:12 UTC473
    version: Version,
a9b016e4b248atagen2026-10-10 08:12 UTC474
    Query(query): Query<HashMap<String, String>>,
a9b016e4b248atagen2026-10-10 08:12 UTC475
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC476
    body: Bytes,
a9b016e4b248atagen2026-10-10 08:12 UTC477
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC478
    let now = Instant::now();
dfa38cf4cd4batagen2026-10-10 09:17 UTC479
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC480
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC481
            "GET" | "HEAD" => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC482
                let accept = header_str(&headers, header::ACCEPT).unwrap_or_default();
dfa38cf4cd4batagen2026-10-10 09:17 UTC483
                if accept.contains("reassurance") {
dfa38cf4cd4batagen2026-10-10 09:17 UTC484
                    return Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC485
                        StatusCode::NOT_ACCEPTABLE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC486
                        "no-reassurance",
dfa38cf4cd4batagen2026-10-10 09:17 UTC487
                        "The server does not produce reassurance.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC488
                    )
dfa38cf4cd4batagen2026-10-10 09:17 UTC489
                    .into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC490
                }
dfa38cf4cd4batagen2026-10-10 09:17 UTC491
                (
dfa38cf4cd4batagen2026-10-10 09:17 UTC492
                    StatusCode::OK,
dfa38cf4cd4batagen2026-10-10 09:17 UTC493
                    [(header::ETAG, run.etag())],
dfa38cf4cd4batagen2026-10-10 09:17 UTC494
                    Json(run.view()),
a9b016e4b248atagen2026-10-10 08:12 UTC495
                )
dfa38cf4cd4batagen2026-10-10 09:17 UTC496
                    .into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC497
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC498
            "JELQ" => jelq(run, &query, &headers, now),
dfa38cf4cd4batagen2026-10-10 09:17 UTC499
            "PUT" => run.put().into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC500
            "PATCH" => patch(run, &headers, &body),
dfa38cf4cd4batagen2026-10-10 09:17 UTC501
            "DELETE" => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC502
                run.end("abandoned", "The run was abandoned.");
dfa38cf4cd4batagen2026-10-10 09:17 UTC503
                StatusCode::NO_CONTENT.into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC504
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC505
            "OPTIONS" => options(RUN_ALLOW, true),
dfa38cf4cd4batagen2026-10-10 09:17 UTC506
            "TRACE" => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC507
                run.shame += 1;
dfa38cf4cd4batagen2026-10-10 09:17 UTC508
                trace(&method, &uri, version, &headers)
dfa38cf4cd4batagen2026-10-10 09:17 UTC509
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC510
            _ => unsupported(&method, RUN_ALLOW),
a9b016e4b248atagen2026-10-10 08:12 UTC511
        }
a9b016e4b248atagen2026-10-10 08:12 UTC512
    })
a9b016e4b248atagen2026-10-10 08:12 UTC513
}
a9b016e4b248atagen2026-10-10 08:12 UTC514
a9b016e4b248atagen2026-10-10 08:12 UTC515
fn jelq(
a9b016e4b248atagen2026-10-10 08:12 UTC516
    run: &mut Run,
a9b016e4b248atagen2026-10-10 08:12 UTC517
    query: &HashMap<String, String>,
a9b016e4b248atagen2026-10-10 08:12 UTC518
    headers: &HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC519
    now: Instant,
a9b016e4b248atagen2026-10-10 08:12 UTC520
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC521
    let techniques_href = format!("/runs/{}/techniques", run.id);
177535ac8abfatagen2026-10-10 10:39 UTC522
    let Some(names) = query.get("technique").filter(|n| !n.trim().is_empty()) else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC523
        return Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC524
            StatusCode::BAD_REQUEST,
dfa38cf4cd4batagen2026-10-10 09:17 UTC525
            "technique-required",
dfa38cf4cd4batagen2026-10-10 09:17 UTC526
            "No technique was specified.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC527
        )
dfa38cf4cd4batagen2026-10-10 09:17 UTC528
        .with("techniques", techniques_href)
dfa38cf4cd4batagen2026-10-10 09:17 UTC529
        .into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC530
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC531
    let mut techniques = Vec::new();
177535ac8abfatagen2026-10-10 10:39 UTC532
    for name in names.split(',').map(str::trim).filter(|n| !n.is_empty()) {
dfa38cf4cd4batagen2026-10-10 09:17 UTC533
        let Some(technique) = Technique::parse(name) else {
a9b016e4b248atagen2026-10-10 08:12 UTC534
            return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC535
                StatusCode::BAD_REQUEST,
dfa38cf4cd4batagen2026-10-10 09:17 UTC536
                "unknown-technique",
dfa38cf4cd4batagen2026-10-10 09:17 UTC537
                format!("{name} is not a technique."),
a9b016e4b248atagen2026-10-10 08:12 UTC538
            )
dfa38cf4cd4batagen2026-10-10 09:17 UTC539
            .with("techniques", techniques_href)
a9b016e4b248atagen2026-10-10 08:12 UTC540
            .into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC541
        };
dfa38cf4cd4batagen2026-10-10 09:17 UTC542
        techniques.push(technique);
dfa38cf4cd4batagen2026-10-10 09:17 UTC543
    }
a9b016e4b248atagen2026-10-10 08:12 UTC544
    let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) {
a9b016e4b248atagen2026-10-10 08:12 UTC545
        None => 5,
a9b016e4b248atagen2026-10-10 08:12 UTC546
        Some(Ok(i)) if (1..=10).contains(&i) => i,
a9b016e4b248atagen2026-10-10 08:12 UTC547
        Some(_) => {
a9b016e4b248atagen2026-10-10 08:12 UTC548
            return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC549
                StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC550
                "intensity-out-of-range",
a9b016e4b248atagen2026-10-10 08:12 UTC551
                "Intensity is measured from 1 to 10.",
a9b016e4b248atagen2026-10-10 08:12 UTC552
            )
a9b016e4b248atagen2026-10-10 08:12 UTC553
            .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC554
        }
a9b016e4b248atagen2026-10-10 08:12 UTC555
    };
a9b016e4b248atagen2026-10-10 08:12 UTC556
    let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) {
a9b016e4b248atagen2026-10-10 08:12 UTC557
        Ok(lube) => lube,
a9b016e4b248atagen2026-10-10 08:12 UTC558
        Err(problem) => return problem.into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC559
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC560
    respond(run.jelq(&techniques, intensity, lube, now))
a9b016e4b248atagen2026-10-10 08:12 UTC561
}
a9b016e4b248atagen2026-10-10 08:12 UTC562
a9b016e4b248atagen2026-10-10 08:12 UTC563
fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC564
    let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default();
a9b016e4b248atagen2026-10-10 08:12 UTC565
    if !content_type.starts_with("application/json-patch+json") {
a9b016e4b248atagen2026-10-10 08:12 UTC566
        return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC567
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
a9b016e4b248atagen2026-10-10 08:12 UTC568
            "patch-format",
a9b016e4b248atagen2026-10-10 08:12 UTC569
            "Patches are accepted as application/json-patch+json.",
a9b016e4b248atagen2026-10-10 08:12 UTC570
        )
a9b016e4b248atagen2026-10-10 08:12 UTC571
        .header(
a9b016e4b248atagen2026-10-10 08:12 UTC572
            HeaderName::from_static("accept-patch"),
a9b016e4b248atagen2026-10-10 08:12 UTC573
            "application/json-patch+json",
a9b016e4b248atagen2026-10-10 08:12 UTC574
        )
a9b016e4b248atagen2026-10-10 08:12 UTC575
        .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC576
    }
a9b016e4b248atagen2026-10-10 08:12 UTC577
    match serde_json::from_slice::<Vec<PatchOp>>(body) {
a9b016e4b248atagen2026-10-10 08:12 UTC578
        Ok(ops) => respond(run.patch(&ops)),
a9b016e4b248atagen2026-10-10 08:12 UTC579
        Err(_) => Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC580
            StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC581
            "malformed-patch",
a9b016e4b248atagen2026-10-10 08:12 UTC582
            "The patch could not be parsed.",
a9b016e4b248atagen2026-10-10 08:12 UTC583
        )
a9b016e4b248atagen2026-10-10 08:12 UTC584
        .into_response(),
a9b016e4b248atagen2026-10-10 08:12 UTC585
    }
a9b016e4b248atagen2026-10-10 08:12 UTC586
}
a9b016e4b248atagen2026-10-10 08:12 UTC587
dfa38cf4cd4batagen2026-10-10 09:17 UTC588
fn millimetres(inches: f64) -> usize {
dfa38cf4cd4batagen2026-10-10 09:17 UTC589
    (inches * MM_PER_INCH).round() as usize
dfa38cf4cd4batagen2026-10-10 09:17 UTC590
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC591
dfa38cf4cd4batagen2026-10-10 09:17 UTC592
fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> {
dfa38cf4cd4batagen2026-10-10 09:17 UTC593
    let (start, end) = range.strip_prefix("inches=")?.split_once('-')?;
dfa38cf4cd4batagen2026-10-10 09:17 UTC594
    let start: f64 = start.trim().parse().ok()?;
dfa38cf4cd4batagen2026-10-10 09:17 UTC595
    let end: f64 = match end.trim() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC596
        "" => total,
dfa38cf4cd4batagen2026-10-10 09:17 UTC597
        end => end.parse().ok()?,
dfa38cf4cd4batagen2026-10-10 09:17 UTC598
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC599
    (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total)))
dfa38cf4cd4batagen2026-10-10 09:17 UTC600
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC601
dfa38cf4cd4batagen2026-10-10 09:17 UTC602
fn length_response(run: &Run, headers: &HeaderMap) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC603
    let inches = if run.items.ruler {
dfa38cf4cd4batagen2026-10-10 09:17 UTC604
        run.current_length()
dfa38cf4cd4batagen2026-10-10 09:17 UTC605
    } else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC606
        run.self_reported()
dfa38cf4cd4batagen2026-10-10 09:17 UTC607
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC608
    let body = shaft(millimetres(inches));
dfa38cf4cd4batagen2026-10-10 09:17 UTC609
    let content_type = (
dfa38cf4cd4batagen2026-10-10 09:17 UTC610
        header::CONTENT_TYPE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC611
        "text/plain; charset=us-ascii".to_owned(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC612
    );
dfa38cf4cd4batagen2026-10-10 09:17 UTC613
    let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned());
dfa38cf4cd4batagen2026-10-10 09:17 UTC614
dfa38cf4cd4batagen2026-10-10 09:17 UTC615
    if let Some(range) = header_str(headers, header::RANGE)
dfa38cf4cd4batagen2026-10-10 09:17 UTC616
        && range.starts_with("inches=")
dfa38cf4cd4batagen2026-10-10 09:17 UTC617
    {
dfa38cf4cd4batagen2026-10-10 09:17 UTC618
        let Some((start, end)) = parse_inch_range(range, inches) else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC619
            return Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC620
                StatusCode::RANGE_NOT_SATISFIABLE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC621
                "range-not-satisfiable",
dfa38cf4cd4batagen2026-10-10 09:17 UTC622
                format!("The requested range is not available. {inches:.2} inches are."),
dfa38cf4cd4batagen2026-10-10 09:17 UTC623
            )
dfa38cf4cd4batagen2026-10-10 09:17 UTC624
            .header(header::CONTENT_RANGE, format!("inches */{inches:.2}"))
dfa38cf4cd4batagen2026-10-10 09:17 UTC625
            .into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC626
        };
dfa38cf4cd4batagen2026-10-10 09:17 UTC627
        let from = millimetres(start).min(body.len() - 1);
dfa38cf4cd4batagen2026-10-10 09:17 UTC628
        let to = millimetres(end).clamp(from + 1, body.len());
dfa38cf4cd4batagen2026-10-10 09:17 UTC629
        let content_range = (
dfa38cf4cd4batagen2026-10-10 09:17 UTC630
            header::CONTENT_RANGE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC631
            format!("inches {start:.2}-{end:.2}/{inches:.2}"),
dfa38cf4cd4batagen2026-10-10 09:17 UTC632
        );
dfa38cf4cd4batagen2026-10-10 09:17 UTC633
        return (
dfa38cf4cd4batagen2026-10-10 09:17 UTC634
            StatusCode::PARTIAL_CONTENT,
dfa38cf4cd4batagen2026-10-10 09:17 UTC635
            [content_type, accept_ranges, content_range],
dfa38cf4cd4batagen2026-10-10 09:17 UTC636
            body[from..to].to_owned(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC637
        )
dfa38cf4cd4batagen2026-10-10 09:17 UTC638
            .into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC639
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC640
dfa38cf4cd4batagen2026-10-10 09:17 UTC641
    if run.items.ruler {
dfa38cf4cd4batagen2026-10-10 09:17 UTC642
        return (StatusCode::OK, [content_type, accept_ranges], body).into_response();
dfa38cf4cd4batagen2026-10-10 09:17 UTC643
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC644
    let chunks: Vec<Result<Bytes, Infallible>> = body
dfa38cf4cd4batagen2026-10-10 09:17 UTC645
        .into_bytes()
dfa38cf4cd4batagen2026-10-10 09:17 UTC646
        .chunks(16)
dfa38cf4cd4batagen2026-10-10 09:17 UTC647
        .map(|c| Ok(Bytes::copy_from_slice(c)))
dfa38cf4cd4batagen2026-10-10 09:17 UTC648
        .collect();
dfa38cf4cd4batagen2026-10-10 09:17 UTC649
    let body = Body::from_stream(futures_util::stream::iter(chunks));
dfa38cf4cd4batagen2026-10-10 09:17 UTC650
    (StatusCode::OK, [content_type, accept_ranges], body).into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC651
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC652
a9b016e4b248atagen2026-10-10 08:12 UTC653
async fn length(
a9b016e4b248atagen2026-10-10 08:12 UTC654
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC655
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC656
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC657
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC658
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC659
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC660
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC661
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC662
            "GET" | "HEAD" => length_response(run, &headers),
dfa38cf4cd4batagen2026-10-10 09:17 UTC663
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC664
            _ => unsupported(&method, ALLOW),
a9b016e4b248atagen2026-10-10 08:12 UTC665
        }
a9b016e4b248atagen2026-10-10 08:12 UTC666
    })
a9b016e4b248atagen2026-10-10 08:12 UTC667
}
a9b016e4b248atagen2026-10-10 08:12 UTC668
a9b016e4b248atagen2026-10-10 08:12 UTC669
async fn foreskin(
a9b016e4b248atagen2026-10-10 08:12 UTC670
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC671
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC672
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC673
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC674
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC675
    const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC676
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC677
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC678
            "GET" | "HEAD" => match run.foreskin {
dfa38cf4cd4batagen2026-10-10 09:17 UTC679
                Some(f) => (
dfa38cf4cd4batagen2026-10-10 09:17 UTC680
                    StatusCode::OK,
dfa38cf4cd4batagen2026-10-10 09:17 UTC681
                    [(header::ETAG, run.etag())],
dfa38cf4cd4batagen2026-10-10 09:17 UTC682
                    Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })),
dfa38cf4cd4batagen2026-10-10 09:17 UTC683
                )
dfa38cf4cd4batagen2026-10-10 09:17 UTC684
                    .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC685
                None => Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC686
                    StatusCode::GONE,
dfa38cf4cd4batagen2026-10-10 09:17 UTC687
                    "foreskin-gone",
dfa38cf4cd4batagen2026-10-10 09:17 UTC688
                    "The foreskin is gone. It will not be returning.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC689
                )
a9b016e4b248atagen2026-10-10 08:12 UTC690
                .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC691
            },
dfa38cf4cd4batagen2026-10-10 09:17 UTC692
            "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))),
dfa38cf4cd4batagen2026-10-10 09:17 UTC693
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC694
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC695
        }
a9b016e4b248atagen2026-10-10 08:12 UTC696
    })
a9b016e4b248atagen2026-10-10 08:12 UTC697
}
a9b016e4b248atagen2026-10-10 08:12 UTC698
a9b016e4b248atagen2026-10-10 08:12 UTC699
async fn inventory(
a9b016e4b248atagen2026-10-10 08:12 UTC700
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC701
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC702
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC703
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC704
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC705
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC706
    with_run(&state, &id, &headers, Scope::Pockets, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC707
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC708
            "GET" | "HEAD" => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC709
                let listed: Vec<Value> = run
dfa38cf4cd4batagen2026-10-10 09:17 UTC710
                    .inventory()
dfa38cf4cd4batagen2026-10-10 09:17 UTC711
                    .into_iter()
dfa38cf4cd4batagen2026-10-10 09:17 UTC712
                    .map(|(name, _)| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC713
                        json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) })
dfa38cf4cd4batagen2026-10-10 09:17 UTC714
                    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC715
                    .collect();
dfa38cf4cd4batagen2026-10-10 09:17 UTC716
                Json(json!({
dfa38cf4cd4batagen2026-10-10 09:17 UTC717
                    "items": listed,
dfa38cf4cd4batagen2026-10-10 09:17 UTC718
                    "ice_packs": run.items.ice_packs,
dfa38cf4cd4batagen2026-10-10 09:17 UTC719
                    "cash": run.cash,
dfa38cf4cd4batagen2026-10-10 09:17 UTC720
                    "excuses": run.excuses(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC721
                }))
dfa38cf4cd4batagen2026-10-10 09:17 UTC722
                .into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC723
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC724
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC725
            _ => unsupported(&method, ALLOW),
a9b016e4b248atagen2026-10-10 08:12 UTC726
        }
a9b016e4b248atagen2026-10-10 08:12 UTC727
    })
a9b016e4b248atagen2026-10-10 08:12 UTC728
}
a9b016e4b248atagen2026-10-10 08:12 UTC729
a9b016e4b248atagen2026-10-10 08:12 UTC730
async fn item(
a9b016e4b248atagen2026-10-10 08:12 UTC731
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC732
    Path((id, name)): Path<(String, String)>,
a9b016e4b248atagen2026-10-10 08:12 UTC733
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC734
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC735
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC736
    const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC737
    with_run(&state, &id, &headers, Scope::Pockets, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC738
        let Some((name, description)) = run.inventory().into_iter().find(|(n, _)| *n == name)
dfa38cf4cd4batagen2026-10-10 09:17 UTC739
        else {
a9b016e4b248atagen2026-10-10 08:12 UTC740
            return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC741
                StatusCode::NOT_FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC742
                "no-such-item",
a9b016e4b248atagen2026-10-10 08:12 UTC743
                "You do not have that.",
a9b016e4b248atagen2026-10-10 08:12 UTC744
            )
a9b016e4b248atagen2026-10-10 08:12 UTC745
            .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC746
        };
a9b016e4b248atagen2026-10-10 08:12 UTC747
        match method.as_str() {
a9b016e4b248atagen2026-10-10 08:12 UTC748
            "GET" | "HEAD" => {
a9b016e4b248atagen2026-10-10 08:12 UTC749
                Json(json!({ "name": name, "description": description })).into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC750
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC751
            "DELETE" => respond(run.drop_item(name)),
a9b016e4b248atagen2026-10-10 08:12 UTC752
            "OPTIONS" => options(ALLOW, false),
a9b016e4b248atagen2026-10-10 08:12 UTC753
            _ => unsupported(&method, ALLOW),
a9b016e4b248atagen2026-10-10 08:12 UTC754
        }
a9b016e4b248atagen2026-10-10 08:12 UTC755
    })
a9b016e4b248atagen2026-10-10 08:12 UTC756
}
a9b016e4b248atagen2026-10-10 08:12 UTC757
a9b016e4b248atagen2026-10-10 08:12 UTC758
async fn techniques(
a9b016e4b248atagen2026-10-10 08:12 UTC759
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC760
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC761
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC762
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC763
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC764
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC765
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC766
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC767
            "GET" | "HEAD" => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC768
                let listed: Vec<Value> = Technique::ALL
dfa38cf4cd4batagen2026-10-10 09:17 UTC769
                    .into_iter()
dfa38cf4cd4batagen2026-10-10 09:17 UTC770
                    .map(|t| t.describe(run.forum_read))
dfa38cf4cd4batagen2026-10-10 09:17 UTC771
                    .collect();
dfa38cf4cd4batagen2026-10-10 09:17 UTC772
                Json(json!({
dfa38cf4cd4batagen2026-10-10 09:17 UTC773
                    "usage": format!("JELQ /runs/{}?technique=<name>[,<name>]&intensity=<1-10>", run.id),
dfa38cf4cd4batagen2026-10-10 09:17 UTC774
                    "lubricant": "Declared with Content-Type.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC775
                    "lubricants": LUBRICANTS,
dfa38cf4cd4batagen2026-10-10 09:17 UTC776
                    "eq_band": [50, 70],
dfa38cf4cd4batagen2026-10-10 09:17 UTC777
                    "techniques": listed,
dfa38cf4cd4batagen2026-10-10 09:17 UTC778
                }))
dfa38cf4cd4batagen2026-10-10 09:17 UTC779
                .into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC780
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC781
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC782
            _ => unsupported(&method, ALLOW),
a9b016e4b248atagen2026-10-10 08:12 UTC783
        }
a9b016e4b248atagen2026-10-10 08:12 UTC784
    })
a9b016e4b248atagen2026-10-10 08:12 UTC785
}
a9b016e4b248atagen2026-10-10 08:12 UTC786
a9b016e4b248atagen2026-10-10 08:12 UTC787
async fn drawer(
a9b016e4b248atagen2026-10-10 08:12 UTC788
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC789
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC790
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC791
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC792
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC793
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC794
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC795
            "POST" => respond(run.search_drawer()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC796
            "OPTIONS" => options("POST, OPTIONS", false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC797
            _ => unsupported(&method, "POST, OPTIONS"),
dfa38cf4cd4batagen2026-10-10 09:17 UTC798
        }
a9b016e4b248atagen2026-10-10 08:12 UTC799
    })
a9b016e4b248atagen2026-10-10 08:12 UTC800
}
a9b016e4b248atagen2026-10-10 08:12 UTC801
a9b016e4b248atagen2026-10-10 08:12 UTC802
async fn branch(
a9b016e4b248atagen2026-10-10 08:12 UTC803
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC804
    Path((id, choice)): Path<(String, String)>,
a9b016e4b248atagen2026-10-10 08:12 UTC805
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC806
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC807
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC808
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC809
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC810
            "POST" => respond(run.choose(&choice)),
dfa38cf4cd4batagen2026-10-10 09:17 UTC811
            "OPTIONS" => options("POST, OPTIONS", false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC812
            _ => unsupported(&method, "POST, OPTIONS"),
dfa38cf4cd4batagen2026-10-10 09:17 UTC813
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC814
    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC815
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC816
dfa38cf4cd4batagen2026-10-10 09:17 UTC817
async fn floor(
dfa38cf4cd4batagen2026-10-10 09:17 UTC818
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC819
    Path(id): Path<String>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC820
    method: Method,
dfa38cf4cd4batagen2026-10-10 09:17 UTC821
    headers: HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC822
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC823
    const ALLOW: &str = "GET, HEAD, POST, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC824
    with_run(&state, &id, &headers, Scope::Pockets, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC825
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC826
            "GET" | "HEAD" => Json(run.floor_view()).into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC827
            "POST" => respond(run.advance()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC828
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC829
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC830
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC831
    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC832
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC833
dfa38cf4cd4batagen2026-10-10 09:17 UTC834
async fn shop(
dfa38cf4cd4batagen2026-10-10 09:17 UTC835
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC836
    Path(id): Path<String>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC837
    method: Method,
dfa38cf4cd4batagen2026-10-10 09:17 UTC838
    headers: HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC839
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC840
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC841
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC842
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC843
            "GET" | "HEAD" => respond(run.shop_view().map(Json)),
dfa38cf4cd4batagen2026-10-10 09:17 UTC844
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC845
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC846
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC847
    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC848
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC849
dfa38cf4cd4batagen2026-10-10 09:17 UTC850
async fn buy(
dfa38cf4cd4batagen2026-10-10 09:17 UTC851
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC852
    Path((id, item)): Path<(String, String)>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC853
    method: Method,
dfa38cf4cd4batagen2026-10-10 09:17 UTC854
    headers: HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC855
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC856
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC857
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC858
            "POST" => respond(run.buy(&item)),
dfa38cf4cd4batagen2026-10-10 09:17 UTC859
            "OPTIONS" => options("POST, OPTIONS", false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC860
            _ => unsupported(&method, "POST, OPTIONS"),
dfa38cf4cd4batagen2026-10-10 09:17 UTC861
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC862
    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC863
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC864
dfa38cf4cd4batagen2026-10-10 09:17 UTC865
async fn portal(
dfa38cf4cd4batagen2026-10-10 09:17 UTC866
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC867
    Path(id): Path<String>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC868
    method: Method,
dfa38cf4cd4batagen2026-10-10 09:17 UTC869
    headers: HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC870
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC871
    const ALLOW: &str = "GET, HEAD, POST, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC872
    with_run(&state, &id, &headers, Scope::Pockets, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC873
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC874
            "GET" | "HEAD" => Json(json!({
dfa38cf4cd4batagen2026-10-10 09:17 UTC875
                "terms": "By connecting you agree that the hotel is not responsible for anything that happens in this room, and that it does not want to know.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC876
                "accept": { "href": format!("/runs/{}/portal", run.id), "method": "POST" },
dfa38cf4cd4batagen2026-10-10 09:17 UTC877
            }))
dfa38cf4cd4batagen2026-10-10 09:17 UTC878
            .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC879
            "POST" => respond(run.accept_terms()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC880
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC881
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC882
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC883
    })
dfa38cf4cd4batagen2026-10-10 09:17 UTC884
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC885
dfa38cf4cd4batagen2026-10-10 09:17 UTC886
async fn elder(
dfa38cf4cd4batagen2026-10-10 09:17 UTC887
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC888
    Path(id): Path<String>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC889
    method: Method,
dfa38cf4cd4batagen2026-10-10 09:17 UTC890
    headers: HeaderMap,
dfa38cf4cd4batagen2026-10-10 09:17 UTC891
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC892
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC893
    with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC894
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC895
            "GET" | "HEAD" => respond(run.elder(
dfa38cf4cd4batagen2026-10-10 09:17 UTC896
                header_str(&headers, header::PROXY_AUTHORIZATION),
dfa38cf4cd4batagen2026-10-10 09:17 UTC897
                header_str(&headers, header::IF_MODIFIED_SINCE),
dfa38cf4cd4batagen2026-10-10 09:17 UTC898
            )),
dfa38cf4cd4batagen2026-10-10 09:17 UTC899
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC900
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC901
        }
a9b016e4b248atagen2026-10-10 08:12 UTC902
    })
a9b016e4b248atagen2026-10-10 08:12 UTC903
}
a9b016e4b248atagen2026-10-10 08:12 UTC904
dfa38cf4cd4batagen2026-10-10 09:17 UTC905
fn requested_grip(headers: &HeaderMap) -> Option<Grip> {
dfa38cf4cd4batagen2026-10-10 09:17 UTC906
    let connection = header_str(headers, header::CONNECTION)?.to_ascii_lowercase();
dfa38cf4cd4batagen2026-10-10 09:17 UTC907
    if !connection.split(',').any(|token| token.trim() == "upgrade") {
dfa38cf4cd4batagen2026-10-10 09:17 UTC908
        return None;
dfa38cf4cd4batagen2026-10-10 09:17 UTC909
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC910
    header_str(headers, header::UPGRADE)?
dfa38cf4cd4batagen2026-10-10 09:17 UTC911
        .split(',')
dfa38cf4cd4batagen2026-10-10 09:17 UTC912
        .find_map(|protocol| Grip::parse(protocol.trim()))
dfa38cf4cd4batagen2026-10-10 09:17 UTC913
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC914
dfa38cf4cd4batagen2026-10-10 09:17 UTC915
async fn grip(
dfa38cf4cd4batagen2026-10-10 09:17 UTC916
    State(state): State<Shared>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC917
    Path(id): Path<String>,
dfa38cf4cd4batagen2026-10-10 09:17 UTC918
    mut request: Request,
dfa38cf4cd4batagen2026-10-10 09:17 UTC919
) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC920
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC921
    let method = request.method().clone();
dfa38cf4cd4batagen2026-10-10 09:17 UTC922
    let headers = request.headers().clone();
dfa38cf4cd4batagen2026-10-10 09:17 UTC923
    let wanted = requested_grip(&headers);
dfa38cf4cd4batagen2026-10-10 09:17 UTC924
    let upgrade = wanted.map(|_| hyper::upgrade::on(&mut request));
177535ac8abfatagen2026-10-10 10:39 UTC925
    let detail = match header_str(&headers, header::UPGRADE) {
177535ac8abfatagen2026-10-10 10:39 UTC926
        Some(protocol) if wanted.is_none() => {
177535ac8abfatagen2026-10-10 10:39 UTC927
            format!("The server does not know the {protocol} grip. Grips are protocols.")
177535ac8abfatagen2026-10-10 10:39 UTC928
        }
177535ac8abfatagen2026-10-10 10:39 UTC929
        _ => "Grips are protocols. Change grip by upgrading the connection.".to_owned(),
177535ac8abfatagen2026-10-10 10:39 UTC930
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC931
    let response = with_run(&state, &id, &headers, Scope::Home, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC932
        match (method.as_str(), wanted) {
dfa38cf4cd4batagen2026-10-10 09:17 UTC933
            ("GET" | "HEAD", Some(grip)) => {
dfa38cf4cd4batagen2026-10-10 09:17 UTC934
                run.set_grip(grip);
dfa38cf4cd4batagen2026-10-10 09:17 UTC935
                (
dfa38cf4cd4batagen2026-10-10 09:17 UTC936
                    StatusCode::SWITCHING_PROTOCOLS,
dfa38cf4cd4batagen2026-10-10 09:17 UTC937
                    [
dfa38cf4cd4batagen2026-10-10 09:17 UTC938
                        (header::UPGRADE, grip.name()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC939
                        (header::CONNECTION, "upgrade"),
dfa38cf4cd4batagen2026-10-10 09:17 UTC940
                    ],
dfa38cf4cd4batagen2026-10-10 09:17 UTC941
                )
dfa38cf4cd4batagen2026-10-10 09:17 UTC942
                    .into_response()
dfa38cf4cd4batagen2026-10-10 09:17 UTC943
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC944
            ("GET" | "HEAD", None) => (
dfa38cf4cd4batagen2026-10-10 09:17 UTC945
                StatusCode::OK,
dfa38cf4cd4batagen2026-10-10 09:17 UTC946
                [(header::UPGRADE, "overhand, underhand")],
dfa38cf4cd4batagen2026-10-10 09:17 UTC947
                Json(json!({
dfa38cf4cd4batagen2026-10-10 09:17 UTC948
                    "grip": run.grip.name(),
177535ac8abfatagen2026-10-10 10:39 UTC949
                    "detail": detail,
dfa38cf4cd4batagen2026-10-10 09:17 UTC950
                })),
dfa38cf4cd4batagen2026-10-10 09:17 UTC951
            )
dfa38cf4cd4batagen2026-10-10 09:17 UTC952
                .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC953
            ("OPTIONS", _) => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC954
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC955
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC956
    });
dfa38cf4cd4batagen2026-10-10 09:17 UTC957
    if response.status() == StatusCode::SWITCHING_PROTOCOLS
dfa38cf4cd4batagen2026-10-10 09:17 UTC958
        && let (Some(upgrade), Some(grip)) = (upgrade, wanted)
dfa38cf4cd4batagen2026-10-10 09:17 UTC959
    {
dfa38cf4cd4batagen2026-10-10 09:17 UTC960
        tokio::spawn(async move {
dfa38cf4cd4batagen2026-10-10 09:17 UTC961
            if let Ok(upgraded) = upgrade.await {
dfa38cf4cd4batagen2026-10-10 09:17 UTC962
                let mut io = TokioIo::new(upgraded);
dfa38cf4cd4batagen2026-10-10 09:17 UTC963
                let line = format!("You are now jelqing {}.\r\n", grip.name());
dfa38cf4cd4batagen2026-10-10 09:17 UTC964
                let _ = io.write_all(line.as_bytes()).await;
dfa38cf4cd4batagen2026-10-10 09:17 UTC965
                let _ = io.shutdown().await;
dfa38cf4cd4batagen2026-10-10 09:17 UTC966
            }
dfa38cf4cd4batagen2026-10-10 09:17 UTC967
        });
dfa38cf4cd4batagen2026-10-10 09:17 UTC968
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC969
    response
dfa38cf4cd4batagen2026-10-10 09:17 UTC970
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC971
a9b016e4b248atagen2026-10-10 08:12 UTC972
async fn diagnosis(
a9b016e4b248atagen2026-10-10 08:12 UTC973
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC974
    Path(id): Path<String>,
a9b016e4b248atagen2026-10-10 08:12 UTC975
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC976
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC977
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC978
    const ALLOW: &str = "GET, HEAD, OPTIONS";
dfa38cf4cd4batagen2026-10-10 09:17 UTC979
    with_run(&state, &id, &headers, Scope::Pockets, |run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC980
        match method.as_str() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC981
            "GET" | "HEAD" => respond(run.diagnose(
dfa38cf4cd4batagen2026-10-10 09:17 UTC982
                header_str(&headers, header::AUTHORIZATION),
dfa38cf4cd4batagen2026-10-10 09:17 UTC983
                header_str(&headers, header::IF_NONE_MATCH),
dfa38cf4cd4batagen2026-10-10 09:17 UTC984
            )),
dfa38cf4cd4batagen2026-10-10 09:17 UTC985
            "OPTIONS" => options(ALLOW, false),
dfa38cf4cd4batagen2026-10-10 09:17 UTC986
            _ => unsupported(&method, ALLOW),
dfa38cf4cd4batagen2026-10-10 09:17 UTC987
        }
a9b016e4b248atagen2026-10-10 08:12 UTC988
    })
a9b016e4b248atagen2026-10-10 08:12 UTC989
}
a9b016e4b248atagen2026-10-10 08:12 UTC990
dfa38cf4cd4batagen2026-10-10 09:17 UTC991
async fn thread(Path(year): Path<String>, method: Method) -> Response {
dfa38cf4cd4batagen2026-10-10 09:17 UTC992
    if method != Method::GET && method != Method::HEAD {
dfa38cf4cd4batagen2026-10-10 09:17 UTC993
        return unsupported(&method, "GET, HEAD");
dfa38cf4cd4batagen2026-10-10 09:17 UTC994
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC995
    match year.parse::<u32>() {
dfa38cf4cd4batagen2026-10-10 09:17 UTC996
        Ok(2009) => (
dfa38cf4cd4batagen2026-10-10 09:17 UTC997
            [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)],
dfa38cf4cd4batagen2026-10-10 09:17 UTC998
            Json(forum_thread()),
dfa38cf4cd4batagen2026-10-10 09:17 UTC999
        )
dfa38cf4cd4batagen2026-10-10 09:17 UTC1000
            .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC1001
        Ok(y) if y > 2009 => Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC1002
            StatusCode::NOT_FOUND,
dfa38cf4cd4batagen2026-10-10 09:17 UTC1003
            "no-thread",
dfa38cf4cd4batagen2026-10-10 09:17 UTC1004
            "Nobody has posted since 2009.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC1005
        )
dfa38cf4cd4batagen2026-10-10 09:17 UTC1006
        .with("thread", FORUM_THREAD)
dfa38cf4cd4batagen2026-10-10 09:17 UTC1007
        .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC1008
        _ => Problem::new(
dfa38cf4cd4batagen2026-10-10 09:17 UTC1009
            StatusCode::NOT_FOUND,
dfa38cf4cd4batagen2026-10-10 09:17 UTC1010
            "no-thread",
dfa38cf4cd4batagen2026-10-10 09:17 UTC1011
            "The forum did not exist yet. The problem did.",
dfa38cf4cd4batagen2026-10-10 09:17 UTC1012
        )
dfa38cf4cd4batagen2026-10-10 09:17 UTC1013
        .with("thread", FORUM_THREAD)
dfa38cf4cd4batagen2026-10-10 09:17 UTC1014
        .into_response(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC1015
    }
dfa38cf4cd4batagen2026-10-10 09:17 UTC1016
}
dfa38cf4cd4batagen2026-10-10 09:17 UTC1017
a9b016e4b248atagen2026-10-10 08:12 UTC1018
async fn graveyard(State(state): State<Shared>, method: Method) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC1019
    if method != Method::GET && method != Method::HEAD {
a9b016e4b248atagen2026-10-10 08:12 UTC1020
        return unsupported(&method, "GET, HEAD");
a9b016e4b248atagen2026-10-10 08:12 UTC1021
    }
a9b016e4b248atagen2026-10-10 08:12 UTC1022
    let runs = state.runs.lock().unwrap();
a9b016e4b248atagen2026-10-10 08:12 UTC1023
    let dead: Vec<Value> = runs
a9b016e4b248atagen2026-10-10 08:12 UTC1024
        .values()
a9b016e4b248atagen2026-10-10 08:12 UTC1025
        .filter_map(|run| {
a9b016e4b248atagen2026-10-10 08:12 UTC1026
            let ending = run.ended.as_ref()?;
a9b016e4b248atagen2026-10-10 08:12 UTC1027
            Some(json!({
a9b016e4b248atagen2026-10-10 08:12 UTC1028
                "id": run.id,
a9b016e4b248atagen2026-10-10 08:12 UTC1029
                "cause": format!("{PROBLEM_BASE}{}", ending.cause),
a9b016e4b248atagen2026-10-10 08:12 UTC1030
                "detail": ending.detail,
a9b016e4b248atagen2026-10-10 08:12 UTC1031
                "turn": ending.turn,
a9b016e4b248atagen2026-10-10 08:12 UTC1032
            }))
a9b016e4b248atagen2026-10-10 08:12 UTC1033
        })
a9b016e4b248atagen2026-10-10 08:12 UTC1034
        .collect();
a9b016e4b248atagen2026-10-10 08:12 UTC1035
    Json(json!({ "runs": dead })).into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC1036
}
a9b016e4b248atagen2026-10-10 08:12 UTC1037
a9b016e4b248atagen2026-10-10 08:12 UTC1038
async fn leaderboard(
a9b016e4b248atagen2026-10-10 08:12 UTC1039
    State(state): State<Shared>,
a9b016e4b248atagen2026-10-10 08:12 UTC1040
    method: Method,
a9b016e4b248atagen2026-10-10 08:12 UTC1041
    Query(query): Query<HashMap<String, String>>,
a9b016e4b248atagen2026-10-10 08:12 UTC1042
    headers: HeaderMap,
a9b016e4b248atagen2026-10-10 08:12 UTC1043
) -> Response {
a9b016e4b248atagen2026-10-10 08:12 UTC1044
    if method != Method::GET && method != Method::HEAD {
a9b016e4b248atagen2026-10-10 08:12 UTC1045
        return unsupported(&method, "GET, HEAD");
a9b016e4b248atagen2026-10-10 08:12 UTC1046
    }
a9b016e4b248atagen2026-10-10 08:12 UTC1047
    let truthful = match query.get("sort").map(String::as_str) {
a9b016e4b248atagen2026-10-10 08:12 UTC1048
        None | Some("self-reported-length") => false,
99524cf9f17catagen2026-10-10 10:53 UTC1049
        Some("content-length") => match cookie(&headers, "ruler") {
99524cf9f17catagen2026-10-10 10:53 UTC1050
            Some(ruler) if secret::verify("ruler", ruler) == Some("1") => true,
99524cf9f17catagen2026-10-10 10:53 UTC1051
            Some(_) => {
99524cf9f17catagen2026-10-10 10:53 UTC1052
                return Problem::new(
99524cf9f17catagen2026-10-10 10:53 UTC1053
                    StatusCode::BAD_REQUEST,
99524cf9f17catagen2026-10-10 10:53 UTC1054
                    "exaggeration",
99524cf9f17catagen2026-10-10 10:53 UTC1055
                    "The server has measured. Lying is not a technique.",
99524cf9f17catagen2026-10-10 10:53 UTC1056
                )
99524cf9f17catagen2026-10-10 10:53 UTC1057
                .with("cookie", "ruler")
99524cf9f17catagen2026-10-10 10:53 UTC1058
                .into_response();
99524cf9f17catagen2026-10-10 10:53 UTC1059
            }
99524cf9f17catagen2026-10-10 10:53 UTC1060
            None => {
99524cf9f17catagen2026-10-10 10:53 UTC1061
                return Problem::new(
99524cf9f17catagen2026-10-10 10:53 UTC1062
                    StatusCode::FORBIDDEN,
99524cf9f17catagen2026-10-10 10:53 UTC1063
                    "ruler-required",
99524cf9f17catagen2026-10-10 10:53 UTC1064
                    "Sorting by the truth requires a Ruler.",
99524cf9f17catagen2026-10-10 10:53 UTC1065
                )
99524cf9f17catagen2026-10-10 10:53 UTC1066
                .into_response();
99524cf9f17catagen2026-10-10 10:53 UTC1067
            }
99524cf9f17catagen2026-10-10 10:53 UTC1068
        },
a9b016e4b248atagen2026-10-10 08:12 UTC1069
        Some(_) => {
a9b016e4b248atagen2026-10-10 08:12 UTC1070
            return Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC1071
                StatusCode::BAD_REQUEST,
a9b016e4b248atagen2026-10-10 08:12 UTC1072
                "unknown-sort",
a9b016e4b248atagen2026-10-10 08:12 UTC1073
                "The leaderboard sorts by self-reported-length or content-length.",
a9b016e4b248atagen2026-10-10 08:12 UTC1074
            )
a9b016e4b248atagen2026-10-10 08:12 UTC1075
            .into_response();
a9b016e4b248atagen2026-10-10 08:12 UTC1076
        }
a9b016e4b248atagen2026-10-10 08:12 UTC1077
    };
99524cf9f17catagen2026-10-10 10:53 UTC1078
    let include_seeded = query.get("seeded").is_some_and(|s| s == "include");
a9b016e4b248atagen2026-10-10 08:12 UTC1079
    let runs = state.runs.lock().unwrap();
99524cf9f17catagen2026-10-10 10:53 UTC1080
    let mut entries: Vec<&Run> = runs
99524cf9f17catagen2026-10-10 10:53 UTC1081
        .values()
99524cf9f17catagen2026-10-10 10:53 UTC1082
        .filter(|run| include_seeded || !run.seeded)
99524cf9f17catagen2026-10-10 10:53 UTC1083
        .collect();
dfa38cf4cd4batagen2026-10-10 09:17 UTC1084
    let key = |r: &Run| {
dfa38cf4cd4batagen2026-10-10 09:17 UTC1085
        if truthful {
dfa38cf4cd4batagen2026-10-10 09:17 UTC1086
            r.current_length()
dfa38cf4cd4batagen2026-10-10 09:17 UTC1087
        } else {
dfa38cf4cd4batagen2026-10-10 09:17 UTC1088
            r.self_reported()
dfa38cf4cd4batagen2026-10-10 09:17 UTC1089
        }
dfa38cf4cd4batagen2026-10-10 09:17 UTC1090
    };
dfa38cf4cd4batagen2026-10-10 09:17 UTC1091
    entries.sort_by(|a, b| key(b).total_cmp(&key(a)));
a9b016e4b248atagen2026-10-10 08:12 UTC1092
    let entries: Vec<Value> = entries
a9b016e4b248atagen2026-10-10 08:12 UTC1093
        .into_iter()
a9b016e4b248atagen2026-10-10 08:12 UTC1094
        .map(|run| {
a9b016e4b248atagen2026-10-10 08:12 UTC1095
            let mut entry = json!({
a9b016e4b248atagen2026-10-10 08:12 UTC1096
                "id": run.id,
a9b016e4b248atagen2026-10-10 08:12 UTC1097
                "status": run.status(),
dfa38cf4cd4batagen2026-10-10 09:17 UTC1098
                "floor": run.floor.number(),
a9b016e4b248atagen2026-10-10 08:12 UTC1099
                "self_reported_length": (run.self_reported() * 100.0).round() / 100.0,
99524cf9f17catagen2026-10-10 10:53 UTC1100
                "ranked": !run.seeded,
a9b016e4b248atagen2026-10-10 08:12 UTC1101
            });
a9b016e4b248atagen2026-10-10 08:12 UTC1102
            if truthful {
dfa38cf4cd4batagen2026-10-10 09:17 UTC1103
                entry["content_length"] = json!(millimetres(run.current_length()));
a9b016e4b248atagen2026-10-10 08:12 UTC1104
            }
a9b016e4b248atagen2026-10-10 08:12 UTC1105
            entry
a9b016e4b248atagen2026-10-10 08:12 UTC1106
        })
a9b016e4b248atagen2026-10-10 08:12 UTC1107
        .collect();
a9b016e4b248atagen2026-10-10 08:12 UTC1108
    Json(json!({ "entries": entries })).into_response()
a9b016e4b248atagen2026-10-10 08:12 UTC1109
}
a9b016e4b248atagen2026-10-10 08:12 UTC1110
99524cf9f17catagen2026-10-10 10:53 UTC1111
async fn index(method: Method, headers: HeaderMap) -> Response {
99524cf9f17catagen2026-10-10 10:53 UTC1112
    if method != Method::GET && method != Method::HEAD {
99524cf9f17catagen2026-10-10 10:53 UTC1113
        return unsupported(&method, "GET, HEAD");
99524cf9f17catagen2026-10-10 10:53 UTC1114
    }
99524cf9f17catagen2026-10-10 10:53 UTC1115
    let wants_html =
99524cf9f17catagen2026-10-10 10:53 UTC1116
        header_str(&headers, header::ACCEPT).is_some_and(|accept| accept.contains("text/html"));
99524cf9f17catagen2026-10-10 10:53 UTC1117
    if wants_html {
99524cf9f17catagen2026-10-10 10:53 UTC1118
        return (
99524cf9f17catagen2026-10-10 10:53 UTC1119
            StatusCode::SEE_OTHER,
99524cf9f17catagen2026-10-10 10:53 UTC1120
            [(header::LOCATION, "/ui/"), (header::VARY, "Accept")],
99524cf9f17catagen2026-10-10 10:53 UTC1121
        )
99524cf9f17catagen2026-10-10 10:53 UTC1122
            .into_response();
99524cf9f17catagen2026-10-10 10:53 UTC1123
    }
99524cf9f17catagen2026-10-10 10:53 UTC1124
    (
99524cf9f17catagen2026-10-10 10:53 UTC1125
        [(header::VARY, "Accept")],
99524cf9f17catagen2026-10-10 10:53 UTC1126
        Json(json!({
99524cf9f17catagen2026-10-10 10:53 UTC1127
            "detail": "This is jelqd. It is a roguelike. It is played over HTTP.",
99524cf9f17catagen2026-10-10 10:53 UTC1128
            "start": { "href": "/runs", "method": "POST" },
99524cf9f17catagen2026-10-10 10:53 UTC1129
            "leaderboard": "/leaderboard",
99524cf9f17catagen2026-10-10 10:53 UTC1130
            "graveyard": "/graveyard",
99524cf9f17catagen2026-10-10 10:53 UTC1131
            "forum": FORUM_THREAD,
99524cf9f17catagen2026-10-10 10:53 UTC1132
            "ui": "/ui/",
99524cf9f17catagen2026-10-10 10:53 UTC1133
        })),
99524cf9f17catagen2026-10-10 10:53 UTC1134
    )
99524cf9f17catagen2026-10-10 10:53 UTC1135
        .into_response()
99524cf9f17catagen2026-10-10 10:53 UTC1136
}
99524cf9f17catagen2026-10-10 10:53 UTC1137
99524cf9f17catagen2026-10-10 10:53 UTC1138
async fn ui(method: Method) -> Response {
99524cf9f17catagen2026-10-10 10:53 UTC1139
    if method != Method::GET && method != Method::HEAD {
99524cf9f17catagen2026-10-10 10:53 UTC1140
        return unsupported(&method, "GET, HEAD");
99524cf9f17catagen2026-10-10 10:53 UTC1141
    }
99524cf9f17catagen2026-10-10 10:53 UTC1142
    Html(include_str!("ui.html")).into_response()
99524cf9f17catagen2026-10-10 10:53 UTC1143
}
99524cf9f17catagen2026-10-10 10:53 UTC1144
99524cf9f17catagen2026-10-10 10:53 UTC1145
#[derive(Deserialize)]
99524cf9f17catagen2026-10-10 10:53 UTC1146
struct Envelope {
99524cf9f17catagen2026-10-10 10:53 UTC1147
    method: String,
99524cf9f17catagen2026-10-10 10:53 UTC1148
    path: String,
99524cf9f17catagen2026-10-10 10:53 UTC1149
    #[serde(default)]
99524cf9f17catagen2026-10-10 10:53 UTC1150
    headers: Vec<(String, String)>,
99524cf9f17catagen2026-10-10 10:53 UTC1151
    #[serde(default)]
99524cf9f17catagen2026-10-10 10:53 UTC1152
    body: String,
99524cf9f17catagen2026-10-10 10:53 UTC1153
}
99524cf9f17catagen2026-10-10 10:53 UTC1154
99524cf9f17catagen2026-10-10 10:53 UTC1155
fn bad_envelope(detail: &str) -> Response {
99524cf9f17catagen2026-10-10 10:53 UTC1156
    Problem::new(StatusCode::BAD_REQUEST, "bad-envelope", detail).into_response()
99524cf9f17catagen2026-10-10 10:53 UTC1157
}
99524cf9f17catagen2026-10-10 10:53 UTC1158
99524cf9f17catagen2026-10-10 10:53 UTC1159
// Browsers hide 3xx responses from fetch and refuse TRACE, Upgrade, and Expect, so the page
99524cf9f17catagen2026-10-10 10:53 UTC1160
// replays requests in-process to show the player exactly what the API said.
99524cf9f17catagen2026-10-10 10:53 UTC1161
async fn ui_send(State(api): State<Router>, headers: HeaderMap, body: Bytes) -> Response {
99524cf9f17catagen2026-10-10 10:53 UTC1162
    let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
99524cf9f17catagen2026-10-10 10:53 UTC1163
        return bad_envelope("The envelope could not be parsed.");
99524cf9f17catagen2026-10-10 10:53 UTC1164
    };
99524cf9f17catagen2026-10-10 10:53 UTC1165
    if !envelope.path.starts_with('/') || envelope.path.starts_with("/ui") {
99524cf9f17catagen2026-10-10 10:53 UTC1166
        return bad_envelope("The path must be an API path.");
99524cf9f17catagen2026-10-10 10:53 UTC1167
    }
99524cf9f17catagen2026-10-10 10:53 UTC1168
    let Ok(method) = Method::from_bytes(envelope.method.as_bytes()) else {
99524cf9f17catagen2026-10-10 10:53 UTC1169
        return bad_envelope("That is not a method.");
99524cf9f17catagen2026-10-10 10:53 UTC1170
    };
99524cf9f17catagen2026-10-10 10:53 UTC1171
    let mut request = Request::builder().method(method).uri(&envelope.path);
99524cf9f17catagen2026-10-10 10:53 UTC1172
    for (name, value) in envelope.headers.iter().filter(|(_, v)| !v.is_empty()) {
99524cf9f17catagen2026-10-10 10:53 UTC1173
        request = request.header(name, value);
99524cf9f17catagen2026-10-10 10:53 UTC1174
    }
99524cf9f17catagen2026-10-10 10:53 UTC1175
    for cookie in headers.get_all(header::COOKIE) {
99524cf9f17catagen2026-10-10 10:53 UTC1176
        request = request.header(header::COOKIE, cookie);
99524cf9f17catagen2026-10-10 10:53 UTC1177
    }
99524cf9f17catagen2026-10-10 10:53 UTC1178
    let Ok(request) = request.body(Body::from(envelope.body)) else {
99524cf9f17catagen2026-10-10 10:53 UTC1179
        return bad_envelope("The request could not be built. A header may be malformed.");
99524cf9f17catagen2026-10-10 10:53 UTC1180
    };
99524cf9f17catagen2026-10-10 10:53 UTC1181
    let Ok(response) = api.oneshot(request).await;
99524cf9f17catagen2026-10-10 10:53 UTC1182
    let (parts, body) = response.into_parts();
99524cf9f17catagen2026-10-10 10:53 UTC1183
    let bytes = axum::body::to_bytes(body, 1 << 20)
99524cf9f17catagen2026-10-10 10:53 UTC1184
        .await
99524cf9f17catagen2026-10-10 10:53 UTC1185
        .unwrap_or_default();
99524cf9f17catagen2026-10-10 10:53 UTC1186
    let listed: Vec<(String, String)> = parts
99524cf9f17catagen2026-10-10 10:53 UTC1187
        .headers
99524cf9f17catagen2026-10-10 10:53 UTC1188
        .iter()
99524cf9f17catagen2026-10-10 10:53 UTC1189
        .map(|(name, value)| {
99524cf9f17catagen2026-10-10 10:53 UTC1190
            (
99524cf9f17catagen2026-10-10 10:53 UTC1191
                name.to_string(),
99524cf9f17catagen2026-10-10 10:53 UTC1192
                value.to_str().unwrap_or("<binary>").to_owned(),
99524cf9f17catagen2026-10-10 10:53 UTC1193
            )
99524cf9f17catagen2026-10-10 10:53 UTC1194
        })
99524cf9f17catagen2026-10-10 10:53 UTC1195
        .collect();
99524cf9f17catagen2026-10-10 10:53 UTC1196
    let mut response = Json(json!({
99524cf9f17catagen2026-10-10 10:53 UTC1197
        "status": parts.status.as_u16(),
99524cf9f17catagen2026-10-10 10:53 UTC1198
        "reason": reason(parts.status),
99524cf9f17catagen2026-10-10 10:53 UTC1199
        "headers": listed,
99524cf9f17catagen2026-10-10 10:53 UTC1200
        "body": String::from_utf8_lossy(&bytes),
99524cf9f17catagen2026-10-10 10:53 UTC1201
    }))
99524cf9f17catagen2026-10-10 10:53 UTC1202
    .into_response();
99524cf9f17catagen2026-10-10 10:53 UTC1203
    for cookie in parts.headers.get_all(header::SET_COOKIE) {
99524cf9f17catagen2026-10-10 10:53 UTC1204
        response
99524cf9f17catagen2026-10-10 10:53 UTC1205
            .headers_mut()
99524cf9f17catagen2026-10-10 10:53 UTC1206
            .append(header::SET_COOKIE, cookie.clone());
99524cf9f17catagen2026-10-10 10:53 UTC1207
    }
99524cf9f17catagen2026-10-10 10:53 UTC1208
    response
99524cf9f17catagen2026-10-10 10:53 UTC1209
}
99524cf9f17catagen2026-10-10 10:53 UTC1210
a9b016e4b248atagen2026-10-10 08:12 UTC1211
async fn not_found() -> Problem {
a9b016e4b248atagen2026-10-10 08:12 UTC1212
    Problem::new(
a9b016e4b248atagen2026-10-10 08:12 UTC1213
        StatusCode::NOT_FOUND,
a9b016e4b248atagen2026-10-10 08:12 UTC1214
        "not-found",
a9b016e4b248atagen2026-10-10 08:12 UTC1215
        "The resource does not exist. This is not about you.",
a9b016e4b248atagen2026-10-10 08:12 UTC1216
    )
a9b016e4b248atagen2026-10-10 08:12 UTC1217
}