atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit 99524cf9f17c

tarball

atagen <boss@atagen.co>2026-10-10 15:15 UTC

12 files changed+611-43
jelqd: add the web ui, signed cookies, and permadeath for careless play

Cookies were the only client-held state, so forging ruler=1 unlocked the truthful leaderboard without a Ruler. They are now HMAC-signed with JELQD_SECRET. Seeds are mixed with the same secret because public source plus a public seed predicted every roll offline, and seeded runs are unranked since replaying a seed still reveals them. The ui replays requests in-process through /ui/send because fetch hides 3xx responses and refuses TRACE, Upgrade, and Expect.
Modified file: Cargo.lock+89 additions
@@ -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"
Modified file: Cargo.toml+3 additions−2 deletions
@@ -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+
Modified file: flake.nix+4 additions−1 deletions
@@ -19,7 +19,10 @@
1919 let
2020 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;
Modified file: src/game/encounters.rs+38 additions−14 deletions
@@ -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();
Modified file: src/game/jelq.rs+9 additions−4 deletions
@@ -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 8
281281 } else {
282282 0
283283 };
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",
Modified file: src/game/mod.rs+23 additions−5 deletions
@@ -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(),
Modified file: src/http.rs+154 additions−15 deletions
@@ -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> = runs
1081+ .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 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+
10721211 async fn not_found() -> Problem {
10731212 Problem::new(
10741213 StatusCode::NOT_FOUND,
Modified file: src/lib.rs+1 additions
@@ -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};
Modified file: src/problem.rs+2 additions−2 deletions
@@ -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);
Added file: src/secret.rs+52 additions
@@ -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+ mac
16+}
17+
18+fn hex(bytes: &[u8]) -> String {
19+ bytes.iter().fold(String::new(), |mut out, b| {
20+ let _ = write!(out, "{b:02x}");
21+ out
22+ })
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+}
Added file: src/ui.html+171 additions
@@ -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+technique
27+<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-Type
41+<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>
Modified file: tests/http.rs+65 additions
@@ -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+}