an http based roguelike rpg about jelqing
| 1 | //! Plumbing shared by every resource under `/runs/{id}`. |
| 2 | |
| 3 | use axum::{ |
| 4 | http::{HeaderName, HeaderValue, StatusCode, header}, |
| 5 | response::{IntoResponse, Response}, |
| 6 | }; |
| 7 | use hyper_util::rt::TokioIo; |
| 8 | use tokio::io::AsyncWriteExt; |
| 9 | |
| 10 | use super::{AppState, ctx::Ctx, middleware::Compress, resource::Resource, runs}; |
| 11 | use crate::{ |
| 12 | game::{Floor, Run, balance::SUSPICION_LIMIT}, |
| 13 | headers::{ |
| 14 | X_CASH, X_EQ, X_FLOOR, X_FORESKIN, X_GIRTH, X_OBSERVED_BY, X_SELF_REPORTED_LENGTH, X_SHAME, |
| 15 | X_STAMINA, X_SUSPICION, |
| 16 | }, |
| 17 | locale::Locale, |
| 18 | problem::{PROBLEM_BASE, Problem}, |
| 19 | secret::Secret, |
| 20 | }; |
| 21 | |
| 22 | pub const REL_BASE: &str = "https://jelq.dev/rels/"; |
| 23 | const COOKIE_ATTRIBUTES: &str = "Max-Age=31536000; Path=/; HttpOnly; SameSite=Strict"; |
| 24 | /// Which household hazards stand between a request and the run. |
| 25 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 26 | pub enum Scope { |
| 27 | Home, |
| 28 | Pockets, |
| 29 | } |
| 30 | |
| 31 | pub struct RunResource { |
| 32 | pub scope: Scope, |
| 33 | pub resource: Resource<Run>, |
| 34 | } |
| 35 | |
| 36 | /// What the server writes over the connection after a `101`. |
| 37 | #[derive(Clone)] |
| 38 | pub struct Greeting(pub String); |
| 39 | |
| 40 | pub fn serve(state: &AppState, mut ctx: Ctx, target: &RunResource) -> Response { |
| 41 | let mut runs = state.runs(); |
| 42 | let Some(run) = runs.get_mut(ctx.param("id")) else { |
| 43 | return Problem::new( |
| 44 | StatusCode::NOT_FOUND, |
| 45 | "no-such-run", |
| 46 | "There is no such run. There may never have been.", |
| 47 | ) |
| 48 | .into_response(); |
| 49 | }; |
| 50 | if let Some(ending) = &run.ended { |
| 51 | return Problem::new(StatusCode::GONE, "run-ended", "This run has ended.") |
| 52 | .with("cause", format!("{PROBLEM_BASE}{}", ending.cause.slug())) |
| 53 | .with("turn", ending.turn) |
| 54 | .with("graveyard", "/graveyard") |
| 55 | .into_response(); |
| 56 | } |
| 57 | run.last_seen = ctx.now; |
| 58 | run.observed = None; |
| 59 | let response = |
| 60 | gate(run, &ctx, target.scope).unwrap_or_else(|| target.resource.dispatch(run, &ctx)); |
| 61 | let response = finish(run, &ctx, &state.config.secret, response); |
| 62 | drop(runs); |
| 63 | greet(response, &mut ctx) |
| 64 | } |
| 65 | |
| 66 | fn gate(run: &mut Run, ctx: &Ctx, scope: Scope) -> Option<Response> { |
| 67 | if scope == Scope::Pockets { |
| 68 | return None; |
| 69 | } |
| 70 | if run.floor == Floor::Hotel && !run.here.wifi_accepted { |
| 71 | let portal = run.path("/portal"); |
| 72 | return Some( |
| 73 | Problem::new( |
| 74 | StatusCode::NETWORK_AUTHENTICATION_REQUIRED, |
| 75 | "captive-portal", |
| 76 | "The network requires authentication. The hotel would like you to agree to some terms.", |
| 77 | ) |
| 78 | .with("portal", portal.as_str()) |
| 79 | .header(header::LINK, format!("<{portal}>; rel=\"{REL_BASE}portal\"")) |
| 80 | .into_response(), |
| 81 | ); |
| 82 | } |
| 83 | let authorization = ctx |
| 84 | .header(header::AUTHORIZATION) |
| 85 | .map(|text| Locale::negotiate(ctx.header(header::ACCEPT_LANGUAGE)).canonicalise(text)); |
| 86 | run.answer_door(authorization.as_deref()) |
| 87 | .err() |
| 88 | .map(IntoResponse::into_response) |
| 89 | } |
| 90 | |
| 91 | /// Every resource the run can reach right now. The `allow` extension attribute (RFC 8288 §3) |
| 92 | /// carries the methods each one accepts, so a client never has to guess. |
| 93 | fn links(run: &Run) -> String { |
| 94 | let linked: [(&str, &RunResource, bool); 13] = [ |
| 95 | ("", &runs::RUN, true), |
| 96 | ("floor", &runs::FLOOR, true), |
| 97 | ("length", &runs::LENGTH, true), |
| 98 | ("foreskin", &runs::FORESKIN, run.foreskin.is_some()), |
| 99 | ("inventory", &runs::INVENTORY, true), |
| 100 | ("techniques", &runs::TECHNIQUES, true), |
| 101 | ("drawer", &runs::DRAWER, true), |
| 102 | ("grip", &runs::GRIP, true), |
| 103 | ("shop", &runs::SHOP, run.floor.shop_open()), |
| 104 | ("portal", &runs::PORTAL, run.floor == Floor::Hotel), |
| 105 | ("elder", &runs::ELDER, run.floor == Floor::Forum), |
| 106 | ("footage", &runs::FOOTAGE, run.filmed_at.is_some()), |
| 107 | ("diagnosis", &runs::DIAGNOSIS, true), |
| 108 | ]; |
| 109 | linked |
| 110 | .into_iter() |
| 111 | .filter(|(_, _, relevant)| *relevant) |
| 112 | .map(|(rel, target, _)| { |
| 113 | let (path, name) = if rel.is_empty() { |
| 114 | (run.path(""), "run") |
| 115 | } else { |
| 116 | (run.path(&format!("/{rel}")), rel) |
| 117 | }; |
| 118 | let allow = target.resource.allow(run); |
| 119 | format!("<{path}>; rel=\"{REL_BASE}{name}\"; allow=\"{allow}\"") |
| 120 | }) |
| 121 | .collect::<Vec<_>>() |
| 122 | .join(", ") |
| 123 | } |
| 124 | |
| 125 | /// Shame lives in a signed cookie so it follows the player across runs. Each response adds this |
| 126 | /// run's new shame to whatever total the client presents, so two runs at once both count. |
| 127 | fn settle_shame(run: &mut Run, ctx: &Ctx, secret: &Secret) -> u32 { |
| 128 | let presented = ctx |
| 129 | .cookie("shame") |
| 130 | .and_then(|signed| secret.verify("shame", signed)) |
| 131 | .and_then(|total| total.parse::<u32>().ok()); |
| 132 | let total = presented.unwrap_or(run.shame_total) + (run.shame - run.shame_reported); |
| 133 | run.shame_reported = run.shame; |
| 134 | run.shame_total = total; |
| 135 | total |
| 136 | } |
| 137 | |
| 138 | pub fn finish(run: &mut Run, ctx: &Ctx, secret: &Secret, mut response: Response) -> Response { |
| 139 | let status = response.status(); |
| 140 | if status == StatusCode::NOT_MODIFIED { |
| 141 | return response; |
| 142 | } |
| 143 | let shame = settle_shame(run, ctx, secret); |
| 144 | let mut stats: Vec<(HeaderName, String)> = vec![ |
| 145 | ( |
| 146 | X_SELF_REPORTED_LENGTH, |
| 147 | format!("{:.2}", run.self_reported()), |
| 148 | ), |
| 149 | (X_GIRTH, format!("{:.2}", run.girth)), |
| 150 | ( |
| 151 | X_FORESKIN, |
| 152 | run.foreskin.map_or("gone".into(), |f| format!("{f:.2}")), |
| 153 | ), |
| 154 | (X_EQ, run.eq.to_string()), |
| 155 | (X_STAMINA, format!("{}/{}", run.stamina, run.max_stamina())), |
| 156 | (X_SUSPICION, format!("{}/{SUSPICION_LIMIT}", run.suspicion)), |
| 157 | (X_SHAME, shame.to_string()), |
| 158 | (X_CASH, run.cash.to_string()), |
| 159 | (X_FLOOR, run.floor.number().to_string()), |
| 160 | (header::LINK, links(run)), |
| 161 | ]; |
| 162 | if let Some(observer) = run.observed { |
| 163 | stats.push((X_OBSERVED_BY, observer.name().into())); |
| 164 | } |
| 165 | if run.mum.is_some() { |
| 166 | stats.push((header::VIA, "1.1 mum".into())); |
| 167 | stats.push((header::VARY, "Mum".into())); |
| 168 | } |
| 169 | // RFC 9111 obsoleted Warning, which is why nobody heeds it. |
| 170 | if run.conditions.pulled_muscle { |
| 171 | stats.push((header::WARNING, "199 - \"a muscle has been pulled\"".into())); |
| 172 | } |
| 173 | if run.eq > 70 && run.ended.is_none() { |
| 174 | stats.push((header::WARNING, "199 - \"it is turning purple\"".into())); |
| 175 | } |
| 176 | stats.push(( |
| 177 | header::SET_COOKIE, |
| 178 | format!( |
| 179 | "shame={}; {COOKIE_ATTRIBUTES}", |
| 180 | secret.sign("shame", &shame.to_string()) |
| 181 | ), |
| 182 | )); |
| 183 | if run.items.ruler { |
| 184 | stats.push(( |
| 185 | header::SET_COOKIE, |
| 186 | format!("ruler={}; {COOKIE_ATTRIBUTES}", secret.sign("ruler", "1")), |
| 187 | )); |
| 188 | } |
| 189 | let headers = response.headers_mut(); |
| 190 | for (name, value) in stats { |
| 191 | if let Ok(value) = HeaderValue::from_str(&value) { |
| 192 | headers.append(name, value); |
| 193 | } |
| 194 | } |
| 195 | let bodiless = status.is_informational() || status == StatusCode::NO_CONTENT; |
| 196 | if run.items.ring && !bodiless { |
| 197 | response.extensions_mut().insert(Compress); |
| 198 | } |
| 199 | response |
| 200 | } |
| 201 | |
| 202 | fn greet(response: Response, ctx: &mut Ctx) -> Response { |
| 203 | if response.status() != StatusCode::SWITCHING_PROTOCOLS { |
| 204 | return response; |
| 205 | } |
| 206 | let (Some(Greeting(text)), Some(upgrade)) = ( |
| 207 | response.extensions().get::<Greeting>().cloned(), |
| 208 | ctx.upgrade.take(), |
| 209 | ) else { |
| 210 | return response; |
| 211 | }; |
| 212 | tokio::spawn(async move { |
| 213 | if let Ok(upgraded) = upgrade.await { |
| 214 | let mut io = TokioIo::new(upgraded); |
| 215 | let _ = io.write_all(text.as_bytes()).await; |
| 216 | let _ = io.shutdown().await; |
| 217 | } |
| 218 | }); |
| 219 | response |
| 220 | } |