Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
9.9 kB · 258 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259//! 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;
/// The lexicon's `tags`: at most 8, each at most 64 characters. Which values/// are allowed is not a limit the lexicon states — they are "drawn from the/// space's tag list", so only the board can answer that, and `create_flare`/// asks it while it is fetching the space record anyway.const TAGS_MAX: usize = 8;const TAG_MAX_CHARS: usize = 64;
/// 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<String, Rejected> { 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<String, Rejected> { 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())}
/// What the player ticked, held to the lexicon's shape.////// Duplicates are dropped rather than refused: two boxes cannot be ticked/// twice in either form, so a repeat means a hand-made request, and one tag/// listed once is what it was asking for either way. Order is the form's.pub fn clean_tags(raw: &[String]) -> Result<Vec<String>, Rejected> { let mut tags: Vec<String> = Vec::with_capacity(raw.len().min(TAGS_MAX)); for tag in raw { let tag = tag.trim(); if tag.is_empty() { continue; } if tag.chars().count() > TAG_MAX_CHARS { return Err("That tag is too long."); } if tag.chars().any(|c| c.is_control()) { return Err("That tag contains characters that are not allowed."); } if !tags.iter().any(|kept| kept == tag) { tags.push(tag.to_owned()); } } if tags.len() > TAGS_MAX { return Err("That is more tags than a report can carry."); } Ok(tags)}
/// 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<Vec<u8>, 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 = crate::png_util::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)}
#[cfg(test)]mod tests { use super::*; use rand::{RngCore, SeedableRng};
fn png_of(width: u32, height: u32) -> Vec<u8> { 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 tags_are_held_to_the_lexicon() { assert_eq!( clean_tags(&["bug".into(), " feature ".into()]).unwrap(), ["bug", "feature"] ); // Two boxes cannot be ticked twice, so a repeat is a hand-made // request asking for the one tag it named. assert_eq!(clean_tags(&["bug".into(), "bug".into()]).unwrap(), ["bug"]); assert_eq!( clean_tags(&["".into(), " ".into()]).unwrap(), Vec::<String>::new() ); assert!(clean_tags(&["a".repeat(TAG_MAX_CHARS)]).is_ok()); assert!(clean_tags(&["a".repeat(TAG_MAX_CHARS + 1)]).is_err()); assert!(clean_tags(&["bu\u{0000}g".into()]).is_err());
let many: Vec<String> = (0..=TAGS_MAX).map(|n| n.to_string()).collect(); assert!(clean_tags(&many).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()); }
/// Dimensions alone (`absurd_screenshots_are_refused`) do not catch this: /// a flat-colour image of the same size would compress under the cap and /// pass. Random pixel data does not deflate away, so the re-encoded PNG /// stays close to its raw size and lands over `IMAGE_MAX_BYTES`. #[test] fn oversized_after_reencode_is_refused() { let (width, height) = (700u32, 700u32); let mut pixels = vec![0u8; (width * height * 4) as usize]; rand::rngs::StdRng::seed_from_u64(0xf1a4e).fill_bytes(&mut pixels);
let mut source = Vec::new(); let mut encoder = png::Encoder::new(&mut source, 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(&pixels).unwrap(); drop(writer); assert!( source.len() > IMAGE_MAX_BYTES, "fixture must already be over the cap going in: {}", source.len() );
assert_eq!( reencode_screenshot(&source), Err("That screenshot is too large. Try a smaller one.") ); }}