atagen

jelq

public

an http based roguelike rpg about jelqing

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