diff --git a/.env.example b/.env.example index f21f2f1..d8cc44f 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ # without it in development, sessions do not survive a restart. # SESSION_SECRET= +# The userinput.app space flares are filed to, as its at:// URI. Unset, +# /api/flare answers that flares are not enabled. +# FLARE_SPACE=at://did:plc:a2j2g42ai6v65qpbvb6hmubi/app.userinput.space/3msr5yrvtq22g + # --- confidential client (public deployments only) --------------------------- # Setting PUBLIC_URL switches to the confidential client: metadata served at # PUBLIC_URL/oauth/client-metadata.json, keys at /.well-known/jwks.json, diff --git a/services/api/src/atproto/client.rs b/services/api/src/atproto/client.rs index 361d10b..f901000 100644 --- a/services/api/src/atproto/client.rs +++ b/services/api/src/atproto/client.rs @@ -91,7 +91,7 @@ pub fn scopes() -> Vec { Scope::Known(KnownScope::Atproto), Scope::Unknown(format!("repo:{}", super::CAMO_NSID)), Scope::Unknown("blob:image/png".to_owned()), - Scope::Unknown("include:app.userinput.authBasic".to_owned()), + Scope::Unknown(super::FLARE_SCOPE.to_owned()), ] } diff --git a/services/api/src/atproto/mod.rs b/services/api/src/atproto/mod.rs index d83eb5e..11537ce 100644 --- a/services/api/src/atproto/mod.rs +++ b/services/api/src/atproto/mod.rs @@ -23,10 +23,22 @@ use atrium_xrpc::{InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest}; use crate::config::Config; use crate::db::Db; -/// The one collection this service writes. Named here because the OAuth -/// scope, the record's `$type` and every repo call have to agree. +/// The camo collection. Named here because the OAuth scope, the record's +/// `$type` and every repo call have to agree. pub const CAMO_NSID: &str = "blue.lance.camo"; +/// A flare's record type: userinput.app's discussion, in the player's repo. +pub const DISCUSSION_NSID: &str = "app.userinput.discussion"; + +/// The space collection a flare's target lives in, and the shape FLARE_SPACE +/// must point at. +pub const SPACE_NSID: &str = "app.userinput.space"; + +/// The scope that covers writing a discussion — userinput.app's own +/// published permission set. One string, used by the scope list and by the +/// stored-grant check, so they cannot drift apart. +pub const FLARE_SCOPE: &str = "include:app.userinput.authBasic"; + pub enum LoginError { /// The input is not a handle. InvalidHandle, @@ -499,6 +511,140 @@ impl Atproto { }) } + /// Files a flare: an `app.userinput.discussion` in the player's own + /// repository, pointing at the board's space. + /// + /// The space's strongRef is fetched fresh from its owner's PDS on every + /// flare — the ref must carry the space record's current CID, and caching + /// one would break every flare the moment the board is renamed. + pub async fn create_flare( + &self, + did: &str, + title: &str, + body: &str, + png: Option>, + space_uri: &str, + ) -> Result { + let did = self.flare_writable(did, png.is_some()).await?; + + let space = self.space_ref(space_uri).await?; + let session = self.session(&did).await?; + // Before the agent consumes the session; an unreferenced blob from a + // write that then fails is collected by the PDS within hours. + let image = match png { + Some(png) => Some(upload_png(&session, png).await?), + None => None, + }; + + let mut record = serde_json::Map::new(); + record.insert("$type".into(), DISCUSSION_NSID.into()); + record.insert("space".into(), space); + record.insert("title".into(), title.into()); + if !body.is_empty() { + record.insert("body".into(), body.into()); + } + if let Some(image) = image { + record.insert( + "images".into(), + serde_json::json!([{ "image": blob_json(image)?, "alt": "" }]), + ); + } + record.insert("createdAt".into(), Datetime::now().as_str().into()); + + let input = create_record::InputData { + collection: discussion_nsid(), + record: to_unknown(record)?, + repo: did.clone().into(), + rkey: None, + swap_commit: None, + validate: None, + }; + let agent = Agent::new(session); + match agent.api.com.atproto.repo.create_record(input.into()).await { + Ok(output) => { + tracing::info!(did = did.as_str(), "flare: discussion written"); + Ok(Written { + uri: output.data.uri.clone(), + cid: output.data.cid.as_ref().to_string(), + }) + } + Err(e) => Err(write_failed("create discussion", &e)), + } + } + + /// The board space's strongRef `{uri, cid}`, read from its owner's PDS. + async fn space_ref(&self, space_uri: &str) -> Result { + let (space_did, rkey) = split_space_uri(space_uri).ok_or_else(|| { + // FLARE_SPACE was validated at startup; reaching this is a bug. + tracing::error!("flare: configured space URI does not parse"); + WriteError::Upstream + })?; + let Ok(space_did) = Did::new(space_did.to_owned()) else { + tracing::error!("flare: configured space DID is not a DID"); + return Err(WriteError::Upstream); + }; + let document = self.did_resolver.resolve(&space_did).await.map_err(|_| { + tracing::warn!("flare: space owner's DID document could not be read"); + WriteError::Upstream + })?; + let Some(pds) = document.get_pds_endpoint() else { + tracing::warn!("flare: space owner's DID document names no PDS"); + return Err(WriteError::Upstream); + }; + let record = self + .fetch_json(&format!( + "{pds}/xrpc/com.atproto.repo.getRecord\ + ?repo={did}&collection={SPACE_NSID}&rkey={rkey}", + did = space_did.as_str() + )) + .await + .map_err(|_| { + tracing::warn!("flare: space record could not be read"); + WriteError::Upstream + })?; + let Some(cid) = record.get("cid").and_then(serde_json::Value::as_str) else { + tracing::warn!("flare: space record answered without a CID"); + return Err(WriteError::Upstream); + }; + if !is_blob_cid(cid) { + tracing::warn!("flare: space record's CID is not shaped like one"); + return Err(WriteError::Upstream); + } + Ok(serde_json::json!({ "uri": space_uri, "cid": cid })) + } + + /// The DID as a DID, once the stored grant is known to cover a flare. + async fn flare_writable(&self, did: &str, with_image: bool) -> Result { + let Ok(did) = Did::new(did.to_owned()) else { + tracing::error!("flare: session DID is not a DID"); + return Err(WriteError::Upstream); + }; + if !self.scope_allows_flare(&did, with_image).await { + tracing::info!( + did = did.as_str(), + "flare: stored session predates the userinput grant" + ); + return Err(WriteError::NeedsSignIn); + } + Ok(did) + } + + /// Same reading as `scope_allows_camo`: only a stored scope that + /// positively omits the grant is evidence. + async fn scope_allows_flare(&self, did: &Did, with_image: bool) -> bool { + match self.sessions.get(did).await { + Ok(Some(session)) => match &session.token_set.scope { + Some(granted) => { + let terms: Vec<&str> = granted.split(' ').collect(); + terms.contains(&FLARE_SCOPE) + && (!with_image || terms.contains(&"blob:image/png")) + } + None => true, + }, + _ => true, + } + } + /// One GET at the other end of the world, with a deadline on it. /// /// The host comes out of a DID document the player controls, so it may be @@ -672,6 +818,25 @@ fn camo_nsid() -> atrium_api::types::string::Nsid { CAMO_NSID.parse().expect("CAMO_NSID is a valid NSID") } +fn discussion_nsid() -> atrium_api::types::string::Nsid { + DISCUSSION_NSID + .parse() + .expect("DISCUSSION_NSID is a valid NSID") +} + +/// `at://did/app.userinput.space/rkey` into its DID and record key. Public +/// so config.rs can refuse a FLARE_SPACE that is not shaped like this at +/// startup instead of on the first flare. +pub fn split_space_uri(uri: &str) -> Option<(&str, &str)> { + let rest = uri.strip_prefix("at://")?; + let mut parts = rest.splitn(3, '/'); + let did = parts.next()?; + let collection = parts.next()?; + let rkey = parts.next()?; + (did.starts_with("did:") && collection == SPACE_NSID && !rkey.is_empty() && !rkey.contains('/')) + .then_some((did, rkey)) +} + /// A record key from the path. Attacker-controlled, so it is parsed rather /// than pasted into a request. fn record_key(rkey: &str) -> Result { @@ -893,6 +1058,34 @@ mod tests { fn camo_nsid_is_the_lexicon_id() { assert_eq!(CAMO_NSID, "blue.lance.camo"); assert!(CAMO_NSID.parse::().is_ok()); + assert!( + DISCUSSION_NSID + .parse::() + .is_ok() + ); + } + + #[test] + fn space_uris_split_or_refuse() { + assert_eq!( + split_space_uri("at://did:plc:abc/app.userinput.space/3kxyz"), + Some(("did:plc:abc", "3kxyz")) + ); + assert_eq!( + split_space_uri("at://did:plc:abc/app.userinput.space/"), + None + ); + assert_eq!( + split_space_uri("at://did:plc:abc/blue.lance.camo/3kxyz"), + None, + "wrong collection" + ); + assert_eq!(split_space_uri("https://userinput.app/s/x/y"), None); + assert_eq!( + split_space_uri("at://alice.example/app.userinput.space/3kxyz"), + None, + "a handle is not a DID" + ); } /// The bytes that go on the wire, pinned. diff --git a/services/api/src/config.rs b/services/api/src/config.rs index 7d5a655..ee6c056 100644 --- a/services/api/src/config.rs +++ b/services/api/src/config.rs @@ -32,6 +32,9 @@ pub struct Config { /// (mm-sur--). The task definition pins /// the image in infra, so infra sets this alongside it; display-only here. pub arena_version: Option, + /// The `at://` URI of the userinput.app space flares are filed to. + /// Absent, /api/flare answers that flares are not enabled. + pub flare_space: Option, } #[derive(Debug, Clone)] @@ -207,6 +210,20 @@ impl Config { } }; + // Refused at startup rather than on the first flare: a typo here + // would otherwise sit quiet until a player hit send. + let flare_space = match lookup("FLARE_SPACE") { + None => None, + Some(uri) => { + if crate::atproto::split_space_uri(&uri).is_none() { + return Err(format!( + "FLARE_SPACE {uri:?} is not at:///app.userinput.space/" + )); + } + Some(uri) + } + }; + Ok(Config { bind_addr, public_url, @@ -220,6 +237,7 @@ impl Config { // build`; like empty, it means unset here. build_ref: lookup("BUILD_REF").filter(|s| !s.is_empty() && s != "unknown"), arena_version: lookup("ARENA_VERSION").filter(|s| !s.is_empty()), + flare_space, }) } @@ -332,6 +350,20 @@ mod tests { assert_eq!(config(&[]).unwrap().cookie_domain, None); } + #[test] + fn flare_space_must_be_a_space_uri() { + assert_eq!(config(&[]).unwrap().flare_space, None); + assert_eq!( + config(&[("FLARE_SPACE", "at://did:plc:abc/app.userinput.space/3kxyz")]) + .unwrap() + .flare_space + .as_deref(), + Some("at://did:plc:abc/app.userinput.space/3kxyz") + ); + assert!(config(&[("FLARE_SPACE", "https://userinput.app/s/x/y")]).is_err()); + assert!(config(&[("FLARE_SPACE", "at://did:plc:abc/other.thing/3k")]).is_err()); + } + #[test] fn short_secret_is_refused() { assert!(config(&[("SESSION_SECRET", "short")]).is_err()); diff --git a/services/api/src/flare.rs b/services/api/src/flare.rs new file mode 100644 index 0000000..5e68853 --- /dev/null +++ b/services/api/src/flare.rs @@ -0,0 +1,195 @@ +//! What a flare is allowed to carry. +//! +//! A flare is feedback a player files from the match screen. It becomes an +//! `app.userinput.discussion` in the player's own repository, pointing at the +//! lance.blue board's space on userinput.app, so every limit here is the +//! published lexicon's; sending something the lexicon refuses would fail at +//! the PDS with a worse message. + +use std::io::Cursor; + +pub type Rejected = &'static str; + +/// The lexicon's `title`: 600 bytes, 300 graphemes. Chars over-count +/// graphemes, so holding chars to the grapheme limit stays inside it. +const TITLE_MAX_BYTES: usize = 600; +const TITLE_MAX_CHARS: usize = 300; + +/// The lexicon's `body` is 20,000 bytes and 10,000 graphemes. The cap here +/// leaves room for the match footer this service appends after validation. +const BODY_MAX_BYTES: usize = 19_000; +const BODY_MAX_CHARS: usize = 9_500; + +/// The lexicon's cap on an attached image blob. +pub const IMAGE_MAX_BYTES: usize = 1_000_000; + +/// A screenshot is a whole match screen, not an 84x72 camo. +const DECODE_BYTE_LIMIT: usize = 64 * 1024 * 1024; +const MAX_DIMENSION: u32 = 4096; + +pub fn clean_title(raw: &str) -> Result { + let title = raw.trim(); + if title.is_empty() { + return Err("Give the flare a title."); + } + if title.len() > TITLE_MAX_BYTES || title.chars().count() > TITLE_MAX_CHARS { + return Err("That title is too long."); + } + if title.chars().any(|c| c.is_control()) { + return Err("That title contains characters that are not allowed."); + } + Ok(title.to_owned()) +} + +/// The player's own words. Empty is fine — a screenshot can speak for +/// itself — and line breaks are what a description is made of, so control +/// characters other than `\n`, `\r` and `\t` are the only ones refused. +pub fn clean_body(raw: &str) -> Result { + let body = raw.trim(); + if body.len() > BODY_MAX_BYTES || body.chars().count() > BODY_MAX_CHARS { + return Err("That description is too long."); + } + if body + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) + { + return Err("That description contains characters that are not allowed."); + } + Ok(body.to_owned()) +} + +/// Decodes a screenshot PNG and writes a fresh one from the pixels. +/// +/// Same rule as a camo upload: the bytes go on to other people's browsers +/// via the board, so they are re-encoded rather than passed through, and +/// metadata does not survive the trip. Unlike a camo the dimensions are +/// whatever the match screen was; only absurd ones are refused. +pub fn reencode_screenshot(bytes: &[u8]) -> Result, Rejected> { + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_limits(png::Limits { + bytes: DECODE_BYTE_LIMIT, + }); + decoder.set_transformations( + png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, + ); + + let mut reader = decoder + .read_info() + .map_err(|_| "That screenshot could not be read as a PNG.")?; + let info = reader.info(); + let (width, height) = (info.width, info.height); + if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION { + return Err("That screenshot's dimensions are not usable."); + } + + let mut buffer = vec![0u8; reader.output_buffer_size().unwrap_or(0)]; + let frame = reader + .next_frame(&mut buffer) + .map_err(|_| "That screenshot could not be decoded.")?; + if frame.width != width || frame.height != height || frame.bit_depth != png::BitDepth::Eight { + return Err("That screenshot could not be decoded."); + } + + let rgba = to_rgba( + &buffer[..frame.buffer_size()], + frame.color_type, + width, + height, + ) + .ok_or("That screenshot is in a colour format this does not read.")?; + + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder + .write_header() + .map_err(|_| "The screenshot could not be re-encoded.")?; + writer + .write_image_data(&rgba) + .map_err(|_| "The screenshot could not be re-encoded.")?; + drop(writer); + + // The lexicon's cap is on the stored blob. The match screen downscales + // before sending, so arriving here means that step failed or was skipped. + if out.len() > IMAGE_MAX_BYTES { + return Err("That screenshot is too large. Try a smaller one."); + } + Ok(out) +} + +/// Widens a decoded 8-bit frame to RGBA. `None` for a colour type the +/// decoder's transformations should have already removed. +fn to_rgba(data: &[u8], color: png::ColorType, width: u32, height: u32) -> Option> { + let pixels = (width as usize).checked_mul(height as usize)?; + match color { + png::ColorType::Rgba if data.len() == pixels * 4 => Some(data.to_vec()), + png::ColorType::Rgb if data.len() == pixels * 3 => Some( + data.chunks_exact(3) + .flat_map(|p| [p[0], p[1], p[2], 255]) + .collect(), + ), + png::ColorType::GrayscaleAlpha if data.len() == pixels * 2 => Some( + data.chunks_exact(2) + .flat_map(|p| [p[0], p[0], p[0], p[1]]) + .collect(), + ), + png::ColorType::Grayscale if data.len() == pixels => { + Some(data.iter().flat_map(|&g| [g, g, g, 255]).collect()) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_of(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&vec![0u8; (width * height * 4) as usize]) + .unwrap(); + drop(writer); + out + } + + #[test] + fn titles_are_held_to_the_lexicon() { + assert_eq!( + clean_title(" Stuck on deployment ").unwrap(), + "Stuck on deployment" + ); + assert!(clean_title(" ").is_err()); + assert!(clean_title(&"a".repeat(TITLE_MAX_CHARS)).is_ok()); + assert!(clean_title(&"a".repeat(TITLE_MAX_CHARS + 1)).is_err()); + assert!(clean_title(&"é".repeat(TITLE_MAX_BYTES / 2 + 1)).is_err()); + assert!(clean_title("a\u{0000}b").is_err()); + } + + #[test] + fn bodies_keep_their_line_breaks() { + assert_eq!(clean_body("one\ntwo").unwrap(), "one\ntwo"); + assert_eq!(clean_body("").unwrap(), ""); + assert!(clean_body("a\u{0007}b").is_err()); + assert!(clean_body(&"a".repeat(BODY_MAX_CHARS + 1)).is_err()); + } + + #[test] + fn screenshots_reencode_at_their_own_size() { + let out = reencode_screenshot(&png_of(320, 200)).unwrap(); + let decoder = png::Decoder::new(Cursor::new(&out[..])); + let reader = decoder.read_info().unwrap(); + assert_eq!((reader.info().width, reader.info().height), (320, 200)); + } + + #[test] + fn absurd_screenshots_are_refused() { + assert!(reencode_screenshot(&png_of(MAX_DIMENSION + 1, 8)).is_err()); + assert!(reencode_screenshot(b"not a png").is_err()); + } +} diff --git a/services/api/src/main.rs b/services/api/src/main.rs index c315668..ebc8c9f 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -2,6 +2,7 @@ mod atproto; mod camo; mod config; mod db; +mod flare; mod matches; mod proxy; mod routes; @@ -87,6 +88,7 @@ async fn main() { matches, build_ref: config.build_ref.clone(), arena_version: config.arena_version.clone(), + flare_space: config.flare_space.clone(), }; let listener = tokio::net::TcpListener::bind(config.bind_addr) diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index ab2bb7c..b8d6415 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -30,6 +30,9 @@ pub struct AppState { /// The arena image tag matches launch from; None when infra has not /// said (or in development). pub arena_version: Option, + /// The at:// URI of the userinput.app space flares are filed to; None + /// means /api/flare answers that flares are not enabled. + pub flare_space: Option, } pub fn app(state: AppState) -> Router { @@ -75,6 +78,12 @@ pub fn app(state: AppState) -> Router { // is four thousand times the largest body this can accept. post(set_default_camo).layer(axum::extract::DefaultBodyLimit::max(4 * 1024)), ) + .route( + "/api/flare", + // A screenshot arrives base64-encoded, which puts a 1MB PNG at + // ~1.4MB on the wire; 2MB covers that and the words around it. + post(create_flare).layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)), + ) .route("/api/scenarios", get(list_scenarios)) .route("/api/opponents", get(list_opponents)) .route("/api/matches", post(create_match).get(list_matches)) @@ -539,6 +548,103 @@ async fn set_default_camo( } } +// --- flare ------------------------------------------------------------- +// +// One route, same reason as camo: the browser holds no token, so a post to +// the player's own repository is made from here on their behalf. The +// destination is not our collection at all — the record is userinput.app's +// discussion, aimed at the board space FLARE_SPACE names. + +#[derive(serde::Deserialize)] +struct FlareBody { + title: Option, + body: Option, + /// The match the flare was sent from. Named so the report can be traced + /// to a container run; refused unless the sender fights in it. + #[serde(rename = "matchId")] + match_id: Option, + /// The screenshot PNG, base64. + png: Option, +} + +/// Files a flare to the board: an `app.userinput.discussion` written to the +/// signed-in player's own repository. +async fn create_flare( + State(state): State, + jar: CookieJar, + body: Option>, +) -> Response { + let Some(did) = session_did(&state, &jar) else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + let Some(space) = state.flare_space.clone() else { + return message(StatusCode::NOT_FOUND, "Flares are not enabled here."); + }; + let Some(Json(body)) = body else { + return message(StatusCode::BAD_REQUEST, "Send a JSON body with a title."); + }; + + let title = match crate::flare::clean_title(body.title.as_deref().unwrap_or_default()) { + Ok(title) => title, + Err(why) => return message(StatusCode::BAD_REQUEST, why), + }; + let mut text = match crate::flare::clean_body(body.body.as_deref().unwrap_or_default()) { + Ok(text) => text, + Err(why) => return message(StatusCode::BAD_REQUEST, why), + }; + + // The footer is appended here, not sent by the page: the page could say + // anything, and the one claim worth carrying — this DID fights in this + // match — is one only the match table can make. + if let Some(match_id) = &body.match_id { + if !state + .db + .is_human_player(match_id, &did) + .await + .unwrap_or(false) + { + return message(StatusCode::FORBIDDEN, "That is not your match."); + } + let build = state.build_ref.as_deref().unwrap_or("dev"); + let arena = state.arena_version.as_deref().unwrap_or("unset"); + text.push_str(&format!( + "\n\n[match {match_id} · arena {arena} · hq {build}]" + )); + } + + let png = match &body.png { + None => None, + Some(encoded) => { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) + else { + return message(StatusCode::BAD_REQUEST, "The screenshot could not be read."); + }; + match crate::flare::reencode_screenshot(&bytes) { + Ok(png) => Some(png), + Err(why) => return message(StatusCode::BAD_REQUEST, why), + } + } + }; + + match state + .atproto + .create_flare(&did, &title, &text, png, &space) + .await + { + Ok(w) => { + tracing::info!(did, from_match = body.match_id.is_some(), "flare: filed"); + written(w) + } + // Not write_failed: its consent sentence names camo, and the missing + // grant here is the userinput one. + Err(crate::atproto::WriteError::NeedsSignIn) => message( + StatusCode::FORBIDDEN, + "Your account has not given lance.blue permission to post feedback. Sign out and sign in again to grant it.", + ), + Err(e) => write_failed(e), + } +} + /// The catalog the challenge screen builds its scenario picker from. Static /// data out of the arena image, so no session — like /api/version. async fn list_scenarios() -> Response { @@ -1063,6 +1169,9 @@ mod tests { matches: None, build_ref: config.build_ref, arena_version: config.arena_version, + // Enabled in tests: the write itself never runs (no session gets + // that far), and the routes below assert everything before it. + flare_space: Some("at://did:plc:board/app.userinput.space/3kspace".into()), }; (dir, state) } @@ -1629,6 +1738,64 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + /// Everything /api/flare can decide without talking to a PDS, in the + /// order it decides it: session, then whether flares are on, then the + /// body, then the match claim. + #[tokio::test] + async fn flare_route_checks_session_body_and_match_first() { + let (_dir, state) = loopback_state().await; + let app = app(state.clone()); + + let no_cookie = Request::post("/api/flare") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"title":"Stuck"}"#)) + .unwrap(); + let response = app.clone().oneshot(no_cookie).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let signed_in = |body: &'static str| { + with_cookie( + Request::post("/api/flare").header(header::CONTENT_TYPE, "application/json"), + &state, + ) + .body(Body::from(body)) + .unwrap() + }; + + for (body, why) in [ + (r#"{}"#, "no title"), + (r#"{"title":" "}"#, "blank title"), + (r#"{"title":"Stuck","png":"not base64!"}"#, "not base64"), + (r#"{"title":"Stuck","png":"AAAA"}"#, "not a PNG"), + ] { + let response = app.clone().oneshot(signed_in(body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}"); + assert!(body_json(response).await["message"].is_string(), "{why}"); + } + + // A match the sender does not fight in is refused before any write. + let response = app + .clone() + .oneshot(signed_in(r#"{"title":"Stuck","matchId":"m1"}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn flares_answer_not_found_when_no_space_is_configured() { + let (_dir, mut state) = loopback_state().await; + state.flare_space = None; + let request = with_cookie( + Request::post("/api/flare").header(header::CONTENT_TYPE, "application/json"), + &state, + ) + .body(Body::from(r#"{"title":"Stuck"}"#)) + .unwrap(); + let response = app(state).oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + /// Everything the camo routes can decide without talking to a PDS. The /// writes themselves need a real session against a real server, so they /// are not reachable from here.