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