Skip to main content

resopt/
server.rs

1//! Loopback-only review server. Never accepts filesystem paths from HTTP clients.
2use crate::{filesystem::contained_file, report::render_page, review::Review};
3use anyhow::{Context, Result, ensure};
4use serde::Deserialize;
5use std::{
6    collections::BTreeMap,
7    io::{Read, Write},
8    path::{Path, PathBuf},
9};
10use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
11
12#[derive(Deserialize)]
13#[serde(deny_unknown_fields)]
14struct Action {
15    resource: usize,
16    candidate: Option<usize>,
17    #[serde(default)]
18    approve_lossy: bool,
19}
20
21/// Serve an existing analysis on loopback. Port 0 chooses an available port.
22/// The printed URL is the entry point; terminate the process to stop serving.
23pub fn serve(directory: impl AsRef<Path>, port: u16) -> Result<()> {
24    let review = Review::open(directory.as_ref())?;
25    let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("{e}"))?;
26    let address = server.server_addr().to_string();
27    let origin = format!("http://{address}");
28    let mut random = [0_u8; 32];
29    getrandom::fill(&mut random).map_err(|e| anyhow::anyhow!("random token: {e}"))?;
30    let token: String = random.iter().map(|b| format!("{b:02x}")).collect();
31    let html = render_page(&review.report, Some(&token))?;
32    let mut assets = BTreeMap::<String, (PathBuf, String)>::new();
33    assets.insert(
34        "/analysis.json".into(),
35        (PathBuf::from("analysis.json"), "application/json".into()),
36    );
37    for r in &review.report.resources {
38        for path in [r.original_artifact.as_ref(), r.original_preview.as_ref()]
39            .into_iter()
40            .flatten()
41            .chain(
42                r.candidates
43                    .iter()
44                    .flat_map(|c| [c.artifact.as_ref(), c.preview.as_ref()])
45                    .flatten(),
46            )
47        {
48            let media = match path.extension().and_then(|v| v.to_str()) {
49                Some("png") => "image/png",
50                Some("jpeg" | "jpg") => "image/jpeg",
51                Some("heic") => "image/heic",
52                _ => "application/octet-stream",
53            };
54            let url = format!("/{}", path.to_string_lossy().replace('\\', "/"));
55            ensure!(url.is_ascii(), "non-ASCII artifact path is unsupported");
56            assets.insert(url, (path.clone(), media.into()));
57        }
58    }
59    println!(
60        "Review server: {origin}/\nProject: {}\nStop with Ctrl-C. Sources change only after an explicit Apply request.",
61        review.report.root.display()
62    );
63    std::io::stdout().flush()?;
64    for mut request in server.incoming_requests() {
65        if header(&request, "Host") != Some(address.as_str()) {
66            respond(
67                request,
68                403,
69                "application/json",
70                br#"{"error":"invalid Host"}"#.to_vec(),
71            );
72            continue;
73        }
74        let route = request.url().to_string();
75        if request.method() == &Method::Get && matches!(route.as_str(), "/" | "/report.html") {
76            respond(
77                request,
78                200,
79                "text/html; charset=utf-8",
80                html.as_bytes().to_vec(),
81            );
82        } else if request.method() == &Method::Get && route == "/api/state" {
83            if header(&request, "X-Resopt-Token") != Some(token.as_str()) {
84                respond(
85                    request,
86                    403,
87                    "application/json",
88                    br#"{"error":"invalid session"}"#.to_vec(),
89                );
90                continue;
91            }
92            respond(
93                request,
94                200,
95                "application/json",
96                serde_json::to_vec(&review.states())?,
97            );
98        } else if request.method() == &Method::Post
99            && matches!(route.as_str(), "/api/apply" | "/api/restore")
100        {
101            if header(&request, "Origin") != Some(origin.as_str())
102                || header(&request, "X-Resopt-Token") != Some(token.as_str())
103                || header(&request, "Content-Type") != Some("application/json")
104            {
105                respond(
106                    request,
107                    403,
108                    "application/json",
109                    br#"{"error":"invalid origin or session"}"#.to_vec(),
110                );
111                continue;
112            }
113            let result = (|| -> Result<()> {
114                ensure!(
115                    request.body_length().is_some_and(|n| n <= 4096),
116                    "request too large or missing content length"
117                );
118                let mut body = vec![];
119                request.as_reader().take(4097).read_to_end(&mut body)?;
120                ensure!(body.len() <= 4096, "request too large");
121                let action: Action = serde_json::from_slice(&body)?;
122                if route == "/api/apply" {
123                    review.apply(
124                        action.resource,
125                        action.candidate.context("missing candidate")?,
126                        action.approve_lossy,
127                    )
128                } else {
129                    review.restore(action.resource)
130                }
131            })();
132            let (code, body) = match result {
133                Ok(()) => (200, serde_json::json!({"ok":true,"states":review.states()})),
134                Err(e) => (
135                    409,
136                    serde_json::json!({"error":format!("{e:#}"),"states":review.states()}),
137                ),
138            };
139            respond(
140                request,
141                code,
142                "application/json",
143                serde_json::to_vec(&body)?,
144            );
145        } else if request.method() == &Method::Get && assets.contains_key(&route) {
146            let (path, media) = &assets[&route];
147            match contained_file(&review.directory, path)
148                .and_then(|p| crate::resources::bounded_read(&p))
149            {
150                Ok(bytes) => respond(request, 200, media, bytes),
151                Err(_) => respond(request, 404, "text/plain", b"Artifact unavailable".to_vec()),
152            }
153        } else {
154            respond(request, 404, "text/plain", b"Not found".to_vec());
155        }
156    }
157    Ok(())
158}
159
160fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
161    request
162        .headers()
163        .iter()
164        .find(|h| h.field.to_string().eq_ignore_ascii_case(name))
165        .map(|h| h.value.as_str())
166}
167fn respond(request: Request, code: u16, media: &str, bytes: Vec<u8>) {
168    let mut response = Response::from_data(bytes).with_status_code(StatusCode(code));
169    for (key, value) in [
170        ("Content-Type", media),
171        ("Cache-Control", "no-store"),
172        ("X-Content-Type-Options", "nosniff"),
173        ("Referrer-Policy", "no-referrer"),
174        (
175            "Content-Security-Policy",
176            "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
177        ),
178    ] {
179        response.add_header(Header::from_bytes(key, value).expect("static valid header"));
180    }
181    let _ = request.respond(response);
182}