| 1 | //! `snowbound-site`, snowbound.paperclover.net: the hosted web build's files from a folder, |
| 2 | //! and for a path that is a Live Share code (`/7KQ-4MZ-9XR`) a page that opens it in |
| 3 | //! Snowbound, or in the web build where `Site::web` says it joins. A build's module and |
| 4 | //! JavaScript sit in `b/<hash>/`, named by their contents, so they are kept for good; |
| 5 | //! `index.html`, which names them, is checked on every load. `POST /crash` keeps a crash |
| 6 | //! report Snowbound sends as a file of its own; everything else is GET and HEAD. A proxy in |
| 7 | //! front terminates TLS. |
| 8 | |
| 9 | use crate::{code, ws}; |
| 10 | use std::{ |
| 11 | collections::{HashMap, VecDeque}, |
| 12 | io::{self, BufReader, Read, Write}, |
| 13 | net::{IpAddr, TcpListener, TcpStream}, |
| 14 | path::{Component, Path, PathBuf}, |
| 15 | sync::{Arc, Mutex}, |
| 16 | thread, |
| 17 | time::{Duration, Instant, SystemTime}, |
| 18 | }; |
| 19 | |
| 20 | /// The largest crash report kept. |
| 21 | pub const REPORT: usize = 64 << 10; |
| 22 | /// Reports kept an hour from one address (an IPv6 /64), and from everyone. |
| 23 | pub const PER_ADDRESS: usize = 10; |
| 24 | pub const PER_HOUR: usize = 500; |
| 25 | const HOUR: Duration = Duration::from_secs(60 * 60); |
| 26 | |
| 27 | /// What the site serves. |
| 28 | #[derive(Clone, Debug)] |
| 29 | pub struct Site { |
| 30 | /// The web build's folder; `/` is its `index.html`. |
| 31 | pub root: PathBuf, |
| 32 | /// Where Open in Web goes, the code appended (`https://snowbound.paperclover.net/?join=`); |
| 33 | /// none offers only Snowbound. |
| 34 | pub web: Option<String>, |
| 35 | /// Where each crash report is kept, a file apiece. |
| 36 | pub crashes: PathBuf, |
| 37 | /// Counts senders by the address the proxy in front gives in `X-Forwarded-For`. |
| 38 | pub trust_forwarded: bool, |
| 39 | } |
| 40 | |
| 41 | /// When the reports of the last hour came, by sender and in all. |
| 42 | #[derive(Default)] |
| 43 | struct Received { |
| 44 | by_address: HashMap<IpAddr, VecDeque<Instant>>, |
| 45 | all: VecDeque<Instant>, |
| 46 | } |
| 47 | |
| 48 | impl Received { |
| 49 | /// Counts a report from `address` at `now`, unless it is one too many. |
| 50 | fn admit(&mut self, address: IpAddr, now: Instant) -> bool { |
| 51 | let recent = |times: &mut VecDeque<Instant>| { |
| 52 | while times.front().is_some_and(|&time| now - time >= HOUR) { |
| 53 | times.pop_front(); |
| 54 | } |
| 55 | }; |
| 56 | recent(&mut self.all); |
| 57 | self.by_address.retain(|_, times| { |
| 58 | recent(times); |
| 59 | !times.is_empty() |
| 60 | }); |
| 61 | let sent = self.by_address.entry(address).or_default(); |
| 62 | if sent.len() >= PER_ADDRESS || self.all.len() >= PER_HOUR { |
| 63 | return false; |
| 64 | } |
| 65 | sent.push_back(now); |
| 66 | self.all.push_back(now); |
| 67 | true |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Serves `site` on `listener` until it fails. |
| 72 | pub fn serve(listener: TcpListener, site: Site) -> io::Result<()> { |
| 73 | let shared = Arc::new((site, Mutex::default())); |
| 74 | for stream in listener.incoming() { |
| 75 | let Ok(stream) = stream else { continue }; |
| 76 | let shared = Arc::clone(&shared); |
| 77 | thread::spawn(move || { |
| 78 | let (site, received) = &*shared; |
| 79 | let _ = answer(site, received, stream); |
| 80 | }); |
| 81 | } |
| 82 | Ok(()) |
| 83 | } |
| 84 | |
| 85 | fn answer(site: &Site, received: &Mutex<Received>, stream: TcpStream) -> io::Result<()> { |
| 86 | stream.set_read_timeout(Some(Duration::from_secs(10)))?; |
| 87 | stream.set_write_timeout(Some(Duration::from_secs(30)))?; |
| 88 | let mut reader = BufReader::new(&stream); |
| 89 | let head = ws::head(&mut reader)?; |
| 90 | let mut words = head.split(' '); |
| 91 | let (method, target) = ( |
| 92 | words.next().unwrap_or_default(), |
| 93 | words.next().unwrap_or("/"), |
| 94 | ); |
| 95 | let path = target.split(['?', '#']).next().unwrap_or("/"); |
| 96 | let (status, kind, body) = if (method, path) == ("POST", "/crash") { |
| 97 | let address = crate::peer(&stream, &head, site.trust_forwarded); |
| 98 | let admit = || { |
| 99 | received |
| 100 | .lock() |
| 101 | .is_ok_and(|mut received| received.admit(address, Instant::now())) |
| 102 | }; |
| 103 | let status = keep_crash(&site.crashes, &head, &mut reader, admit); |
| 104 | ( |
| 105 | status, |
| 106 | "text/plain", |
| 107 | format!("{}\n", &status[4..]).into_bytes(), |
| 108 | ) |
| 109 | } else if !matches!(method, "GET" | "HEAD") { |
| 110 | ( |
| 111 | "405 Method Not Allowed", |
| 112 | "text/plain", |
| 113 | b"GET only\n".to_vec(), |
| 114 | ) |
| 115 | } else if let Some((number, secret)) = code::parse(path.trim_matches('/')) { |
| 116 | let shown = code::format(number, &secret).unwrap_or_default(); |
| 117 | ( |
| 118 | "200 OK", |
| 119 | "text/html; charset=utf-8", |
| 120 | landing(&shown, site).into_bytes(), |
| 121 | ) |
| 122 | } else { |
| 123 | match file(&site.root, path) { |
| 124 | Some(file) => match std::fs::read(&file) { |
| 125 | Ok(bytes) => ("200 OK", content_type(&file), bytes), |
| 126 | Err(_) => ("404 Not Found", "text/plain", b"Not found\n".to_vec()), |
| 127 | }, |
| 128 | None => ("404 Not Found", "text/plain", b"Not found\n".to_vec()), |
| 129 | } |
| 130 | }; |
| 131 | let cache = if method == "POST" || !status.starts_with("200") || kind.starts_with("text/html") { |
| 132 | "no-cache" |
| 133 | } else if path.starts_with("/b/") { |
| 134 | "public, max-age=31536000, immutable" |
| 135 | } else { |
| 136 | // Fonts and dictionaries. |
| 137 | "public, max-age=86400" |
| 138 | }; |
| 139 | let mut writer = &stream; |
| 140 | write!( |
| 141 | writer, |
| 142 | "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nCache-Control: {cache}\r\n\ |
| 143 | X-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", |
| 144 | body.len() |
| 145 | )?; |
| 146 | if method != "HEAD" { |
| 147 | writer.write_all(&body)?; |
| 148 | } |
| 149 | Ok(()) |
| 150 | } |
| 151 | |
| 152 | /// Keeps the report a `POST /crash` with `head` brings in `reader` in `folder`, where it is |
| 153 | /// small enough, plain text or JSON, and `admit` lets it in; answers the status. |
| 154 | fn keep_crash( |
| 155 | folder: &Path, |
| 156 | head: &str, |
| 157 | reader: &mut impl Read, |
| 158 | admit: impl FnOnce() -> bool, |
| 159 | ) -> &'static str { |
| 160 | let kind = ws::header(head, "Content-Type").unwrap_or_default(); |
| 161 | let kind = kind.split(';').next().unwrap_or_default().trim(); |
| 162 | let extension = if kind.eq_ignore_ascii_case("text/plain") { |
| 163 | "txt" |
| 164 | } else if kind.eq_ignore_ascii_case("application/json") { |
| 165 | "json" |
| 166 | } else { |
| 167 | return "415 Unsupported Media Type"; |
| 168 | }; |
| 169 | if ws::header(head, "Transfer-Encoding").is_some() { |
| 170 | return "411 Length Required"; |
| 171 | } |
| 172 | let Some(length) = ws::header(head, "Content-Length").and_then(|value| value.parse().ok()) |
| 173 | else { |
| 174 | return "411 Length Required"; |
| 175 | }; |
| 176 | if length == 0 { |
| 177 | return "400 Bad Request"; |
| 178 | } |
| 179 | if length > REPORT { |
| 180 | return "413 Content Too Large"; |
| 181 | } |
| 182 | if !admit() { |
| 183 | return "429 Too Many Requests"; |
| 184 | } |
| 185 | let mut report = vec![0; length]; |
| 186 | if reader.read_exact(&mut report).is_err() || std::str::from_utf8(&report).is_err() { |
| 187 | return "400 Bad Request"; |
| 188 | } |
| 189 | let mut tag = [0; 4]; |
| 190 | let _ = getrandom::fill(&mut tag); |
| 191 | let tag: String = tag.iter().map(|byte| format!("{byte:02x}")).collect(); |
| 192 | let file = folder.join(format!( |
| 193 | "{}-{tag}.{extension}", |
| 194 | timestamp(SystemTime::now()) |
| 195 | )); |
| 196 | let kept = std::fs::create_dir_all(folder).and_then(|()| { |
| 197 | std::fs::OpenOptions::new() |
| 198 | .write(true) |
| 199 | .create_new(true) |
| 200 | .open(&file)? |
| 201 | .write_all(&report) |
| 202 | }); |
| 203 | match kept { |
| 204 | Ok(()) => "200 OK", |
| 205 | Err(error) => { |
| 206 | eprintln!("Cannot keep a crash report in {}: {error}", file.display()); |
| 207 | "500 Internal Server Error" |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | /// `time` in UTC as `2026-10-03T21-04-05Z`, which sorts as it reads and names a file anywhere. |
| 213 | fn timestamp(time: SystemTime) -> String { |
| 214 | let seconds = time |
| 215 | .duration_since(SystemTime::UNIX_EPOCH) |
| 216 | .map_or(0, |since| since.as_secs()); |
| 217 | let (days, of_day) = (seconds / 86_400, seconds % 86_400); |
| 218 | // Howard Hinnant's civil_from_days, from 1970-01-01. |
| 219 | let shifted = days + 719_468; |
| 220 | let era = shifted / 146_097; |
| 221 | let of_era = shifted % 146_097; |
| 222 | let year_of_era = (of_era - of_era / 1_460 + of_era / 36_524 - of_era / 146_096) / 365; |
| 223 | let of_year = of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); |
| 224 | let month_index = (5 * of_year + 2) / 153; |
| 225 | let day = of_year - (153 * month_index + 2) / 5 + 1; |
| 226 | let month = if month_index < 10 { |
| 227 | month_index + 3 |
| 228 | } else { |
| 229 | month_index - 9 |
| 230 | }; |
| 231 | let year = year_of_era + era * 400 + u64::from(month <= 2); |
| 232 | format!( |
| 233 | "{year:04}-{month:02}-{day:02}T{:02}-{:02}-{:02}Z", |
| 234 | of_day / 3_600, |
| 235 | of_day / 60 % 60, |
| 236 | of_day % 60 |
| 237 | ) |
| 238 | } |
| 239 | |
| 240 | /// The file `path` names in `root`, `index.html` for a folder; none outside it. |
| 241 | fn file(root: &Path, path: &str) -> Option<PathBuf> { |
| 242 | let relative = Path::new(path.trim_start_matches('/')); |
| 243 | if relative |
| 244 | .components() |
| 245 | .any(|part| !matches!(part, Component::Normal(_))) |
| 246 | || path.contains(['%', '\\', '\0']) |
| 247 | { |
| 248 | return None; |
| 249 | } |
| 250 | let mut file = root.join(relative); |
| 251 | if file.is_dir() { |
| 252 | file = file.join("index.html"); |
| 253 | } |
| 254 | file.is_file().then_some(file) |
| 255 | } |
| 256 | |
| 257 | fn content_type(file: &Path) -> &'static str { |
| 258 | match file.extension().and_then(|extension| extension.to_str()) { |
| 259 | Some("html") => "text/html; charset=utf-8", |
| 260 | Some("js") => "text/javascript; charset=utf-8", |
| 261 | Some("wasm") => "application/wasm", |
| 262 | Some("css") => "text/css; charset=utf-8", |
| 263 | Some("json") => "application/json", |
| 264 | Some("svg") => "image/svg+xml", |
| 265 | Some("png") => "image/png", |
| 266 | Some("ico") => "image/x-icon", |
| 267 | Some("ttf") => "font/ttf", |
| 268 | Some("otf") => "font/otf", |
| 269 | Some("gz") => "application/gzip", |
| 270 | Some("txt") => "text/plain; charset=utf-8", |
| 271 | _ => "application/octet-stream", |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /// The page a shared notebook's link opens: the code, and the ways to open it. |
| 276 | fn landing(shown: &str, site: &Site) -> String { |
| 277 | let web = site.web.as_ref().map_or_else(String::new, |web| { |
| 278 | format!(r#"<a class="other" href="{web}{shown}">Open in Web</a>"#) |
| 279 | }); |
| 280 | format!( |
| 281 | r#"<!doctype html> |
| 282 | <html lang="en"> |
| 283 | <meta charset="utf-8"> |
| 284 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 285 | <title>Shared notebook {shown} · Snowbound</title> |
| 286 | <style> |
| 287 | :root {{ color-scheme: light dark; font-family: system-ui, sans-serif; }} |
| 288 | body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas; color: CanvasText; }} |
| 289 | main {{ max-width: 26rem; padding: 2rem; text-align: center; }} |
| 290 | h1 {{ font-size: 1.3rem; font-weight: 600; margin: 0 0 .5rem; }} |
| 291 | p {{ margin: .5rem 0 1.5rem; opacity: .75; }} |
| 292 | code {{ display: block; font: 600 2rem ui-monospace, monospace; letter-spacing: .08em; margin: 1rem 0 1.5rem; }} |
| 293 | a {{ display: inline-block; margin: .25rem; padding: .6rem 1.1rem; border-radius: .5rem; text-decoration: none; font-weight: 600; }} |
| 294 | .open {{ background: #3768c7; color: white; }} |
| 295 | .other {{ border: 1px solid #3768c788; color: inherit; }} |
| 296 | </style> |
| 297 | <main> |
| 298 | <h1>Someone shared a notebook with you</h1> |
| 299 | <code>{shown}</code> |
| 300 | <a class="open" href="snowbound://join/{shown}">Open in Snowbound</a> |
| 301 | {web} |
| 302 | <p>If Snowbound doesn’t open, open it yourself, choose Open Shared Notebook, and enter this code.</p> |
| 303 | </main> |
| 304 | </html> |
| 305 | "# |
| 306 | ) |
| 307 | } |
| 308 | |
| 309 | #[cfg(test)] |
| 310 | mod tests { |
| 311 | use super::*; |
| 312 | |
| 313 | #[test] |
| 314 | fn timestamps_are_utc_dates_and_times() { |
| 315 | let at = |seconds| timestamp(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)); |
| 316 | assert_eq!(at(0), "1970-01-01T00-00-00Z"); |
| 317 | assert_eq!(at(951_782_400), "2000-02-29T00-00-00Z"); |
| 318 | assert_eq!(at(1_791_072_245), "2026-10-04T00-04-05Z"); |
| 319 | } |
| 320 | } |