Skip to main content

manabrew_art_cache/
server.rs

1//! Serving a card art cache to a network, so only one machine in a group has to
2//! have gone online.
3//!
4//! Deliberately not `asset_server` with its loopback check relaxed: that one
5//! serves the app's assets and answers its commands. This has one route, never
6//! fetches upstream, and is world-readable to the subnet, which is why it only
7//! starts on a deliberate act.
8
9use std::sync::Arc;
10
11use crate::{key_from_request_path, mime_for, ImageCache};
12
13pub struct ArtServer {
14    pub port: u16,
15    /// Held so `Drop` can `unblock` it: a flag is only read after the next
16    /// request arrives, leaving the port bound and answering.
17    server: Arc<tiny_http::Server>,
18}
19
20impl Drop for ArtServer {
21    fn drop(&mut self) {
22        self.server.unblock();
23    }
24}
25
26impl ArtServer {
27    /// `None` leaves peers falling back to the CDN. The cache is passed in so a
28    /// headless host and the desktop shell can each own one.
29    pub fn spawn(bind_ip: std::net::IpAddr, cache: Arc<ImageCache>) -> Option<ArtServer> {
30        Self::spawn_on(bind_ip, 0, cache)
31    }
32
33    /// Fixed, for a host whose address a client learns from configuration.
34    pub fn spawn_on(
35        bind_ip: std::net::IpAddr,
36        port: u16,
37        cache: Arc<ImageCache>,
38    ) -> Option<ArtServer> {
39        let server = Arc::new(tiny_http::Server::http((bind_ip, port)).ok()?);
40        let port = server.server_addr().to_ip()?.port();
41        let accept = server.clone();
42
43        std::thread::spawn(move || {
44            // `unblock` ends this iterator, closing the listener.
45            for request in accept.incoming_requests() {
46                serve(request, &cache);
47            }
48        });
49
50        Some(ArtServer { port, server })
51    }
52}
53
54fn serve(request: tiny_http::Request, cache: &ImageCache) {
55    let raw = request.url().to_string();
56    let Some(key) = key_from_request_path(&raw) else {
57        let _ = request.respond(tiny_http::Response::empty(404));
58        return;
59    };
60    // Read-only: a host with internet must not become a proxy for the subnet.
61    let Some(bytes) = cache.read(key) else {
62        let _ = request.respond(tiny_http::Response::empty(404));
63        return;
64    };
65    let mut response = tiny_http::Response::from_data(bytes);
66    for (name, value) in [
67        ("Content-Type", mime_for(key)),
68        ("Access-Control-Allow-Origin", "*"),
69        ("Cross-Origin-Resource-Policy", "cross-origin"),
70        ("Cache-Control", "public, max-age=31536000, immutable"),
71    ] {
72        if let Ok(header) = tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) {
73            response.add_header(header);
74        }
75    }
76    let _ = request.respond(response);
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::net::{Ipv4Addr, TcpStream};
83    use std::time::{Duration, Instant};
84
85    fn answers(port: u16) -> bool {
86        TcpStream::connect_timeout(
87            &(Ipv4Addr::LOCALHOST, port).into(),
88            Duration::from_millis(250),
89        )
90        .is_ok()
91    }
92
93    #[test]
94    fn a_cached_key_is_served_to_the_network() {
95        let dir = tempfile::tempdir().expect("temp dir");
96        let cache = Arc::new(ImageCache::new(dir.path().to_path_buf()));
97        cache
98            .store("front/a/b/card.jpg", b"pixels", true)
99            .expect("store");
100
101        let server = ArtServer::spawn(Ipv4Addr::LOCALHOST.into(), cache).expect("spawn");
102        let body = get(server.port, "/scryfall-img/front/a/b/card.jpg");
103        assert!(body.contains("200 OK"), "{body}");
104        assert!(body.ends_with("pixels"), "{body}");
105
106        // And nothing it does not have, rather than fetching it.
107        assert!(get(server.port, "/scryfall-img/front/missing.jpg").contains("404"));
108        // And nothing outside the cache.
109        assert!(get(server.port, "/scryfall-img/../../etc/passwd").contains("404"));
110    }
111
112    fn get(port: u16, path: &str) -> String {
113        use std::io::{Read, Write};
114        let mut stream =
115            TcpStream::connect((Ipv4Addr::LOCALHOST, port)).expect("connect to art server");
116        write!(
117            stream,
118            "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
119        )
120        .expect("request");
121        let mut body = String::new();
122        let _ = stream.read_to_string(&mut body);
123        body
124    }
125
126    /// A flag was only read after the next request arrived, so the port stayed
127    /// bound and serving the subnet after the room was gone.
128    #[test]
129    fn dropping_the_server_stops_the_port_answering() {
130        let dir = tempfile::tempdir().expect("temp dir");
131        let cache = Arc::new(ImageCache::new(dir.path().to_path_buf()));
132
133        let server = ArtServer::spawn(Ipv4Addr::LOCALHOST.into(), cache).expect("spawn art server");
134        let port = server.port;
135        assert!(answers(port), "the listener should be up while the room is");
136
137        drop(server);
138
139        // The accept loop ends on `unblock`, then the thread drops its handle
140        // and the socket closes.
141        let deadline = Instant::now() + Duration::from_secs(5);
142        while Instant::now() < deadline {
143            if !answers(port) {
144                return;
145            }
146            std::thread::sleep(Duration::from_millis(50));
147        }
148        panic!("port {port} still answering after the ArtServer was dropped");
149    }
150}