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
9use crate::{code, ws};
10use 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.
21pub const REPORT: usize = 64 << 10;
22/// Reports kept an hour from one address (an IPv6 /64), and from everyone.
23pub const PER_ADDRESS: usize = 10;
24pub const PER_HOUR: usize = 500;
25const HOUR: Duration = Duration::from_secs(60 * 60);
26
27/// What the site serves.
28#[derive(Clone, Debug)]
29pub 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)]
43struct Received {
44 by_address: HashMap<IpAddr, VecDeque<Instant>>,
45 all: VecDeque<Instant>,
46}
47
48impl 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.
72pub 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
85fn 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.
154fn 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.
213fn 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.
241fn 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
257fn 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.
276fn 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)]
310mod 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}