atagen

jelq

public

an http based roguelike rpg about jelqing

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