atagen

jelq

public

an http based roguelike rpg about jelqing

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