1use 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
21pub 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}