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