atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0
2ea5ef81f040ab9608f3421508d03314efb7f6d8
1
ssh://brewery@pls.lobotomise.me:33/atagen/jelq.git
src/http.rsRust1217 lines38.7 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::{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
}