an http based roguelike rpg about jelqing
Commit 99524cf9f17c
tarball12 files changed+611-43
@@ -67,6 +67,15 @@ dependencies = [
6767
]6868
6969
[[package]]70
+name = "block-buffer"71
+version = "0.10.4"72
+source = "registry+https://github.com/rust-lang/crates.io-index"73
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"74
+dependencies = [75
+ "generic-array",76
+]77
+78
+[[package]]7079
name = "bytes"7180
version = "1.12.1"7281
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -79,6 +88,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
7988
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"8089
8190
[[package]]91
+name = "cpufeatures"92
+version = "0.2.17"93
+source = "registry+https://github.com/rust-lang/crates.io-index"94
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"95
+dependencies = [96
+ "libc",97
+]98
+99
+[[package]]82100
name = "crc32fast"83101
version = "1.5.2"84102
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -88,6 +106,27 @@ dependencies = [
88106
]89107
90108
[[package]]109
+name = "crypto-common"110
+version = "0.1.7"111
+source = "registry+https://github.com/rust-lang/crates.io-index"112
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"113
+dependencies = [114
+ "generic-array",115
+ "typenum",116
+]117
+118
+[[package]]119
+name = "digest"120
+version = "0.10.7"121
+source = "registry+https://github.com/rust-lang/crates.io-index"122
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"123
+dependencies = [124
+ "block-buffer",125
+ "crypto-common",126
+ "subtle",127
+]128
+129
+[[package]]91130
name = "flate2"92131
version = "1.1.10"93132
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -144,6 +183,16 @@ dependencies = [
144183
]145184
146185
[[package]]186
+name = "generic-array"187
+version = "0.14.7"188
+source = "registry+https://github.com/rust-lang/crates.io-index"189
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"190
+dependencies = [191
+ "typenum",192
+ "version_check",193
+]194
+195
+[[package]]147196
name = "getrandom"148197
version = "0.3.4"149198
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -156,6 +205,15 @@ dependencies = [
156205
]157206
158207
[[package]]208
+name = "hmac"209
+version = "0.12.1"210
+source = "registry+https://github.com/rust-lang/crates.io-index"211
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"212
+dependencies = [213
+ "digest",214
+]215
+216
+[[package]]159217
name = "http"160218
version = "1.5.0"161219
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -247,12 +305,14 @@ dependencies = [
247305
"axum",248306
"flate2",249307
"futures-util",308
+ "hmac",250309
"httpdate",251310
"hyper",252311
"hyper-util",253312
"rand",254313
"serde",255314
"serde_json",315
+ "sha2",256316
"tokio",257317
"tower",258318
]@@ -461,6 +521,17 @@ dependencies = [
461521
]462522
463523
[[package]]524
+name = "sha2"525
+version = "0.10.9"526
+source = "registry+https://github.com/rust-lang/crates.io-index"527
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"528
+dependencies = [529
+ "cfg-if",530
+ "cpufeatures",531
+ "digest",532
+]533
+534
+[[package]]464535
name = "simd-adler32"465536
version = "0.3.10"466537
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -489,6 +560,12 @@ dependencies = [
489560
]490561
491562
[[package]]563
+name = "subtle"564
+version = "2.6.1"565
+source = "registry+https://github.com/rust-lang/crates.io-index"566
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"567
+568
+[[package]]492569
name = "syn"493570
version = "2.0.119"494571
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -591,12 +668,24 @@ dependencies = [
591668
]592669
593670
[[package]]671
+name = "typenum"672
+version = "1.20.1"673
+source = "registry+https://github.com/rust-lang/crates.io-index"674
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"675
+676
+[[package]]594677
name = "unicode-ident"595678
version = "1.0.26"596679
source = "registry+https://github.com/rust-lang/crates.io-index"597680
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"598681
599682
[[package]]683
+name = "version_check"684
+version = "0.9.5"685
+source = "registry+https://github.com/rust-lang/crates.io-index"686
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"687
+688
+[[package]]600689
name = "wasi"601690
version = "0.11.1+wasi-snapshot-preview1"602691
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -9,13 +9,14 @@ license = "MIT"
99
axum = "0.8"1010
flate2 = "1"1111
futures-util = "0.3"12
+hmac = "0.12"1213
httpdate = "1"1314
hyper = { version = "1", features = ["http1", "server"] }1415
hyper-util = { version = "0.1", features = ["tokio"] }1516
rand = "0.9"1617
serde = { version = "1", features = ["derive"] }1718
serde_json = "1"19
+sha2 = "0.10"1820
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread"] }19
-20
-[dev-dependencies]2121
tower = { version = "0.5", features = ["util"] }22
+@@ -19,7 +19,10 @@
1919
let2020
pkgs = nixpkgs.legacyPackages.${system};2121
craneLib = crane.mkLib pkgs;22
- src = craneLib.cleanCargoSource ./.;22
+ src = pkgs.lib.cleanSourceWith {23
+ src = ./.;24
+ filter = path: type: pkgs.lib.hasSuffix ".html" path || craneLib.filterCargoSources path type;25
+ };2326
commonArgs = {2427
inherit src;2528
strictDeps = true;@@ -17,6 +17,9 @@ pub const ELDER_LAST_MODIFIED: &str = "Sun, 14 Jun 2009 03:12:00 GMT";
1717
pub const EXCUSES: [&str; 3] = ["changing", "homework", "pulled a muscle"];1818
pub const THANKSGIVING_EXCUSES: [&str; 3] = ["on a call", "folding laundry", "praying"];1919
const TRUTHS: [&str; 3] = ["the truth", "truth", "honesty"];20
+const PATIENCE: u8 = 3;21
+const CAUGHT: &str = "Mom has seen enough. The run has ended. Nobody will mention it at dinner.";22
+const BANNED: &str = "Your account has been banned. The forum can tell. The run has ended.";2023
2124
const INTAKE: [&str; 3] = [2225
"The clinic does not accept excuses.",@@ -124,27 +127,27 @@ impl Run {
124127
return Ok(());125128
}126129
if parse_credentials(authorization, "basic").is_some() {127
- return Err(challenge(Problem::new(130
+ return Err(self.mom_strike(challenge(Problem::new(128131
StatusCode::UNAUTHORIZED,129132
"basic",130133
"Mom does not accept Basic authentication. Nobody should.",131
- )));134
+ ))));132135
}133136
134137
let excuse = if digest {135138
let Some(params) = digest_params(authorization) else {136
- return Err(challenge(Problem::new(139
+ return Err(self.mom_strike(challenge(Problem::new(137140
StatusCode::UNAUTHORIZED,138141
"digest-required",139142
"Mom has started using Digest authentication.",140
- )));143
+ ))));141144
};142145
if params.get("nonce") != Some(&self.nonce) {143
- return Err(challenge(Problem::new(146
+ return Err(self.mom_strike(challenge(Problem::new(144147
StatusCode::UNAUTHORIZED,145148
"stale-nonce",146149
"That was a different conversation.",147
- )));150
+ ))));148151
}149152
params.get("username").cloned().unwrap_or_default()150153
} else {@@ -162,11 +165,11 @@ impl Run {
162165
if digest && self.heard.contains(&excuse) {163166
self.shame += 3;164167
self.touch();165
- return Err(challenge(Problem::new(168
+ return Err(self.mom_strike(challenge(Problem::new(166169
StatusCode::UNAUTHORIZED,167170
"excuse-replayed",168171
"Mom has heard that one before. She would have preferred the truth.",169
- )));172
+ ))));170173
}171174
if self.excuses().contains(&excuse.as_str()) {172175
self.heard.push(excuse);@@ -180,11 +183,11 @@ impl Run {
180183
} else {181184
"Mom has not heard that one before. She does not believe it."182185
};183
- Err(challenge(Problem::new(186
+ Err(self.mom_strike(challenge(Problem::new(184187
StatusCode::UNAUTHORIZED,185188
"excuse-rejected",186189
detail,187
- )))190
+ ))))188191
}189192
190193
pub(super) fn errand(&mut self, summoned: bool) -> &'static str {@@ -209,7 +212,28 @@ impl Run {
209212
errands[self.rng.random_range(0..errands.len())]210213
}211214
215
+ fn mom_strike(&mut self, problem: Problem) -> Problem {216
+ self.mom_strikes += 1;217
+ self.touch();218
+ if self.mom_strikes < PATIENCE {219
+ return problem;220
+ }221
+ self.end("caught", CAUGHT);222
+ Problem::new(StatusCode::GONE, "caught", CAUGHT)223
+ }224
+225
+ fn elder_strike(&mut self, problem: Problem) -> Problem {226
+ self.elder_strikes += 1;227
+ self.touch();228
+ if self.elder_strikes < PATIENCE {229
+ return problem;230
+ }231
+ self.end("banned", BANNED);232
+ Problem::new(StatusCode::FORBIDDEN, "banned", BANNED)233
+ }234
+212235
fn leave_door(&mut self) {236
+ self.mom_strikes = 0;213237
self.mom_present = false;214238
self.suspicion = 0;215239
self.touch();@@ -639,13 +663,13 @@ impl Run {
639663
let actual = self.current_length();640664
if claimed >= actual + 1.0 {641665
self.shame += 2;642
- self.touch();643
- return Err(exaggeration());666
+ return Err(self.elder_strike(exaggeration()));644667
}645668
if !honest(claimed, actual) {646669
self.shame += 1;647
- self.touch();648
- return Err(verification("Verification failed. The forum can tell."));670
+ return Err(671
+ self.elder_strike(verification("Verification failed. The forum can tell."))672
+ );649673
}650674
self.verified = true;651675
self.touch();@@ -274,14 +274,15 @@ impl Run {
274274
self.stamina -= cost + i32::from(lube == Some(Lube::Lotion));275275
if self.stamina <= 0 {276276
self.stamina = 0;277
- self.refractory_until = Some(now + REFRACTORY);277
+ self.refractory_until = Some(now + *REFRACTORY);278278
}279279
let chill = if self.floor == Floor::LockerRoom {280280
8281281
} else {282282
0283283
};284
- self.eq = (self.eq + intensity * 2 - 6 - chill).clamp(0, 100);284
+ let wobble = self.rng.random_range(-3..=3);285
+ self.eq = (self.eq + intensity * 2 - 6 - chill + wobble).clamp(0, 100);285286
286287
let mut suspicion = intensity / 5 + i32::from(lube == Some(Lube::Vaseline));287288
if matches!(self.floor, Floor::LockerRoom | Floor::Grandmas) {@@ -297,6 +298,7 @@ impl Run {
297298
let summoned = was_below && self.suspicion == SUSPICION_LIMIT;298299
if summoned || self.rng.random_ratio(u32::from(self.suspicion) + 1, 16) {299300
self.mom_present = true;301
+ self.mom_strikes = 0;300302
self.mom_errand = self.errand(summoned);301303
self.nonce = self.fresh_nonce();302304
}@@ -343,7 +345,7 @@ impl Run {
343345
return Err(self.rupture());344346
}345347
self.shame += 5;346
- self.next_floor();348
+ self.next_floor(false);347349
return Err(Problem::new(348350
StatusCode::TOO_EARLY,349351
"premature",@@ -434,6 +436,9 @@ impl Run {
434436
}435437
return Err(timed(not_extended()));436438
}439
+ if natural == 2 && self.eq > 70 {440
+ return Err(timed(self.rupture()));441
+ }437442
if self.eq > rupture_eq && total < dc {438443
return Err(timed(self.rupture()));439444
}@@ -579,7 +584,7 @@ impl Run {
579584
if self.edges >= 3 {580585
self.edges = 0;581586
self.stamina = 0;582
- self.refractory_until = Some(now + REFRACTORY);587
+ self.refractory_until = Some(now + *REFRACTORY);583588
return Err(Problem::new(584589
StatusCode::LOOP_DETECTED,585590
"edging-loop",@@ -5,7 +5,10 @@ mod jelq;
55
pub use catalog::{LUBRICANTS, Lube, SHOP, ShopItem, Technique};66
pub use encounters::{ELDER_LAST_MODIFIED, FORUM_THREAD, PatchOp, forum_thread};77
8
-use std::time::{Duration, Instant};8
+use std::{9
+ sync::LazyLock,10
+ time::{Duration, Instant},11
+};912
1013
use axum::{1114
Json,@@ -23,7 +26,13 @@ pub const BRANCH_TURN: u32 = 10;
2326
pub const FLOOR_TURNS: u32 = 6;2427
pub const STARTING_CASH: u32 = 20;2528
pub const FLOOR_ALLOWANCE: u32 = 10;26
-pub const REFRACTORY: Duration = Duration::from_secs(30);29
+pub static REFRACTORY: LazyLock<Duration> = LazyLock::new(|| {30
+ let seconds = std::env::var("JELQD_REFRACTORY_SECS")31
+ .ok()32
+ .and_then(|s| s.parse().ok())33
+ .unwrap_or(30);34
+ Duration::from_secs(seconds)35
+});2736
pub const SELF_REPORT_INFLATION: f64 = 1.5;2837
pub const MM_PER_INCH: f64 = 25.4;2938
pub const RUN_ALLOW: &str = "GET, HEAD, JELQ, PUT, PATCH, DELETE, OPTIONS, TRACE";@@ -264,6 +273,9 @@ pub struct Run {
264273
pub branch_pending: bool,265274
pub mom_present: bool,266275
pub mom_errand: &'static str,276
+ pub mom_strikes: u8,277
+ pub elder_strikes: u8,278
+ pub seeded: bool,267279
pub observed: Option<&'static str>,268280
pub measured: bool,269281
pub cured: bool,@@ -356,6 +368,9 @@ impl Run {
356368
branch_pending: false,357369
mom_present: false,358370
mom_errand: "She heard a noise.",371
+ mom_strikes: 0,372
+ elder_strikes: 0,373
+ seeded: false,359374
observed: None,360375
measured: false,361376
cured: false,@@ -434,6 +449,7 @@ impl Run {
434449
"cash": self.cash,435450
"grip": self.grip.name(),436451
"warmed_up": self.warmed_up,452
+ "ranked": !self.seeded,437453
"conditions": conditions,438454
});439455
if self.items.ruler {@@ -600,7 +616,7 @@ impl Run {
600616
.with("turn", self.turn)601617
}602618
603
- fn next_floor(&mut self) {619
+ fn next_floor(&mut self, paid: bool) {604620
let Some(next) = self.floor.next() else {605621
return;606622
};@@ -615,7 +631,9 @@ impl Run {
615631
self.roommate_told = false;616632
self.drawer_searches = 0;617633
self.edges = 0;618
- self.cash += FLOOR_ALLOWANCE;634
+ if paid {635
+ self.cash += FLOOR_ALLOWANCE;636
+ }619637
self.nonce = self.fresh_nonce();620638
self.touch();621639
}@@ -651,7 +669,7 @@ impl Run {
651669
_ => {}652670
}653671
let reverted = self.temp_length;654
- self.next_floor();672
+ self.next_floor(true);655673
let mut body = json!({656674
"detail": format!("You have arrived at {}.", self.floor.name()),657675
"floor": self.floor.number(),@@ -13,21 +13,24 @@ use axum::{
1313
extract::{Path, Query, Request, State},1414
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, header},1515
middleware::{self, Next},16
- response::{IntoResponse, Response},16
+ response::{Html, IntoResponse, Redirect, Response},1717
routing::any,1818
};1919
use flate2::{Compression, write::GzEncoder};2020
use hyper_util::rt::TokioIo;2121
use rand::Rng;22
+use serde::Deserialize;2223
use serde_json::{Value, json};2324
use tokio::io::AsyncWriteExt;25
+use tower::ServiceExt;2426
2527
use crate::{2628
game::{2729
ELDER_LAST_MODIFIED, FORUM_THREAD, Floor, Grip, LUBRICANTS, Lube, MAX_STAMINA, MM_PER_INCH,2830
PatchOp, RUN_ALLOW, Run, SUSPICION_LIMIT, Technique, forum_thread, shaft,2931
},30
- problem::{PROBLEM_BASE, Problem},32
+ problem::{PROBLEM_BASE, Problem, reason},33
+ secret,3134
};3235
3336
const REL_BASE: &str = "https://jelq.dev/rels/";@@ -64,7 +67,18 @@ pub fn app() -> Router {
6467
}6568
6669
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 {6780
Router::new()81
+ .route("/", any(index))6882
.route("/runs", any(runs))6983
.route("/runs/{id}", any(run))7084
.route("/runs/{id}/length", any(length))@@ -319,10 +333,16 @@ fn finish(run: &Run, mut response: Response) -> Response {
319333
}320334
stats.push((321335
"set-cookie",322
- format!("shame={}; {COOKIE_ATTRIBUTES}", run.total_shame()),336
+ format!(337
+ "shame={}; {COOKIE_ATTRIBUTES}",338
+ secret::sign("shame", &run.total_shame().to_string())339
+ ),323340
));324341
if run.items.ruler {325
- stats.push(("set-cookie", format!("ruler=1; {COOKIE_ATTRIBUTES}")));342
+ stats.push((343
+ "set-cookie",344
+ format!("ruler={}; {COOKIE_ATTRIBUTES}", secret::sign("ruler", "1")),345
+ ));326346
}327347
let headers = response.headers_mut();328348
for (name, value) in stats {@@ -409,6 +429,7 @@ async fn runs(
409429
if method != Method::POST {410430
return unsupported(&method, "POST");411431
}432
+ let seeded = query.contains_key("seed");412433
let seed = match query.get("seed").map(|s| s.parse::<u64>()) {413434
None => rand::rng().random(),414435
Some(Ok(seed)) => seed,@@ -422,10 +443,12 @@ async fn runs(
422443
}423444
};424445
let shame = cookie(&headers, "shame")446
+ .and_then(|s| secret::verify("shame", s))425447
.and_then(|s| s.parse().ok())426448
.unwrap_or(0);427449
let id = format!("{:08x}", rand::rng().random::<u32>());428
- let run = Run::new(id.clone(), seed, shame);450
+ let mut run = Run::new(id.clone(), secret::mix_seed(seed), shame);451
+ run.seeded = seeded;429452
let response = (430453
StatusCode::CREATED,431454
[@@ -1023,15 +1046,26 @@ async fn leaderboard(
10231046
}10241047
let truthful = match query.get("sort").map(String::as_str) {10251048
None | Some("self-reported-length") => false,1026
- Some("content-length") if cookie(&headers, "ruler") == Some("1") => true,1027
- Some("content-length") => {1028
- return Problem::new(1029
- StatusCode::FORBIDDEN,1030
- "ruler-required",1031
- "Sorting by the truth requires a Ruler.",1032
- )1033
- .into_response();1034
- }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
+ },10351069
Some(_) => {10361070
return Problem::new(10371071
StatusCode::BAD_REQUEST,@@ -1041,8 +1075,12 @@ async fn leaderboard(
10411075
.into_response();10421076
}10431077
};1078
+ let include_seeded = query.get("seeded").is_some_and(|s| s == "include");10441079
let runs = state.runs.lock().unwrap();1045
- let mut entries: Vec<&Run> = runs.values().collect();1080
+ let mut entries: Vec<&Run> = runs1081
+ .values()1082
+ .filter(|run| include_seeded || !run.seeded)1083
+ .collect();10461084
let key = |r: &Run| {10471085
if truthful {10481086
r.current_length()@@ -1059,6 +1097,7 @@ async fn leaderboard(
10591097
"status": run.status(),10601098
"floor": run.floor.number(),10611099
"self_reported_length": (run.self_reported() * 100.0).round() / 100.0,1100
+ "ranked": !run.seeded,10621101
});10631102
if truthful {10641103
entry["content_length"] = json!(millimetres(run.current_length()));@@ -1069,6 +1108,106 @@ async fn leaderboard(
10691108
Json(json!({ "entries": entries })).into_response()10701109
}10711110
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 page1160
+// 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
+ .await1185
+ .unwrap_or_default();1186
+ let listed: Vec<(String, String)> = parts1187
+ .headers1188
+ .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
+ response1205
+ .headers_mut()1206
+ .append(header::SET_COOKIE, cookie.clone());1207
+ }1208
+ response1209
+}1210
+10721211
async fn not_found() -> Problem {10731212
Problem::new(10741213
StatusCode::NOT_FOUND,@@ -1,5 +1,6 @@
11
pub mod game;22
pub mod http;33
pub mod problem;4
+pub mod secret;45
56
pub use http::{AppState, app, router};@@ -46,7 +46,7 @@ impl Problem {
4646
}4747
4848
// The http crate still uses the RFC 7231 names for these.49
-fn title(status: StatusCode) -> &'static str {49
+pub fn reason(status: StatusCode) -> &'static str {5050
match status.as_u16() {5151
413 => "Content Too Large",5252
422 => "Unprocessable Content",@@ -62,7 +62,7 @@ impl IntoResponse for Problem {
6262
"type".into(),6363
json!(format!("{PROBLEM_BASE}{}", problem.slug)),6464
);65
- body.insert("title".into(), json!(title(problem.status)));65
+ body.insert("title".into(), json!(reason(problem.status)));6666
body.insert("status".into(), json!(problem.status.as_u16()));6767
body.insert("detail".into(), json!(problem.detail));6868
body.extend(problem.extensions);@@ -0,0 +1,52 @@
1
+use std::{fmt::Write as _, sync::LazyLock};2
+3
+use hmac::{Hmac, Mac};4
+use sha2::Sha256;5
+6
+static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {7
+ std::env::var("JELQD_SECRET")8
+ .map(String::into_bytes)9
+ .unwrap_or_else(|_| rand::random::<[u8; 32]>().to_vec())10
+});11
+12
+fn mac(message: &str) -> Hmac<Sha256> {13
+ let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac accepts any key length");14
+ mac.update(message.as_bytes());15
+ mac16
+}17
+18
+fn hex(bytes: &[u8]) -> String {19
+ bytes.iter().fold(String::new(), |mut out, b| {20
+ let _ = write!(out, "{b:02x}");21
+ out22
+ })23
+}24
+25
+fn unhex(text: &str) -> Option<Vec<u8>> {26
+ if !text.len().is_multiple_of(2) {27
+ return None;28
+ }29
+ (0..text.len())30
+ .step_by(2)31
+ .map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok())32
+ .collect()33
+}34
+35
+pub fn sign(name: &str, value: &str) -> String {36
+ let tag = mac(&format!("{name}={value}")).finalize().into_bytes();37
+ format!("{value}.{}", hex(&tag))38
+}39
+40
+pub fn verify<'a>(name: &str, signed: &'a str) -> Option<&'a str> {41
+ let (value, tag) = signed.rsplit_once('.')?;42
+ mac(&format!("{name}={value}"))43
+ .verify_slice(&unhex(tag)?)44
+ .ok()?;45
+ Some(value)46
+}47
+48
+/// Mixes the server secret into a seed, so a public seed and public source cannot predict rolls offline.49
+pub fn mix_seed(seed: u64) -> u64 {50
+ let tag = mac(&format!("seed={seed}")).finalize().into_bytes();51
+ u64::from_le_bytes(tag[..8].try_into().expect("sha256 is longer than 8 bytes"))52
+}@@ -0,0 +1,171 @@
1
+<!DOCTYPE html>2
+<html>3
+<head>4
+<meta charset="utf-8">5
+<title>jelqd</title>6
+</head>7
+<body>8
+9
+<h1 id="status">jelqd</h1>10
+<p id="detail">A roguelike played over HTTP. Press POST /runs to begin.</p>11
+<ul id="links"></ul>12
+<ul id="headers"></ul>13
+<pre id="body"></pre>14
+15
+<hr>16
+17
+<h2>Run</h2>18
+<p>19
+Run <input id="run" size="20" placeholder="/runs/...">20
+seed <input id="seed" size="12" placeholder="(unranked)">21
+<button onclick="start()">POST /runs</button>22
+</p>23
+24
+<h2>JELQ</h2>25
+<p>26
+technique27
+<select id="technique">28
+<option>warmup</option>29
+<option>wet</option>30
+<option>dry</option>31
+<option>stretch</option>32
+<option>uli</option>33
+<option>edge</option>34
+<option>pump</option>35
+<option>ice</option>36
+<option>supplement</option>37
+</select>38
+or combo <input id="combo" size="14" placeholder="wet,stretch">39
+intensity <input id="intensity" type="number" min="1" max="10" value="5" size="3">40
+Content-Type41
+<select id="lube">42
+<option value="">(none)</option>43
+<option>application/baby-oil</option>44
+<option>application/coconut-oil</option>45
+<option>application/lotion</option>46
+<option>application/vaseline</option>47
+<option>text/plain; charset=spit</option>48
+<option>image/jpeg</option>49
+</select>50
+<button onclick="jelq()">JELQ</button>51
+</p>52
+53
+<h2>Methods</h2>54
+<ul>55
+<li>the run: <button onclick="go('GET', '')">GET</button> <button onclick="go('HEAD', '')">HEAD</button> <button onclick="go('PUT', '')">PUT</button> <button onclick="go('OPTIONS', '')">OPTIONS</button> <button onclick="go('TRACE', '')">TRACE</button> <button onclick="go('DELETE', '')">DELETE</button></li>56
+<li>/floor: <button onclick="go('GET', '/floor')">GET</button> <button onclick="go('POST', '/floor')">POST</button></li>57
+<li>/drawer: <button onclick="go('POST', '/drawer')">POST</button></li>58
+<li>/inventory: <button onclick="go('GET', '/inventory')">GET</button> item <input id="item" size="16"> <button onclick="go('GET', '/inventory/' + val('item'))">GET</button> <button onclick="go('DELETE', '/inventory/' + val('item'))">DELETE</button></li>59
+<li>/techniques: <button onclick="go('GET', '/techniques')">GET</button></li>60
+<li>/length: <button onclick="go('GET', '/length')">GET</button> <button onclick="go('HEAD', '/length')">HEAD</button></li>61
+<li>/foreskin: <button onclick="go('GET', '/foreskin')">GET</button> <button onclick="go('DELETE', '/foreskin')">DELETE</button></li>62
+<li>/branches: <button onclick="go('POST', '/branches/forum')">POST forum</button> <button onclick="go('POST', '/branches/close-tab')">POST close-tab</button></li>63
+<li>/shop: <button onclick="go('GET', '/shop')">GET</button> item <input id="buy" size="16"> <button onclick="go('POST', '/shop/' + val('buy'))">POST</button></li>64
+<li>/portal: <button onclick="go('GET', '/portal')">GET</button> <button onclick="go('POST', '/portal')">POST</button></li>65
+<li>/elder: <button onclick="go('GET', '/elder')">GET</button></li>66
+<li>/grip: <button onclick="go('GET', '/grip')">GET</button></li>67
+<li>/diagnosis: <button onclick="go('GET', '/diagnosis')">GET</button></li>68
+<li>PATCH the run, as application/json-patch+json:<br>69
+<textarea id="patch" rows="2" cols="60">[{"op": "test", "path": "/length", "value": 6}]</textarea>70
+<button onclick="go('PATCH', '', [['Content-Type', 'application/json-patch+json']], val('patch'))">PATCH</button></li>71
+<li>anything: method <input id="method" size="8" value="GET"> path <input id="path" size="30" value="/leaderboard"> <button onclick="send(val('method'), val('path'))">Send</button></li>72
+</ul>73
+74
+<h2>Headers</h2>75
+<p>Sent with every request when filled in.</p>76
+<ul>77
+<li>Authorization <input id="authorization" size="50" placeholder='Excuse "homework"'></li>78
+<li>Proxy-Authorization <input id="proxy-authorization" size="30" placeholder="Ruler 5.4"></li>79
+<li>If-Match <input id="if-match" size="24"></li>80
+<li>If-None-Match <input id="if-none-match" size="24"></li>81
+<li>If-Modified-Since <input id="if-modified-since" size="32"></li>82
+<li>Range <input id="range" size="16" placeholder="inches=0-2"></li>83
+<li>Accept <input id="accept" size="30"></li>84
+<li>Accept-Encoding <input id="accept-encoding" size="10"></li>85
+<li>Expect <input id="expect" size="16"></li>86
+<li>Upgrade <input id="upgrade" size="16" placeholder="underhand"></li>87
+</ul>88
+89
+<hr>90
+<p><a href="/leaderboard">/leaderboard</a> <a href="/graveyard">/graveyard</a> <a href="/forum/threads/2009">/forum/threads/2009</a></p>91
+92
+<script>93
+const HEADERS = ["Authorization", "Proxy-Authorization", "If-Match", "If-None-Match", "If-Modified-Since", "Range", "Accept", "Accept-Encoding", "Expect", "Upgrade"];94
+95
+function val(id) {96
+ return document.getElementById(id).value.trim();97
+}98
+99
+function put(id, text) {100
+ document.getElementById(id).textContent = text;101
+}102
+103
+function item(list, text, onclick) {104
+ const li = document.createElement("li");105
+ if (onclick) {106
+ const button = document.createElement("button");107
+ button.textContent = text;108
+ button.onclick = onclick;109
+ li.appendChild(button);110
+ } else {111
+ li.textContent = text;112
+ }113
+ document.getElementById(list).appendChild(li);114
+}115
+116
+function hrefs(value, found) {117
+ if (value && typeof value === "object") {118
+ if (typeof value.href === "string") found.push([value.method || "GET", value.href]);119
+ for (const key in value) hrefs(value[key], found);120
+ }121
+ return found;122
+}123
+124
+async function send(method, path, extra, body) {125
+ const headers = HEADERS.map(name => [name, val(name.toLowerCase())]).filter(h => h[1]);126
+ if (val("upgrade")) headers.push(["Connection", "upgrade"]);127
+ const response = await fetch("/ui/send", {128
+ method: "POST",129
+ headers: {"Content-Type": "application/json"},130
+ body: JSON.stringify({method, path, headers: headers.concat(extra || []), body: body || ""}),131
+ });132
+ show(await response.json(), path);133
+}134
+135
+function show(r, path) {136
+ put("status", r.status + " " + (r.reason || ""));137
+ let parsed = null;138
+ try { parsed = JSON.parse(r.body); } catch (e) {}139
+ put("detail", parsed && parsed.detail ? parsed.detail : "");140
+ put("body", r.body);141
+ document.getElementById("headers").replaceChildren();142
+ document.getElementById("links").replaceChildren();143
+ for (const [name, value] of r.headers) item("headers", name + ": " + value);144
+ const location = r.headers.find(h => h[0] === "location");145
+ if (location && path === "/runs" && r.status === 201) document.getElementById("run").value = location[1];146
+ if (location) item("links", "GET " + location[1], () => send("GET", location[1]));147
+ for (const [method, href] of hrefs(parsed, [])) item("links", method + " " + href, () => send(method, href));148
+}149
+150
+function go(method, sub, extra, body) {151
+ if (!val("run")) {152
+ put("status", "No run");153
+ put("detail", "Press POST /runs first.");154
+ return;155
+ }156
+ send(method, val("run") + sub, extra, body);157
+}158
+159
+function start() {160
+ send("POST", val("seed") ? "/runs?seed=" + encodeURIComponent(val("seed")) : "/runs");161
+}162
+163
+function jelq() {164
+ const technique = val("combo") || val("technique");165
+ const query = "?technique=" + encodeURIComponent(technique) + "&intensity=" + encodeURIComponent(val("intensity"));166
+ const lube = val("lube");167
+ go("JELQ", query, lube ? [["Content-Type", lube]] : []);168
+}169
+</script>170
+</body>171
+</html>@@ -439,3 +439,68 @@ async fn the_displayed_length_is_accepted_as_honest() {
439439
.await;440440
assert_eq!(response.status(), StatusCode::OK);441441
}442
+443
+#[tokio::test]444
+async fn mom_runs_out_of_patience() {445
+ let app = seeded_with(Floor::Bedroom, |run| run.mom_present = true);446
+ let lie = [("authorization", "Excuse \"it is for science\"")];447
+ for _ in 0..2 {448
+ let response = send(&app, "GET", "/runs/cafebabe", &lie, "").await;449
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);450
+ }451
+ let caught = send(&app, "GET", "/runs/cafebabe", &lie, "").await;452
+ assert_eq!(caught.status(), StatusCode::GONE);453
+ assert_eq!(454
+ json(caught).await["type"],455
+ "https://jelq.dev/problems/caught"456
+ );457
+}458
+459
+#[tokio::test]460
+async fn forged_ruler_cookies_are_lies() {461
+ let app = jelqd::app();462
+ let forged = send(463
+ &app,464
+ "GET",465
+ "/leaderboard?sort=content-length",466
+ &[("cookie", "ruler=1")],467
+ "",468
+ )469
+ .await;470
+ assert_eq!(forged.status(), StatusCode::BAD_REQUEST);471
+}472
+473
+#[tokio::test]474
+async fn seeded_runs_are_unranked() {475
+ let app = jelqd::app();476
+ send(&app, "POST", "/runs?seed=7", &[], "").await;477
+ let board = json(send(&app, "GET", "/leaderboard", &[], "").await).await;478
+ assert_eq!(board["entries"].as_array().unwrap().len(), 0);479
+ let all = json(send(&app, "GET", "/leaderboard?seeded=include", &[], "").await).await;480
+ assert_eq!(all["entries"][0]["ranked"], false);481
+}482
+483
+#[tokio::test]484
+async fn the_root_negotiates() {485
+ let app = jelqd::app();486
+ let browser = send(&app, "GET", "/", &[("accept", "text/html,*/*")], "").await;487
+ assert_eq!(browser.status(), StatusCode::SEE_OTHER);488
+ assert_eq!(browser.headers()[header::LOCATION], "/ui/");489
+ let curl = send(&app, "GET", "/", &[("accept", "*/*")], "").await;490
+ assert_eq!(json(curl).await["ui"], "/ui/");491
+ let page = send(&app, "GET", "/ui/", &[], "").await;492
+ assert_eq!(page.status(), StatusCode::OK);493
+}494
+495
+#[tokio::test]496
+async fn the_ui_replays_what_browsers_cannot_send() {497
+ let app = jelqd::app();498
+ let envelope =499
+ r#"{"method": "TRACE", "path": "/graveyard", "headers": [["X-Confession", "I did it"]]}"#;500
+ let traced = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;501
+ assert_eq!(traced["status"], 405);502
+ let envelope = r#"{"method": "GET", "path": "/", "headers": [["Accept", "text/html"]]}"#;503
+ let redirected = json(send(&app, "POST", "/ui/send", &[], envelope).await).await;504
+ assert_eq!(redirected["status"], 303);505
+ assert_eq!(redirected["reason"], "See Other");506
+}