an http based roguelike rpg about jelqing
| 1 | use std::{ |
| 2 | collections::HashMap, |
| 3 | convert::Infallible, |
| 4 | fmt::Write as _, |
| 5 | io::Write as _, |
| 6 | sync::{Arc, Mutex}, |
| 7 | time::Instant, |
| 8 | }; |
| 9 | |
| 10 | use axum::{ |
| 11 | Json, Router, |
| 12 | body::{Body, Bytes}, |
| 13 | extract::{Path, Query, Request, State}, |
| 14 | http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header}, |
| 15 | middleware::{self, Next}, |
| 16 | response::{Html, IntoResponse, Redirect, Response}, |
| 17 | routing::any, |
| 18 | }; |
| 19 | use flate2::{Compression, write::GzEncoder}; |
| 20 | use hyper_util::rt::TokioIo; |
| 21 | use rand::Rng; |
| 22 | use serde::Deserialize; |
| 23 | use serde_json::{Value, json}; |
| 24 | use tokio::io::AsyncWriteExt; |
| 25 | use tower::ServiceExt; |
| 26 | |
| 27 | use crate::{ |
| 28 | game::{ |
| 29 | ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH, |
| 30 | PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft, |
| 31 | }, |
| 32 | problem::{PROBLEM_BASE, Problem, reason}, |
| 33 | secret, |
| 34 | }; |
| 35 | |
| 36 | const REL_BASE: &str = "https://jelq.dev/rels/"; |
| 37 | const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict"; |
| 38 | 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."; |
| 39 | const MAX_URI: usize = 512; |
| 40 | const MAX_HEADER: usize = 1024; |
| 41 | |
| 42 | #[derive(Default)] |
| 43 | pub struct AppState { |
| 44 | runs: Mutex<HashMap<String, Run>>, |
| 45 | } |
| 46 | |
| 47 | impl AppState { |
| 48 | pub fn insert(&self, run: Run) { |
| 49 | self.runs.lock().unwrap().insert(run.id.clone(), run); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | type Shared = Arc<AppState>; |
| 54 | |
| 55 | #[derive(Clone)] |
| 56 | struct Compress; |
| 57 | |
| 58 | /// Which household hazards stand between the request and the run. |
| 59 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 60 | enum Scope { |
| 61 | Home, |
| 62 | Pockets, |
| 63 | } |
| 64 | |
| 65 | pub fn app() -> Router { |
| 66 | router(Arc::default()) |
| 67 | } |
| 68 | |
| 69 | pub fn router(state: Shared) -> Router { |
| 70 | let api = api(state); |
| 71 | let ui = Router::new() |
| 72 | .route("/ui", any(|| async { Redirect::permanent("/ui/") })) |
| 73 | .route("/ui/", any(ui)) |
| 74 | .route("/ui/send", any(ui_send)) |
| 75 | .with_state(api.clone()); |
| 76 | api.merge(ui) |
| 77 | } |
| 78 | |
| 79 | fn api(state: Shared) -> Router { |
| 80 | Router::new() |
| 81 | .route("/", any(index)) |
| 82 | .route("/runs", any(runs)) |
| 83 | .route("/runs/{id}", any(run)) |
| 84 | .route("/runs/{id}/length", any(length)) |
| 85 | .route("/runs/{id}/foreskin", any(foreskin)) |
| 86 | .route("/runs/{id}/inventory", any(inventory)) |
| 87 | .route("/runs/{id}/inventory/{item}", any(item)) |
| 88 | .route("/runs/{id}/techniques", any(techniques)) |
| 89 | .route("/runs/{id}/drawer", any(drawer)) |
| 90 | .route("/runs/{id}/branches/{choice}", any(branch)) |
| 91 | .route("/runs/{id}/floor", any(floor)) |
| 92 | .route("/runs/{id}/shop", any(shop)) |
| 93 | .route("/runs/{id}/shop/{item}", any(buy)) |
| 94 | .route("/runs/{id}/portal", any(portal)) |
| 95 | .route("/runs/{id}/elder", any(elder)) |
| 96 | .route("/runs/{id}/grip", any(grip)) |
| 97 | .route("/runs/{id}/diagnosis", any(diagnosis)) |
| 98 | .route("/forum/threads/{year}", any(thread)) |
| 99 | .route("/graveyard", any(graveyard)) |
| 100 | .route("/leaderboard", any(leaderboard)) |
| 101 | .fallback(not_found) |
| 102 | .layer(middleware::from_fn(chrome)) |
| 103 | .with_state(state) |
| 104 | } |
| 105 | |
| 106 | fn reject(request: &Request) -> Option<Problem> { |
| 107 | if request.version() < Version::HTTP_11 { |
| 108 | return Some(Problem::new( |
| 109 | StatusCode::HTTP_VERSION_NOT_SUPPORTED, |
| 110 | "pdf", |
| 111 | "HTTP/1.0 is the version the original jelqing PDF was written for. The server has read it.", |
| 112 | )); |
| 113 | } |
| 114 | if request.uri().to_string().len() > MAX_URI { |
| 115 | return Some(Problem::new( |
| 116 | StatusCode::URI_TOO_LONG, |
| 117 | "uri-too-long", |
| 118 | "The URI is longer than anything else here.", |
| 119 | )); |
| 120 | } |
| 121 | if request.headers().values().any(|v| v.len() > MAX_HEADER) { |
| 122 | return Some(Problem::new( |
| 123 | StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE, |
| 124 | "head-too-large", |
| 125 | "Head too large.", |
| 126 | )); |
| 127 | } |
| 128 | let expectation = request.headers().get(header::EXPECT)?; |
| 129 | if expectation.as_bytes().eq_ignore_ascii_case(b"100-continue") { |
| 130 | return None; |
| 131 | } |
| 132 | Some(Problem::new( |
| 133 | StatusCode::EXPECTATION_FAILED, |
| 134 | "expectations", |
| 135 | format!( |
| 136 | "Expect: {} cannot be met.", |
| 137 | expectation.to_str().unwrap_or("that") |
| 138 | ), |
| 139 | )) |
| 140 | } |
| 141 | |
| 142 | async fn compress(response: Response) -> Response { |
| 143 | let (mut parts, body) = response.into_parts(); |
| 144 | let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else { |
| 145 | return Response::from_parts(parts, Body::empty()); |
| 146 | }; |
| 147 | let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); |
| 148 | if encoder.write_all(&bytes).is_err() { |
| 149 | return Response::from_parts(parts, Body::from(bytes)); |
| 150 | } |
| 151 | let Ok(gzipped) = encoder.finish() else { |
| 152 | return Response::from_parts(parts, Body::from(bytes)); |
| 153 | }; |
| 154 | parts |
| 155 | .headers |
| 156 | .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); |
| 157 | parts.headers.remove(header::CONTENT_LENGTH); |
| 158 | parts |
| 159 | .headers |
| 160 | .append(header::VARY, HeaderValue::from_static("Accept-Encoding")); |
| 161 | Response::from_parts(parts, Body::from(gzipped)) |
| 162 | } |
| 163 | |
| 164 | async fn chrome(request: Request, next: Next) -> Response { |
| 165 | let gzip = header_str(request.headers(), header::ACCEPT_ENCODING) |
| 166 | .is_some_and(|e| e.to_ascii_lowercase().contains("gzip")); |
| 167 | let mut response = match reject(&request) { |
| 168 | Some(problem) => problem.into_response(), |
| 169 | None => next.run(request).await, |
| 170 | }; |
| 171 | if gzip && response.extensions().get::<Compress>().is_some() { |
| 172 | response = compress(response).await; |
| 173 | } |
| 174 | let headers = response.headers_mut(); |
| 175 | headers.insert(header::SERVER, HeaderValue::from_static("jelqd/0.6.9")); |
| 176 | headers.insert("x-powered-by", HeaderValue::from_static("blood")); |
| 177 | headers.insert( |
| 178 | header::CACHE_CONTROL, |
| 179 | HeaderValue::from_static("no-store, private"), |
| 180 | ); |
| 181 | response |
| 182 | } |
| 183 | |
| 184 | fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> { |
| 185 | headers.get(name).and_then(|v| v.to_str().ok()) |
| 186 | } |
| 187 | |
| 188 | fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { |
| 189 | headers |
| 190 | .get_all(header::COOKIE) |
| 191 | .iter() |
| 192 | .filter_map(|v| v.to_str().ok()) |
| 193 | .flat_map(|v| v.split(';')) |
| 194 | .filter_map(|pair| pair.trim().split_once('=')) |
| 195 | .find(|(key, _)| *key == name) |
| 196 | .map(|(_, value)| value) |
| 197 | } |
| 198 | |
| 199 | fn unsupported(method: &Method, allow: &'static str) -> Response { |
| 200 | if *method == Method::CONNECT { |
| 201 | return StatusCode::NOT_IMPLEMENTED.into_response(); |
| 202 | } |
| 203 | let known = matches!( |
| 204 | method.as_str(), |
| 205 | "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "TRACE" | "JELQ" |
| 206 | ); |
| 207 | if method.as_str() == "BREW" { |
| 208 | return Problem::new( |
| 209 | StatusCode::IM_A_TEAPOT, |
| 210 | "brew", |
| 211 | "The server is a teapot. RFC 2324 is clear on this. It is not yet short and stout.", |
| 212 | ) |
| 213 | .into_response(); |
| 214 | } |
| 215 | if method.as_str().eq_ignore_ascii_case("jelq") { |
| 216 | return Problem::new( |
| 217 | StatusCode::NOT_IMPLEMENTED, |
| 218 | "not-implemented", |
| 219 | format!( |
| 220 | "The server does not implement {method}. Methods are case-sensitive. JELQ is shouted for a reason." |
| 221 | ), |
| 222 | ) |
| 223 | .into_response(); |
| 224 | } |
| 225 | if !known { |
| 226 | return Problem::new( |
| 227 | StatusCode::NOT_IMPLEMENTED, |
| 228 | "not-implemented", |
| 229 | format!("The server does not implement {method}."), |
| 230 | ) |
| 231 | .into_response(); |
| 232 | } |
| 233 | Problem::new( |
| 234 | StatusCode::METHOD_NOT_ALLOWED, |
| 235 | "method-not-allowed", |
| 236 | format!("{method} is not supported here."), |
| 237 | ) |
| 238 | .header(header::ALLOW, allow) |
| 239 | .into_response() |
| 240 | } |
| 241 | |
| 242 | fn options(allow: &'static str, patchable: bool) -> Response { |
| 243 | let mut response = ( |
| 244 | StatusCode::OK, |
| 245 | [(header::ALLOW, allow)], |
| 246 | Json(json!({ "detail": THERAPY })), |
| 247 | ) |
| 248 | .into_response(); |
| 249 | if patchable { |
| 250 | response.headers_mut().insert( |
| 251 | "accept-patch", |
| 252 | HeaderValue::from_static("application/json-patch+json"), |
| 253 | ); |
| 254 | } |
| 255 | response |
| 256 | } |
| 257 | |
| 258 | fn trace(method: &Method, uri: &Uri, version: Version, headers: &HeaderMap) -> Response { |
| 259 | let mut message = format!("{method} {uri} {version:?}\r\n"); |
| 260 | for (name, value) in headers { |
| 261 | let _ = write!( |
| 262 | message, |
| 263 | "{name}: {}\r\n", |
| 264 | value.to_str().unwrap_or("<binary>") |
| 265 | ); |
| 266 | } |
| 267 | message.push_str("\r\n"); |
| 268 | ( |
| 269 | StatusCode::OK, |
| 270 | [(header::CONTENT_TYPE, "message/http")], |
| 271 | message, |
| 272 | ) |
| 273 | .into_response() |
| 274 | } |
| 275 | |
| 276 | fn links(run: &Run) -> String { |
| 277 | let id = &run.id; |
| 278 | let mut links = vec![format!("</runs/{id}>; rel=\"{REL_BASE}run\"")]; |
| 279 | for rel in [ |
| 280 | "floor", |
| 281 | "length", |
| 282 | "foreskin", |
| 283 | "inventory", |
| 284 | "techniques", |
| 285 | "drawer", |
| 286 | "grip", |
| 287 | "diagnosis", |
| 288 | ] { |
| 289 | links.push(format!("</runs/{id}/{rel}>; rel=\"{REL_BASE}{rel}\"")); |
| 290 | } |
| 291 | if run.floor.shop_open() { |
| 292 | links.push(format!("</runs/{id}/shop>; rel=\"{REL_BASE}shop\"")); |
| 293 | } |
| 294 | links.join(", ") |
| 295 | } |
| 296 | |
| 297 | fn finish(run: &Run, mut response: Response) -> Response { |
| 298 | let status = response.status(); |
| 299 | if status == StatusCode::NOT_MODIFIED { |
| 300 | return response; |
| 301 | } |
| 302 | let mut stats = vec![ |
| 303 | ( |
| 304 | "x-self-reported-length", |
| 305 | format!("{:.2}", run.self_reported()), |
| 306 | ), |
| 307 | ("x-girth", format!("{:.2}", run.girth)), |
| 308 | ( |
| 309 | "x-foreskin", |
| 310 | run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")), |
| 311 | ), |
| 312 | ("x-eq", run.eq.to_string()), |
| 313 | ("x-stamina", format!("{}/{MAX_STAMINA}", run.stamina)), |
| 314 | ( |
| 315 | "x-suspicion", |
| 316 | format!("{}/{SUSPICION_LIMIT}", run.suspicion), |
| 317 | ), |
| 318 | ("x-shame", run.total_shame().to_string()), |
| 319 | ("x-cash", run.cash.to_string()), |
| 320 | ("x-floor", run.floor.number().to_string()), |
| 321 | ("link", links(run)), |
| 322 | ]; |
| 323 | if let Some(observer) = run.observed { |
| 324 | stats.push(("x-observed-by", observer.into())); |
| 325 | } |
| 326 | if run.mom_present { |
| 327 | stats.push(("via", "1.1 mom".into())); |
| 328 | stats.push(("vary", "Mom".into())); |
| 329 | } |
| 330 | // RFC 9111 obsoleted Warning, which is why nobody heeds it. |
| 331 | if run.eq > 70 && run.ended.is_none() { |
| 332 | stats.push(("warning", "199 - \"it is turning purple\"".into())); |
| 333 | } |
| 334 | stats.push(( |
| 335 | "set-cookie", |
| 336 | format!( |
| 337 | "shame={}; {COOKIE_ATTRIBUTES}", |
| 338 | secret::sign("shame", &run.total_shame().to_string()) |
| 339 | ), |
| 340 | )); |
| 341 | if run.items.ruler { |
| 342 | stats.push(( |
| 343 | "set-cookie", |
| 344 | format!("ruler={}; {COOKIE_ATTRIBUTES}", secret::sign("ruler", "1")), |
| 345 | )); |
| 346 | } |
| 347 | let headers = response.headers_mut(); |
| 348 | for (name, value) in stats { |
| 349 | headers.append(name, HeaderValue::from_str(&value).expect("visible ascii")); |
| 350 | } |
| 351 | let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT; |
| 352 | if run.items.ring && !bodiless { |
| 353 | response.extensions_mut().insert(Compress); |
| 354 | } |
| 355 | response |
| 356 | } |
| 357 | |
| 358 | fn gone(run: &Run) -> Response { |
| 359 | let ending = run.ended.as_ref().expect("only called on ended runs"); |
| 360 | Problem::new(StatusCode::GONE, "run-ended", "This run has ended.") |
| 361 | .with("cause", format!("{PROBLEM_BASE}{}", ending.cause)) |
| 362 | .with("turn", ending.turn) |
| 363 | .with("graveyard", "/graveyard") |
| 364 | .into_response() |
| 365 | } |
| 366 | |
| 367 | fn captive_portal(run: &Run) -> Response { |
| 368 | Problem::new( |
| 369 | StatusCode::NETWORK_AUTHENTICATION_REQUIRED, |
| 370 | "captive-portal", |
| 371 | "The network requires authentication. The hotel would like you to agree to some terms.", |
| 372 | ) |
| 373 | .with("portal", format!("/runs/{}/portal", run.id)) |
| 374 | .header( |
| 375 | header::LINK, |
| 376 | format!("</runs/{}/portal>; rel=\"{REL_BASE}portal\"", run.id), |
| 377 | ) |
| 378 | .into_response() |
| 379 | } |
| 380 | |
| 381 | fn with_run( |
| 382 | state: &AppState, |
| 383 | id: &str, |
| 384 | headers: &HeaderMap, |
| 385 | scope: Scope, |
| 386 | handle: impl FnOnce(&mut Run) -> Response, |
| 387 | ) -> Response { |
| 388 | let mut runs = state.runs.lock().unwrap(); |
| 389 | let Some(run) = runs.get_mut(id) else { |
| 390 | return Problem::new( |
| 391 | StatusCode::NOT_FOUND, |
| 392 | "no-such-run", |
| 393 | format!("There is no run {id}. There may never have been."), |
| 394 | ) |
| 395 | .into_response(); |
| 396 | }; |
| 397 | if run.ended.is_some() { |
| 398 | return gone(run); |
| 399 | } |
| 400 | run.observed = None; |
| 401 | if scope == Scope::Home { |
| 402 | if run.floor == Floor::Hotel && !run.wifi_accepted { |
| 403 | return finish(run, captive_portal(run)); |
| 404 | } |
| 405 | if run.floor.has_mom() |
| 406 | && run.mom_present |
| 407 | && let Err(problem) = run.answer_door(header_str(headers, header::AUTHORIZATION)) |
| 408 | { |
| 409 | return finish(run, problem.into_response()); |
| 410 | } |
| 411 | } |
| 412 | let response = handle(run); |
| 413 | finish(run, response) |
| 414 | } |
| 415 | |
| 416 | fn respond<T: IntoResponse, E: IntoResponse>(result: Result<T, E>) -> Response { |
| 417 | match result { |
| 418 | Ok(ok) => ok.into_response(), |
| 419 | Err(err) => err.into_response(), |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | async fn runs( |
| 424 | State(state): State<Shared>, |
| 425 | method: Method, |
| 426 | Query(query): Query<HashMap<String, String>>, |
| 427 | headers: HeaderMap, |
| 428 | ) -> Response { |
| 429 | if method != Method::POST { |
| 430 | return unsupported(&method, "POST"); |
| 431 | } |
| 432 | let seeded = query.contains_key("seed"); |
| 433 | let seed = match query.get("seed").map(|s| s.parse::<u64>()) { |
| 434 | None => rand::rng().random(), |
| 435 | Some(Ok(seed)) => seed, |
| 436 | Some(Err(_)) => { |
| 437 | return Problem::new( |
| 438 | StatusCode::BAD_REQUEST, |
| 439 | "bad-seed", |
| 440 | "The seed must be an unsigned 64-bit integer.", |
| 441 | ) |
| 442 | .into_response(); |
| 443 | } |
| 444 | }; |
| 445 | let shame = cookie(&headers, "shame") |
| 446 | .and_then(|s| secret::verify("shame", s)) |
| 447 | .and_then(|s| s.parse().ok()) |
| 448 | .unwrap_or(0); |
| 449 | let id = format!("{:08x}", rand::rng().random::<u32>()); |
| 450 | let mut run = Run::new(id.clone(), secret::mix_seed(seed), shame); |
| 451 | run.seeded = seeded; |
| 452 | let response = ( |
| 453 | StatusCode::CREATED, |
| 454 | [ |
| 455 | (header::LOCATION, format!("/runs/{id}")), |
| 456 | (header::ETAG, run.etag()), |
| 457 | (HeaderName::from_static("x-seed"), seed.to_string()), |
| 458 | ], |
| 459 | Json(run.view()), |
| 460 | ) |
| 461 | .into_response(); |
| 462 | let response = finish(&run, response); |
| 463 | state.insert(run); |
| 464 | response |
| 465 | } |
| 466 | |
| 467 | #[allow(clippy::too_many_arguments)] |
| 468 | async fn run( |
| 469 | State(state): State<Shared>, |
| 470 | Path(id): Path<String>, |
| 471 | method: Method, |
| 472 | uri: Uri, |
| 473 | version: Version, |
| 474 | Query(query): Query<HashMap<String, String>>, |
| 475 | headers: HeaderMap, |
| 476 | body: Bytes, |
| 477 | ) -> Response { |
| 478 | let now = Instant::now(); |
| 479 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 480 | match method.as_str() { |
| 481 | "GET" | "HEAD" => { |
| 482 | let accept = header_str(&headers, header::ACCEPT).unwrap_or_default(); |
| 483 | if accept.contains("reassurance") { |
| 484 | return Problem::new( |
| 485 | StatusCode::NOT_ACCEPTABLE, |
| 486 | "no-reassurance", |
| 487 | "The server does not produce reassurance.", |
| 488 | ) |
| 489 | .into_response(); |
| 490 | } |
| 491 | ( |
| 492 | StatusCode::OK, |
| 493 | [(header::ETAG, run.etag())], |
| 494 | Json(run.view()), |
| 495 | ) |
| 496 | .into_response() |
| 497 | } |
| 498 | "JELQ" => jelq(run, &query, &headers, now), |
| 499 | "PUT" => run.put().into_response(), |
| 500 | "PATCH" => patch(run, &headers, &body), |
| 501 | "DELETE" => { |
| 502 | run.end("abandoned", "The run was abandoned."); |
| 503 | StatusCode::NO_CONTENT.into_response() |
| 504 | } |
| 505 | "OPTIONS" => options(RUN_ALLOW, true), |
| 506 | "TRACE" => { |
| 507 | run.shame += 1; |
| 508 | trace(&method, &uri, version, &headers) |
| 509 | } |
| 510 | _ => unsupported(&method, RUN_ALLOW), |
| 511 | } |
| 512 | }) |
| 513 | } |
| 514 | |
| 515 | fn jelq( |
| 516 | run: &mut Run, |
| 517 | query: &HashMap<String, String>, |
| 518 | headers: &HeaderMap, |
| 519 | now: Instant, |
| 520 | ) -> Response { |
| 521 | let techniques_href = format!("/runs/{}/techniques", run.id); |
| 522 | let Some(names) = query.get("technique").filter(|n| !n.trim().is_empty()) else { |
| 523 | return Problem::new( |
| 524 | StatusCode::BAD_REQUEST, |
| 525 | "technique-required", |
| 526 | "No technique was specified.", |
| 527 | ) |
| 528 | .with("techniques", techniques_href) |
| 529 | .into_response(); |
| 530 | }; |
| 531 | let mut techniques = Vec::new(); |
| 532 | for name in names.split(',').map(str::trim).filter(|n| !n.is_empty()) { |
| 533 | let Some(technique) = Technique::parse(name) else { |
| 534 | return Problem::new( |
| 535 | StatusCode::BAD_REQUEST, |
| 536 | "unknown-technique", |
| 537 | format!("{name} is not a technique."), |
| 538 | ) |
| 539 | .with("techniques", techniques_href) |
| 540 | .into_response(); |
| 541 | }; |
| 542 | techniques.push(technique); |
| 543 | } |
| 544 | let intensity = match query.get("intensity").map(|i| i.parse::<i32>()) { |
| 545 | None => 5, |
| 546 | Some(Ok(i)) if (1..=10).contains(&i) => i, |
| 547 | Some(_) => { |
| 548 | return Problem::new( |
| 549 | StatusCode::BAD_REQUEST, |
| 550 | "intensity-out-of-range", |
| 551 | "Intensity is measured from 1 to 10.", |
| 552 | ) |
| 553 | .into_response(); |
| 554 | } |
| 555 | }; |
| 556 | let lube = match Lube::from_content_type(header_str(headers, header::CONTENT_TYPE)) { |
| 557 | Ok(lube) => lube, |
| 558 | Err(problem) => return problem.into_response(), |
| 559 | }; |
| 560 | respond(run.jelq(&techniques, intensity, lube, now)) |
| 561 | } |
| 562 | |
| 563 | fn patch(run: &mut Run, headers: &HeaderMap, body: &[u8]) -> Response { |
| 564 | let content_type = header_str(headers, header::CONTENT_TYPE).unwrap_or_default(); |
| 565 | if !content_type.starts_with("application/json-patch+json") { |
| 566 | return Problem::new( |
| 567 | StatusCode::UNSUPPORTED_MEDIA_TYPE, |
| 568 | "patch-format", |
| 569 | "Patches are accepted as application/json-patch+json.", |
| 570 | ) |
| 571 | .header( |
| 572 | HeaderName::from_static("accept-patch"), |
| 573 | "application/json-patch+json", |
| 574 | ) |
| 575 | .into_response(); |
| 576 | } |
| 577 | match serde_json::from_slice::<Vec<PatchOp>>(body) { |
| 578 | Ok(ops) => respond(run.patch(&ops)), |
| 579 | Err(_) => Problem::new( |
| 580 | StatusCode::BAD_REQUEST, |
| 581 | "malformed-patch", |
| 582 | "The patch could not be parsed.", |
| 583 | ) |
| 584 | .into_response(), |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | fn millimetres(inches: f64) -> usize { |
| 589 | (inches * MM_PER_INCH).round() as usize |
| 590 | } |
| 591 | |
| 592 | fn parse_inch_range(range: &str, total: f64) -> Option<(f64, f64)> { |
| 593 | let (start, end) = range.strip_prefix("inches=")?.split_once('-')?; |
| 594 | let start: f64 = start.trim().parse().ok()?; |
| 595 | let end: f64 = match end.trim() { |
| 596 | "" => total, |
| 597 | end => end.parse().ok()?, |
| 598 | }; |
| 599 | (start >= 0.0 && start < total && start <= end).then_some((start, end.min(total))) |
| 600 | } |
| 601 | |
| 602 | fn length_response(run: &Run, headers: &HeaderMap) -> Response { |
| 603 | let inches = if run.items.ruler { |
| 604 | run.current_length() |
| 605 | } else { |
| 606 | run.self_reported() |
| 607 | }; |
| 608 | let body = shaft(millimetres(inches)); |
| 609 | let content_type = ( |
| 610 | header::CONTENT_TYPE, |
| 611 | "text/plain; charset=us-ascii".to_owned(), |
| 612 | ); |
| 613 | let accept_ranges = (header::ACCEPT_RANGES, "inches".to_owned()); |
| 614 | |
| 615 | if let Some(range) = header_str(headers, header::RANGE) |
| 616 | && range.starts_with("inches=") |
| 617 | { |
| 618 | let Some((start, end)) = parse_inch_range(range, inches) else { |
| 619 | return Problem::new( |
| 620 | StatusCode::RANGE_NOT_SATISFIABLE, |
| 621 | "range-not-satisfiable", |
| 622 | format!("The requested range is not available. {inches:.2} inches are."), |
| 623 | ) |
| 624 | .header(header::CONTENT_RANGE, format!("inches */{inches:.2}")) |
| 625 | .into_response(); |
| 626 | }; |
| 627 | let from = millimetres(start).min(body.len() - 1); |
| 628 | let to = millimetres(end).clamp(from + 1, body.len()); |
| 629 | let content_range = ( |
| 630 | header::CONTENT_RANGE, |
| 631 | format!("inches {start:.2}-{end:.2}/{inches:.2}"), |
| 632 | ); |
| 633 | return ( |
| 634 | StatusCode::PARTIAL_CONTENT, |
| 635 | [content_type, accept_ranges, content_range], |
| 636 | body[from..to].to_owned(), |
| 637 | ) |
| 638 | .into_response(); |
| 639 | } |
| 640 | |
| 641 | if run.items.ruler { |
| 642 | return (StatusCode::OK, [content_type, accept_ranges], body).into_response(); |
| 643 | } |
| 644 | let chunks: Vec<Result<Bytes, Infallible>> = body |
| 645 | .into_bytes() |
| 646 | .chunks(16) |
| 647 | .map(|c| Ok(Bytes::copy_from_slice(c))) |
| 648 | .collect(); |
| 649 | let body = Body::from_stream(futures_util::stream::iter(chunks)); |
| 650 | (StatusCode::OK, [content_type, accept_ranges], body).into_response() |
| 651 | } |
| 652 | |
| 653 | async fn length( |
| 654 | State(state): State<Shared>, |
| 655 | Path(id): Path<String>, |
| 656 | method: Method, |
| 657 | headers: HeaderMap, |
| 658 | ) -> Response { |
| 659 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 660 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 661 | match method.as_str() { |
| 662 | "GET" | "HEAD" => length_response(run, &headers), |
| 663 | "OPTIONS" => options(ALLOW, false), |
| 664 | _ => unsupported(&method, ALLOW), |
| 665 | } |
| 666 | }) |
| 667 | } |
| 668 | |
| 669 | async fn foreskin( |
| 670 | State(state): State<Shared>, |
| 671 | Path(id): Path<String>, |
| 672 | method: Method, |
| 673 | headers: HeaderMap, |
| 674 | ) -> Response { |
| 675 | const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS"; |
| 676 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 677 | match method.as_str() { |
| 678 | "GET" | "HEAD" => match run.foreskin { |
| 679 | Some(f) => ( |
| 680 | StatusCode::OK, |
| 681 | [(header::ETAG, run.etag())], |
| 682 | Json(json!({ "foreskin": (f * 100.0).round() / 100.0 })), |
| 683 | ) |
| 684 | .into_response(), |
| 685 | None => Problem::new( |
| 686 | StatusCode::GONE, |
| 687 | "foreskin-gone", |
| 688 | "The foreskin is gone. It will not be returning.", |
| 689 | ) |
| 690 | .into_response(), |
| 691 | }, |
| 692 | "DELETE" => respond(run.delete_foreskin(header_str(&headers, header::IF_MATCH))), |
| 693 | "OPTIONS" => options(ALLOW, false), |
| 694 | _ => unsupported(&method, ALLOW), |
| 695 | } |
| 696 | }) |
| 697 | } |
| 698 | |
| 699 | async fn inventory( |
| 700 | State(state): State<Shared>, |
| 701 | Path(id): Path<String>, |
| 702 | method: Method, |
| 703 | headers: HeaderMap, |
| 704 | ) -> Response { |
| 705 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 706 | with_run(&state, &id, &headers, Scope::Pockets, |run| { |
| 707 | match method.as_str() { |
| 708 | "GET" | "HEAD" => { |
| 709 | let listed: Vec<Value> = run |
| 710 | .inventory() |
| 711 | .into_iter() |
| 712 | .map(|(name, _)| { |
| 713 | json!({ "name": name, "href": format!("/runs/{}/inventory/{name}", run.id) }) |
| 714 | }) |
| 715 | .collect(); |
| 716 | Json(json!({ |
| 717 | "items": listed, |
| 718 | "ice_packs": run.items.ice_packs, |
| 719 | "cash": run.cash, |
| 720 | "excuses": run.excuses(), |
| 721 | })) |
| 722 | .into_response() |
| 723 | } |
| 724 | "OPTIONS" => options(ALLOW, false), |
| 725 | _ => unsupported(&method, ALLOW), |
| 726 | } |
| 727 | }) |
| 728 | } |
| 729 | |
| 730 | async fn item( |
| 731 | State(state): State<Shared>, |
| 732 | Path((id, name)): Path<(String, String)>, |
| 733 | method: Method, |
| 734 | headers: HeaderMap, |
| 735 | ) -> Response { |
| 736 | const ALLOW: &str = "GET, HEAD, DELETE, OPTIONS"; |
| 737 | with_run(&state, &id, &headers, Scope::Pockets, |run| { |
| 738 | let Some((name, description)) = run.inventory().into_iter().find(|(n, _)| *n == name) |
| 739 | else { |
| 740 | return Problem::new( |
| 741 | StatusCode::NOT_FOUND, |
| 742 | "no-such-item", |
| 743 | "You do not have that.", |
| 744 | ) |
| 745 | .into_response(); |
| 746 | }; |
| 747 | match method.as_str() { |
| 748 | "GET" | "HEAD" => { |
| 749 | Json(json!({ "name": name, "description": description })).into_response() |
| 750 | } |
| 751 | "DELETE" => respond(run.drop_item(name)), |
| 752 | "OPTIONS" => options(ALLOW, false), |
| 753 | _ => unsupported(&method, ALLOW), |
| 754 | } |
| 755 | }) |
| 756 | } |
| 757 | |
| 758 | async fn techniques( |
| 759 | State(state): State<Shared>, |
| 760 | Path(id): Path<String>, |
| 761 | method: Method, |
| 762 | headers: HeaderMap, |
| 763 | ) -> Response { |
| 764 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 765 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 766 | match method.as_str() { |
| 767 | "GET" | "HEAD" => { |
| 768 | let listed: Vec<Value> = Technique::ALL |
| 769 | .into_iter() |
| 770 | .map(|t| t.describe(run.forum_read)) |
| 771 | .collect(); |
| 772 | Json(json!({ |
| 773 | "usage": format!("JELQ /runs/{}?technique=<name>[,<name>]&intensity=<1-10>", run.id), |
| 774 | "lubricant": "Declared with Content-Type.", |
| 775 | "lubricants": LUBRICANTS, |
| 776 | "eq_band": [50, 70], |
| 777 | "techniques": listed, |
| 778 | })) |
| 779 | .into_response() |
| 780 | } |
| 781 | "OPTIONS" => options(ALLOW, false), |
| 782 | _ => unsupported(&method, ALLOW), |
| 783 | } |
| 784 | }) |
| 785 | } |
| 786 | |
| 787 | async fn drawer( |
| 788 | State(state): State<Shared>, |
| 789 | Path(id): Path<String>, |
| 790 | method: Method, |
| 791 | headers: HeaderMap, |
| 792 | ) -> Response { |
| 793 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 794 | match method.as_str() { |
| 795 | "POST" => respond(run.search_drawer()), |
| 796 | "OPTIONS" => options("POST, OPTIONS", false), |
| 797 | _ => unsupported(&method, "POST, OPTIONS"), |
| 798 | } |
| 799 | }) |
| 800 | } |
| 801 | |
| 802 | async fn branch( |
| 803 | State(state): State<Shared>, |
| 804 | Path((id, choice)): Path<(String, String)>, |
| 805 | method: Method, |
| 806 | headers: HeaderMap, |
| 807 | ) -> Response { |
| 808 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 809 | match method.as_str() { |
| 810 | "POST" => respond(run.choose(&choice)), |
| 811 | "OPTIONS" => options("POST, OPTIONS", false), |
| 812 | _ => unsupported(&method, "POST, OPTIONS"), |
| 813 | } |
| 814 | }) |
| 815 | } |
| 816 | |
| 817 | async fn floor( |
| 818 | State(state): State<Shared>, |
| 819 | Path(id): Path<String>, |
| 820 | method: Method, |
| 821 | headers: HeaderMap, |
| 822 | ) -> Response { |
| 823 | const ALLOW: &str = "GET, HEAD, POST, OPTIONS"; |
| 824 | with_run(&state, &id, &headers, Scope::Pockets, |run| { |
| 825 | match method.as_str() { |
| 826 | "GET" | "HEAD" => Json(run.floor_view()).into_response(), |
| 827 | "POST" => respond(run.advance()), |
| 828 | "OPTIONS" => options(ALLOW, false), |
| 829 | _ => unsupported(&method, ALLOW), |
| 830 | } |
| 831 | }) |
| 832 | } |
| 833 | |
| 834 | async fn shop( |
| 835 | State(state): State<Shared>, |
| 836 | Path(id): Path<String>, |
| 837 | method: Method, |
| 838 | headers: HeaderMap, |
| 839 | ) -> Response { |
| 840 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 841 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 842 | match method.as_str() { |
| 843 | "GET" | "HEAD" => respond(run.shop_view().map(Json)), |
| 844 | "OPTIONS" => options(ALLOW, false), |
| 845 | _ => unsupported(&method, ALLOW), |
| 846 | } |
| 847 | }) |
| 848 | } |
| 849 | |
| 850 | async fn buy( |
| 851 | State(state): State<Shared>, |
| 852 | Path((id, item)): Path<(String, String)>, |
| 853 | method: Method, |
| 854 | headers: HeaderMap, |
| 855 | ) -> Response { |
| 856 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 857 | match method.as_str() { |
| 858 | "POST" => respond(run.buy(&item)), |
| 859 | "OPTIONS" => options("POST, OPTIONS", false), |
| 860 | _ => unsupported(&method, "POST, OPTIONS"), |
| 861 | } |
| 862 | }) |
| 863 | } |
| 864 | |
| 865 | async fn portal( |
| 866 | State(state): State<Shared>, |
| 867 | Path(id): Path<String>, |
| 868 | method: Method, |
| 869 | headers: HeaderMap, |
| 870 | ) -> Response { |
| 871 | const ALLOW: &str = "GET, HEAD, POST, OPTIONS"; |
| 872 | with_run(&state, &id, &headers, Scope::Pockets, |run| { |
| 873 | match method.as_str() { |
| 874 | "GET" | "HEAD" => Json(json!({ |
| 875 | "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.", |
| 876 | "accept": { "href": format!("/runs/{}/portal", run.id), "method": "POST" }, |
| 877 | })) |
| 878 | .into_response(), |
| 879 | "POST" => respond(run.accept_terms()), |
| 880 | "OPTIONS" => options(ALLOW, false), |
| 881 | _ => unsupported(&method, ALLOW), |
| 882 | } |
| 883 | }) |
| 884 | } |
| 885 | |
| 886 | async fn elder( |
| 887 | State(state): State<Shared>, |
| 888 | Path(id): Path<String>, |
| 889 | method: Method, |
| 890 | headers: HeaderMap, |
| 891 | ) -> Response { |
| 892 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 893 | with_run(&state, &id, &headers, Scope::Home, |run| { |
| 894 | match method.as_str() { |
| 895 | "GET" | "HEAD" => respond(run.elder( |
| 896 | header_str(&headers, header::PROXY_AUTHORIZATION), |
| 897 | header_str(&headers, header::IF_MODIFIED_SINCE), |
| 898 | )), |
| 899 | "OPTIONS" => options(ALLOW, false), |
| 900 | _ => unsupported(&method, ALLOW), |
| 901 | } |
| 902 | }) |
| 903 | } |
| 904 | |
| 905 | fn requested_grip(headers: &HeaderMap) -> Option<Grip> { |
| 906 | let connection = header_str(headers, header::CONNECTION)?.to_ascii_lowercase(); |
| 907 | if !connection.split(',').any(|token| token.trim() == "upgrade") { |
| 908 | return None; |
| 909 | } |
| 910 | header_str(headers, header::UPGRADE)? |
| 911 | .split(',') |
| 912 | .find_map(|protocol| Grip::parse(protocol.trim())) |
| 913 | } |
| 914 | |
| 915 | async fn grip( |
| 916 | State(state): State<Shared>, |
| 917 | Path(id): Path<String>, |
| 918 | mut request: Request, |
| 919 | ) -> Response { |
| 920 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 921 | let method = request.method().clone(); |
| 922 | let headers = request.headers().clone(); |
| 923 | let wanted = requested_grip(&headers); |
| 924 | let upgrade = wanted.map(|_| hyper::upgrade::on(&mut request)); |
| 925 | let detail = match header_str(&headers, header::UPGRADE) { |
| 926 | Some(protocol) if wanted.is_none() => { |
| 927 | format!("The server does not know the {protocol} grip. Grips are protocols.") |
| 928 | } |
| 929 | _ => "Grips are protocols. Change grip by upgrading the connection.".to_owned(), |
| 930 | }; |
| 931 | let response = with_run(&state, &id, &headers, Scope::Home, |run| { |
| 932 | match (method.as_str(), wanted) { |
| 933 | ("GET" | "HEAD", Some(grip)) => { |
| 934 | run.set_grip(grip); |
| 935 | ( |
| 936 | StatusCode::SWITCHING_PROTOCOLS, |
| 937 | [ |
| 938 | (header::UPGRADE, grip.name()), |
| 939 | (header::CONNECTION, "upgrade"), |
| 940 | ], |
| 941 | ) |
| 942 | .into_response() |
| 943 | } |
| 944 | ("GET" | "HEAD", None) => ( |
| 945 | StatusCode::OK, |
| 946 | [(header::UPGRADE, "overhand, underhand")], |
| 947 | Json(json!({ |
| 948 | "grip": run.grip.name(), |
| 949 | "detail": detail, |
| 950 | })), |
| 951 | ) |
| 952 | .into_response(), |
| 953 | ("OPTIONS", _) => options(ALLOW, false), |
| 954 | _ => unsupported(&method, ALLOW), |
| 955 | } |
| 956 | }); |
| 957 | if response.status() == StatusCode::SWITCHING_PROTOCOLS |
| 958 | && let (Some(upgrade), Some(grip)) = (upgrade, wanted) |
| 959 | { |
| 960 | tokio::spawn(async move { |
| 961 | if let Ok(upgraded) = upgrade.await { |
| 962 | let mut io = TokioIo::new(upgraded); |
| 963 | let line = format!("You are now jelqing {}.\r\n", grip.name()); |
| 964 | let _ = io.write_all(line.as_bytes()).await; |
| 965 | let _ = io.shutdown().await; |
| 966 | } |
| 967 | }); |
| 968 | } |
| 969 | response |
| 970 | } |
| 971 | |
| 972 | async fn diagnosis( |
| 973 | State(state): State<Shared>, |
| 974 | Path(id): Path<String>, |
| 975 | method: Method, |
| 976 | headers: HeaderMap, |
| 977 | ) -> Response { |
| 978 | const ALLOW: &str = "GET, HEAD, OPTIONS"; |
| 979 | with_run(&state, &id, &headers, Scope::Pockets, |run| { |
| 980 | match method.as_str() { |
| 981 | "GET" | "HEAD" => respond(run.diagnose( |
| 982 | header_str(&headers, header::AUTHORIZATION), |
| 983 | header_str(&headers, header::IF_NONE_MATCH), |
| 984 | )), |
| 985 | "OPTIONS" => options(ALLOW, false), |
| 986 | _ => unsupported(&method, ALLOW), |
| 987 | } |
| 988 | }) |
| 989 | } |
| 990 | |
| 991 | async fn thread(Path(year): Path<String>, method: Method) -> Response { |
| 992 | if method != Method::GET && method != Method::HEAD { |
| 993 | return unsupported(&method, "GET, HEAD"); |
| 994 | } |
| 995 | match year.parse::<u32>() { |
| 996 | Ok(2009) => ( |
| 997 | [(header::LAST_MODIFIED, ELDER_LAST_MODIFIED)], |
| 998 | Json(forum_thread()), |
| 999 | ) |
| 1000 | .into_response(), |
| 1001 | Ok(y) if y > 2009 => Problem::new( |
| 1002 | StatusCode::NOT_FOUND, |
| 1003 | "no-thread", |
| 1004 | "Nobody has posted since 2009.", |
| 1005 | ) |
| 1006 | .with("thread", FORUM_THREAD) |
| 1007 | .into_response(), |
| 1008 | _ => Problem::new( |
| 1009 | StatusCode::NOT_FOUND, |
| 1010 | "no-thread", |
| 1011 | "The forum did not exist yet. The problem did.", |
| 1012 | ) |
| 1013 | .with("thread", FORUM_THREAD) |
| 1014 | .into_response(), |
| 1015 | } |
| 1016 | } |
| 1017 | |
| 1018 | async fn graveyard(State(state): State<Shared>, method: Method) -> Response { |
| 1019 | if method != Method::GET && method != Method::HEAD { |
| 1020 | return unsupported(&method, "GET, HEAD"); |
| 1021 | } |
| 1022 | let runs = state.runs.lock().unwrap(); |
| 1023 | let dead: Vec<Value> = runs |
| 1024 | .values() |
| 1025 | .filter_map(|run| { |
| 1026 | let ending = run.ended.as_ref()?; |
| 1027 | Some(json!({ |
| 1028 | "id": run.id, |
| 1029 | "cause": format!("{PROBLEM_BASE}{}", ending.cause), |
| 1030 | "detail": ending.detail, |
| 1031 | "turn": ending.turn, |
| 1032 | })) |
| 1033 | }) |
| 1034 | .collect(); |
| 1035 | Json(json!({ "runs": dead })).into_response() |
| 1036 | } |
| 1037 | |
| 1038 | async fn leaderboard( |
| 1039 | State(state): State<Shared>, |
| 1040 | method: Method, |
| 1041 | Query(query): Query<HashMap<String, String>>, |
| 1042 | headers: HeaderMap, |
| 1043 | ) -> Response { |
| 1044 | if method != Method::GET && method != Method::HEAD { |
| 1045 | return unsupported(&method, "GET, HEAD"); |
| 1046 | } |
| 1047 | let truthful = match query.get("sort").map(String::as_str) { |
| 1048 | None | Some("self-reported-length") => false, |
| 1049 | Some("content-length") => match cookie(&headers, "ruler") { |
| 1050 | Some(ruler) if secret::verify("ruler", ruler) == Some("1") => true, |
| 1051 | Some(_) => { |
| 1052 | return Problem::new( |
| 1053 | StatusCode::BAD_REQUEST, |
| 1054 | "exaggeration", |
| 1055 | "The server has measured. Lying is not a technique.", |
| 1056 | ) |
| 1057 | .with("cookie", "ruler") |
| 1058 | .into_response(); |
| 1059 | } |
| 1060 | None => { |
| 1061 | return Problem::new( |
| 1062 | StatusCode::FORBIDDEN, |
| 1063 | "ruler-required", |
| 1064 | "Sorting by the truth requires a Ruler.", |
| 1065 | ) |
| 1066 | .into_response(); |
| 1067 | } |
| 1068 | }, |
| 1069 | Some(_) => { |
| 1070 | return Problem::new( |
| 1071 | StatusCode::BAD_REQUEST, |
| 1072 | "unknown-sort", |
| 1073 | "The leaderboard sorts by self-reported-length or content-length.", |
| 1074 | ) |
| 1075 | .into_response(); |
| 1076 | } |
| 1077 | }; |
| 1078 | let include_seeded = query.get("seeded").is_some_and(|s| s == "include"); |
| 1079 | let runs = state.runs.lock().unwrap(); |
| 1080 | let mut entries: Vec<&Run> = runs |
| 1081 | .values() |
| 1082 | .filter(|run| include_seeded || !run.seeded) |
| 1083 | .collect(); |
| 1084 | let key = |r: &Run| { |
| 1085 | if truthful { |
| 1086 | r.current_length() |
| 1087 | } else { |
| 1088 | r.self_reported() |
| 1089 | } |
| 1090 | }; |
| 1091 | entries.sort_by(|a, b| key(b).total_cmp(&key(a))); |
| 1092 | let entries: Vec<Value> = entries |
| 1093 | .into_iter() |
| 1094 | .map(|run| { |
| 1095 | let mut entry = json!({ |
| 1096 | "id": run.id, |
| 1097 | "status": run.status(), |
| 1098 | "floor": run.floor.number(), |
| 1099 | "self_reported_length": (run.self_reported() * 100.0).round() / 100.0, |
| 1100 | "ranked": !run.seeded, |
| 1101 | }); |
| 1102 | if truthful { |
| 1103 | entry["content_length"] = json!(millimetres(run.current_length())); |
| 1104 | } |
| 1105 | entry |
| 1106 | }) |
| 1107 | .collect(); |
| 1108 | Json(json!({ "entries": entries })).into_response() |
| 1109 | } |
| 1110 | |
| 1111 | async fn index(method: Method, headers: HeaderMap) -> Response { |
| 1112 | if method != Method::GET && method != Method::HEAD { |
| 1113 | return unsupported(&method, "GET, HEAD"); |
| 1114 | } |
| 1115 | let wants_html = |
| 1116 | header_str(&headers, header::ACCEPT).is_some_and(|accept| accept.contains("text/html")); |
| 1117 | if wants_html { |
| 1118 | return ( |
| 1119 | StatusCode::SEE_OTHER, |
| 1120 | [(header::LOCATION, "/ui/"), (header::VARY, "Accept")], |
| 1121 | ) |
| 1122 | .into_response(); |
| 1123 | } |
| 1124 | ( |
| 1125 | [(header::VARY, "Accept")], |
| 1126 | Json(json!({ |
| 1127 | "detail": "This is jelqd. It is a roguelike. It is played over HTTP.", |
| 1128 | "start": { "href": "/runs", "method": "POST" }, |
| 1129 | "leaderboard": "/leaderboard", |
| 1130 | "graveyard": "/graveyard", |
| 1131 | "forum": FORUM_THREAD, |
| 1132 | "ui": "/ui/", |
| 1133 | })), |
| 1134 | ) |
| 1135 | .into_response() |
| 1136 | } |
| 1137 | |
| 1138 | async fn ui(method: Method) -> Response { |
| 1139 | if method != Method::GET && method != Method::HEAD { |
| 1140 | return unsupported(&method, "GET, HEAD"); |
| 1141 | } |
| 1142 | Html(include_str!("ui.html")).into_response() |
| 1143 | } |
| 1144 | |
| 1145 | #[derive(Deserialize)] |
| 1146 | struct Envelope { |
| 1147 | method: String, |
| 1148 | path: String, |
| 1149 | #[serde(default)] |
| 1150 | headers: Vec<(String, String)>, |
| 1151 | #[serde(default)] |
| 1152 | body: String, |
| 1153 | } |
| 1154 | |
| 1155 | fn bad_envelope(detail: &str) -> Response { |
| 1156 | Problem::new(StatusCode::BAD_REQUEST, "bad-envelope", detail).into_response() |
| 1157 | } |
| 1158 | |
| 1159 | // Browsers hide 3xx responses from fetch and refuse TRACE, Upgrade, and Expect, so the page |
| 1160 | // replays requests in-process to show the player exactly what the API said. |
| 1161 | async fn ui_send(State(api): State<Router>, headers: HeaderMap, body: Bytes) -> Response { |
| 1162 | let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else { |
| 1163 | return bad_envelope("The envelope could not be parsed."); |
| 1164 | }; |
| 1165 | if !envelope.path.starts_with('/') || envelope.path.starts_with("/ui") { |
| 1166 | return bad_envelope("The path must be an API path."); |
| 1167 | } |
| 1168 | let Ok(method) = Method::from_bytes(envelope.method.as_bytes()) else { |
| 1169 | return bad_envelope("That is not a method."); |
| 1170 | }; |
| 1171 | let mut request = Request::builder().method(method).uri(&envelope.path); |
| 1172 | for (name, value) in envelope.headers.iter().filter(|(_, v)| !v.is_empty()) { |
| 1173 | request = request.header(name, value); |
| 1174 | } |
| 1175 | for cookie in headers.get_all(header::COOKIE) { |
| 1176 | request = request.header(header::COOKIE, cookie); |
| 1177 | } |
| 1178 | let Ok(request) = request.body(Body::from(envelope.body)) else { |
| 1179 | return bad_envelope("The request could not be built. A header may be malformed."); |
| 1180 | }; |
| 1181 | let Ok(response) = api.oneshot(request).await; |
| 1182 | let (parts, body) = response.into_parts(); |
| 1183 | let bytes = axum::body::to_bytes(body, 1 << 20) |
| 1184 | .await |
| 1185 | .unwrap_or_default(); |
| 1186 | let listed: Vec<(String, String)> = parts |
| 1187 | .headers |
| 1188 | .iter() |
| 1189 | .map(|(name, value)| { |
| 1190 | ( |
| 1191 | name.to_string(), |
| 1192 | value.to_str().unwrap_or("<binary>").to_owned(), |
| 1193 | ) |
| 1194 | }) |
| 1195 | .collect(); |
| 1196 | let mut response = Json(json!({ |
| 1197 | "status": parts.status.as_u16(), |
| 1198 | "reason": reason(parts.status), |
| 1199 | "headers": listed, |
| 1200 | "body": String::from_utf8_lossy(&bytes), |
| 1201 | })) |
| 1202 | .into_response(); |
| 1203 | for cookie in parts.headers.get_all(header::SET_COOKIE) { |
| 1204 | response |
| 1205 | .headers_mut() |
| 1206 | .append(header::SET_COOKIE, cookie.clone()); |
| 1207 | } |
| 1208 | response |
| 1209 | } |
| 1210 | |
| 1211 | async fn not_found() -> Problem { |
| 1212 | Problem::new( |
| 1213 | StatusCode::NOT_FOUND, |
| 1214 | "not-found", |
| 1215 | "The resource does not exist. This is not about you.", |
| 1216 | ) |
| 1217 | } |