1use std::fs;
2use std::io::{BufRead, BufReader, Read, Write};
3use std::net::{SocketAddr, TcpListener, TcpStream};
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result, bail};
8
9const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
10
11pub fn serve_progress(bind: SocketAddr, progress: &Path, token: &str) -> Result<()> {
15 if token.len() < 16 {
16 bail!("--token must be at least 16 characters long");
17 }
18 if let Some(parent) = progress.parent() {
19 fs::create_dir_all(parent)
20 .with_context(|| format!("creating sync directory {}", parent.display()))?;
21 }
22 let listener =
23 TcpListener::bind(bind).with_context(|| format!("binding sync server at {bind}"))?;
24 println!("Haqor progress sync listening on http://{bind}/v1/progress");
25 println!("Keep the token private: anyone with it can read and change your learning progress.");
26 for stream in listener.incoming() {
27 match stream {
28 Ok(stream) => {
29 if let Err(error) = handle(stream, progress, token) {
30 eprintln!("sync request failed: {error:#}");
31 }
32 }
33 Err(error) => eprintln!("sync connection failed: {error}"),
34 }
35 }
36 Ok(())
37}
38
39fn handle(stream: TcpStream, progress: &Path, token: &str) -> Result<()> {
40 let mut reader = BufReader::new(stream.try_clone()?);
41 let mut request_line = String::new();
42 reader.read_line(&mut request_line)?;
43 let mut authorization = None;
44 let mut content_length = None;
45 loop {
46 let mut line = String::new();
47 if reader.read_line(&mut line)? == 0 {
48 bail!("connection closed before request headers");
49 }
50 if line == "\r\n" {
51 break;
52 }
53 let Some((name, value)) = line.split_once(':') else {
54 respond(stream, "400 Bad Request", b"malformed header")?;
55 return Ok(());
56 };
57 match name.to_ascii_lowercase().as_str() {
58 "authorization" => authorization = Some(value.trim().to_owned()),
59 "content-length" => content_length = value.trim().parse::<usize>().ok(),
60 _ => {}
61 }
62 }
63 if authorization.as_deref() != Some(&format!("Bearer {token}")) {
64 respond(stream, "401 Unauthorized", b"unauthorized")?;
65 return Ok(());
66 }
67 let request = request_line.trim_end();
68 if request == "GET /health HTTP/1.1" || request == "GET /health HTTP/1.0" {
69 respond(stream, "200 OK", b"ok")?;
70 return Ok(());
71 }
72 if request == "GET /v1/progress HTTP/1.1" || request == "GET /v1/progress HTTP/1.0" {
73 let snapshot =
74 fs::read(progress).with_context(|| format!("reading {}", progress.display()))?;
75 respond_with_snapshot(stream, &snapshot)?;
76 return Ok(());
77 }
78 if request != "POST /v1/progress HTTP/1.1" && request != "POST /v1/progress HTTP/1.0" {
79 respond(stream, "404 Not Found", b"not found")?;
80 return Ok(());
81 }
82 let Some(length) = content_length else {
83 respond(stream, "411 Length Required", b"content-length required")?;
84 return Ok(());
85 };
86 if length > MAX_SNAPSHOT_BYTES {
87 respond(stream, "413 Payload Too Large", b"snapshot too large")?;
88 return Ok(());
89 }
90 let mut body = vec![0; length];
91 reader.read_exact(&mut body)?;
92 if !haqor_core::progress_sync::is_sqlite_snapshot(&body) {
93 respond(stream, "400 Bad Request", b"expected a SQLite snapshot")?;
94 return Ok(());
95 }
96 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
97 let incoming =
98 std::env::temp_dir().join(format!("haqor-sync-{}-{nonce}.db", std::process::id()));
99 fs::write(&incoming, body)?;
100 let result = haqor_core::progress_sync::merge_progress_files(progress, &incoming)
101 .context("merging received progress snapshot");
102 let _ = fs::remove_file(&incoming);
103 result?;
104 let snapshot = fs::read(progress).with_context(|| format!("reading {}", progress.display()))?;
105 respond_with_snapshot(stream, &snapshot)
106}
107
108fn respond(mut stream: TcpStream, status: &str, body: &[u8]) -> Result<()> {
109 write!(
110 stream,
111 "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
112 body.len()
113 )?;
114 stream.write_all(body)?;
115 Ok(())
116}
117
118fn respond_with_snapshot(mut stream: TcpStream, snapshot: &[u8]) -> Result<()> {
119 write!(
120 stream,
121 "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.sqlite3\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
122 snapshot.len()
123 )?;
124 stream.write_all(snapshot)?;
125 Ok(())
126}