1use crate::{
13 AnalysisControl, ResourceAnalysis,
14 batch::{self, BatchPlan, BatchPolicy, BatchStatus},
15 filesystem::contained_file,
16 review::{Approvals, Review},
17};
18use anyhow::{Context, Result, ensure};
19use serde::{Deserialize, Serialize};
20use std::{
21 io::{Read, Write},
22 path::{Path, PathBuf},
23 sync::{
24 Arc, Mutex, OnceLock,
25 atomic::{AtomicBool, Ordering},
26 },
27};
28use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
29
30const MAX_BODY_BYTES: usize = 256 * 1024;
31const RESULTS_PAGE: usize = 500;
32const HTTP_WORKERS: usize = 4;
33
34#[derive(Deserialize)]
35#[serde(deny_unknown_fields)]
36struct Action {
37 resource: usize,
38 candidate: Option<usize>,
39 #[serde(default)]
40 approve_lossy: bool,
41 #[serde(default)]
43 approve_alpha_loss: bool,
44 #[serde(default)]
45 approve_warnings: Vec<String>,
46 #[serde(default)]
47 plan_token: Option<String>,
48}
49
50impl Action {
51 fn warnings(&self) -> Vec<String> {
52 let mut warnings = self.approve_warnings.clone();
53 if self.approve_alpha_loss {
54 for kind in [
55 "alpha_error_exceeds_policy",
56 "transparency_presence_changed",
57 ] {
58 if !warnings.iter().any(|w| w == kind) {
59 warnings.push(kind.to_string());
60 }
61 }
62 }
63 warnings
64 }
65}
66
67#[derive(Deserialize)]
68#[serde(deny_unknown_fields)]
69struct BatchRequest {
70 policy: BatchPolicy,
71 #[serde(default)]
72 token: Option<String>,
73}
74
75#[derive(Default)]
77pub(crate) struct Live {
78 pub rows: Vec<(usize, ResourceAnalysis)>,
80 pub total: usize,
81 pub error: Option<String>,
82 pub cancelled: bool,
83}
84
85#[derive(Serialize)]
86struct ResultsPage<'a> {
87 phase: &'static str,
88 completed: usize,
89 total: usize,
90 candidates: usize,
91 savings_bytes: u64,
92 next: usize,
93 rows: Vec<Row<'a>>,
94 error: Option<&'a str>,
95}
96#[derive(Serialize)]
97struct Row<'a> {
98 index: usize,
99 row: &'a ResourceAnalysis,
100}
101
102pub(crate) struct App {
103 pub directory: PathBuf,
104 pub project: PathBuf,
105 pub live: Mutex<Live>,
106 pub control: AnalysisControl,
107 pub review: OnceLock<Review>,
109 batch_status: Mutex<BatchStatus>,
110 batch_cancel: AtomicBool,
111 batch_running: AtomicBool,
112}
113
114impl App {
115 pub fn new(directory: PathBuf, project: PathBuf) -> Self {
116 Self {
117 directory,
118 project,
119 live: Mutex::new(Live::default()),
120 control: AnalysisControl::default(),
121 review: OnceLock::new(),
122 batch_status: Mutex::new(BatchStatus::default()),
123 batch_cancel: AtomicBool::new(false),
124 batch_running: AtomicBool::new(false),
125 }
126 }
127
128 pub fn finish(&self, review: Review) {
130 {
131 let mut live = self.live.lock().unwrap_or_else(|e| e.into_inner());
132 live.total = review.report.resources.len();
133 live.cancelled = review.report.cancelled;
134 live.rows = review
135 .report
136 .resources
137 .iter()
138 .cloned()
139 .enumerate()
140 .collect();
141 }
142 let _ = self.review.set(review);
143 }
144
145 fn review(&self) -> Result<&Review> {
146 self.review
147 .get()
148 .context("analysis is still running; changes can be applied once it completes")
149 }
150}
151
152pub fn serve(directory: impl AsRef<Path>, port: u16) -> Result<()> {
155 let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("{e}"))?;
156 let review = Review::open(directory.as_ref())?;
157 let app = Arc::new(App::new(
158 review.directory.clone(),
159 review.report.root.clone(),
160 ));
161 app.finish(review);
162 let token = session_token()?;
163 println!(
164 "Review server: {}\nProject: {}\nStop with Ctrl-C. Sources change only after an explicit Apply request.",
165 launch_url(&server, &token),
166 app.project.display()
167 );
168 std::io::stdout().flush()?;
169 run(Arc::new(server), app, token)
170}
171
172pub(crate) fn session_token() -> Result<String> {
173 let mut random = [0_u8; 32];
174 getrandom::fill(&mut random).map_err(|e| anyhow::anyhow!("random token: {e}"))?;
175 Ok(random.iter().map(|b| format!("{b:02x}")).collect())
176}
177
178pub(crate) fn launch_url(server: &Server, token: &str) -> String {
180 format!("http://{}/?k={token}", server.server_addr())
181}
182
183pub(crate) fn run(server: Arc<Server>, app: Arc<App>, token: String) -> Result<()> {
185 let address = server.server_addr().to_string();
186 let page = crate::report::render_live_page(&app.project, &token)?;
187 let session = Arc::new(Session {
188 cookie: format!("resopt_{}", address.rsplit(':').next().unwrap_or_default()),
189 origin: format!("http://{address}"),
190 address,
191 token,
192 page,
193 });
194 let workers: Vec<_> = (0..HTTP_WORKERS)
195 .map(|_| {
196 let (server, app, session) = (server.clone(), app.clone(), session.clone());
197 std::thread::spawn(move || {
198 for request in server.incoming_requests() {
199 handle(request, &app, &session);
200 }
201 })
202 })
203 .collect();
204 for worker in workers {
205 let _ = worker.join();
206 }
207 Ok(())
208}
209
210struct Session {
211 cookie: String,
213 address: String,
214 origin: String,
215 token: String,
216 page: String,
217}
218
219fn json(request: Request, code: u16, value: &impl Serialize) {
220 let body = serde_json::to_vec(value)
221 .unwrap_or_else(|_| br#"{"error":"response serialization failed"}"#.to_vec());
222 respond(request, code, "application/json", body);
223}
224
225fn error(request: Request, code: u16, message: &str) {
226 json(request, code, &serde_json::json!({ "error": message }));
227}
228
229fn handle(mut request: Request, app: &Arc<App>, session: &Session) {
230 if header(&request, "Host") != Some(session.address.as_str()) {
231 return error(request, 403, "invalid Host");
232 }
233 let url = request.url().to_string();
234 let (route, query) = url.split_once('?').unwrap_or((&url, ""));
235 let get = request.method() == &Method::Get;
236 let post = request.method() == &Method::Post;
237 if !get && !post {
238 return error(
239 request,
240 405,
241 "method not allowed; this server accepts no uploads",
242 );
243 }
244 let has_cookie = header(&request, "Cookie").is_some_and(|cookies| {
245 cookies
246 .split(';')
247 .filter_map(|pair| pair.trim().split_once('='))
248 .any(|(name, value)| name == session.cookie && value == session.token)
249 });
250 if get && matches!(route, "/" | "/report.html") {
251 let has_key = query
252 .split('&')
253 .any(|pair| pair.strip_prefix("k=") == Some(session.token.as_str()));
254 if !has_key && !has_cookie {
255 return respond(
256 request,
257 403,
258 "text/plain; charset=utf-8",
259 b"Open the full URL that resopt printed in your terminal (it contains the session key).".to_vec(),
260 );
261 }
262 let cookie = format!(
263 "{}={}; HttpOnly; SameSite=Strict; Path=/",
264 session.cookie, session.token
265 );
266 return respond_with(
267 request,
268 200,
269 "text/html; charset=utf-8",
270 session.page.as_bytes().to_vec(),
271 &[("Set-Cookie", cookie.as_str())],
272 );
273 }
274 if get && route == "/favicon.ico" {
275 return respond(request, 204, "image/x-icon", vec![]);
276 }
277 if get && !route.starts_with("/api/") {
278 if !has_cookie && header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
280 return error(
281 request,
282 403,
283 "invalid session; open the URL printed by resopt",
284 );
285 }
286 return serve_artifact(request, app, route);
287 }
288 if !route.starts_with("/api/") {
289 return error(request, 404, "not found");
290 }
291 if header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
292 return error(
293 request,
294 403,
295 "invalid session; reload the page opened by resopt",
296 );
297 }
298 if post
299 && (header(&request, "Origin") != Some(session.origin.as_str())
300 || header(&request, "Content-Type") != Some("application/json"))
301 {
302 return error(request, 403, "invalid origin or content type");
303 }
304 let result = if get {
305 api_get(app, route, query)
306 } else {
307 read_body(&mut request).and_then(|body| api_post(app, route, &body))
308 };
309 match result {
310 Ok(Some(value)) => json(request, 200, &value),
311 Ok(None) => error(request, 404, "not found"),
312 Err(failure) => {
313 let states = app.review.get().map(Review::states);
314 json(
315 request,
316 409,
317 &serde_json::json!({"error": format!("{failure:#}"), "states": states}),
318 );
319 }
320 }
321}
322
323fn read_body(request: &mut Request) -> Result<Vec<u8>> {
324 ensure!(
325 request.body_length().is_some_and(|n| n <= MAX_BODY_BYTES),
326 "request too large or missing content length"
327 );
328 let mut body = vec![];
329 request
330 .as_reader()
331 .take(MAX_BODY_BYTES as u64 + 1)
332 .read_to_end(&mut body)?;
333 ensure!(body.len() <= MAX_BODY_BYTES, "request too large");
334 Ok(body)
335}
336
337fn api_get(app: &Arc<App>, route: &str, query: &str) -> Result<Option<serde_json::Value>> {
338 Ok(Some(match route {
339 "/api/capabilities" => serde_json::to_value(crate::capabilities())?,
340 "/api/results" => {
341 let after = query
342 .split('&')
343 .find_map(|pair| pair.strip_prefix("after="))
344 .map(|value| value.parse::<usize>())
345 .transpose()
346 .context("invalid after parameter")?
347 .unwrap_or(0);
348 let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
349 let rows: Vec<_> = live
350 .rows
351 .iter()
352 .skip(after)
353 .take(RESULTS_PAGE)
354 .map(|(index, row)| Row { index: *index, row })
355 .collect();
356 serde_json::to_value(ResultsPage {
357 phase: if live.error.is_some() {
358 "failed"
359 } else if app.review.get().is_some() {
360 "ready"
361 } else {
362 "analyzing"
363 },
364 completed: live.rows.len(),
365 total: live.total,
366 candidates: live
367 .rows
368 .iter()
369 .filter(|(_, r)| r.recommended_savings() > 0)
370 .count(),
371 savings_bytes: live.rows.iter().map(|(_, r)| r.recommended_savings()).sum(),
372 next: after + rows.len(),
373 rows,
374 error: live.error.as_deref(),
375 })?
376 }
377 "/api/report" => crate::report::meta(&app.review()?.report),
378 "/api/state" => match app.review.get() {
379 Some(review) => review.states(),
380 None => serde_json::json!({}),
381 },
382 "/api/batch" => {
383 serde_json::to_value(&*app.batch_status.lock().unwrap_or_else(|e| e.into_inner()))?
384 }
385 _ => return Ok(None),
386 }))
387}
388
389fn api_post(app: &Arc<App>, route: &str, body: &[u8]) -> Result<Option<serde_json::Value>> {
390 match route {
391 "/api/cancel" => {
392 app.control.cancel();
393 return Ok(Some(serde_json::json!({"ok": true})));
394 }
395 "/api/batch/cancel" => {
396 app.batch_cancel.store(true, Ordering::SeqCst);
397 return Ok(Some(serde_json::json!({"ok": true})));
398 }
399 _ => {}
400 }
401 let review = app.review()?;
402 Ok(Some(match route {
403 "/api/preview" => {
404 let action: Action = serde_json::from_slice(body)?;
405 review.preview_with_warnings(
406 action.resource,
407 action.candidate.context("missing candidate")?,
408 &action.warnings(),
409 )?
410 }
411 "/api/apply" | "/api/restore" => {
412 ensure!(
413 !app.batch_running.load(Ordering::SeqCst),
414 "a batch is running; wait for it to finish or cancel it"
415 );
416 let action: Action = serde_json::from_slice(body)?;
417 if route == "/api/apply" {
418 review.apply_with_warnings(
419 action.resource,
420 action.candidate.context("missing candidate")?,
421 &Approvals {
422 lossy: action.approve_lossy,
423 warnings: action.warnings(),
424 },
425 action.plan_token.as_deref(),
426 true,
427 )?;
428 } else {
429 review.restore(action.resource)?;
430 }
431 serde_json::json!({"ok": true, "states": review.states()})
432 }
433 "/api/batch/preview" => {
434 let request: BatchRequest = serde_json::from_slice(body)?;
435 serde_json::to_value(batch::plan(review, &request.policy)?)?
436 }
437 "/api/batch/apply" => {
438 let request: BatchRequest = serde_json::from_slice(body)?;
439 let plan = batch::plan(review, &request.policy)?;
440 ensure!(
441 request.token.as_deref() == Some(plan.token.as_str()),
442 "the project or policy changed after the preview; review the batch again"
443 );
444 start_batch(app, Some(plan))?;
445 serde_json::json!({"ok": true})
446 }
447 "/api/restore-all" => {
448 start_batch(app, None)?;
449 serde_json::json!({"ok": true})
450 }
451 _ => return Ok(None),
452 }))
453}
454
455fn start_batch(app: &Arc<App>, plan: Option<BatchPlan>) -> Result<()> {
457 ensure!(
458 !app.batch_running.swap(true, Ordering::SeqCst),
459 "another batch is already running"
460 );
461 app.batch_cancel.store(false, Ordering::SeqCst);
462 *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = BatchStatus {
465 running: true,
466 total: plan.as_ref().map_or(0, |p| p.items.len()),
467 ..Default::default()
468 };
469 let app = app.clone();
470 std::thread::spawn(move || {
471 if let Some(review) = app.review.get() {
472 match plan {
473 Some(plan) => batch::run(review, &plan, &app.batch_status, &app.batch_cancel),
474 None => {
475 let status = batch::restore_all(review, &app.batch_cancel);
476 *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = status;
477 }
478 }
479 }
480 app.batch_running.store(false, Ordering::SeqCst);
481 });
482 Ok(())
483}
484
485fn artifact_path(route: &str) -> Option<PathBuf> {
487 if route == "/analysis.json" {
488 return Some(PathBuf::from("analysis.json"));
489 }
490 let (folder, name) = route.strip_prefix('/')?.split_once('/')?;
491 let generated = !name.is_empty()
492 && name.len() <= 96
493 && name.as_bytes()[0].is_ascii_digit()
494 && name
495 .bytes()
496 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.'))
497 && !name.contains("..");
498 (matches!(folder, "previews" | "candidates" | "originals") && generated)
499 .then(|| Path::new(folder).join(name))
500}
501
502fn serve_artifact(request: Request, app: &App, route: &str) {
503 let Some(relative) = artifact_path(route) else {
504 return respond(request, 404, "text/plain", b"Not found".to_vec());
505 };
506 let media = match relative.extension().and_then(|v| v.to_str()) {
507 Some("png") => "image/png",
508 Some("jpeg" | "jpg") => "image/jpeg",
509 Some("heic") => "image/heic",
510 Some("webp") => "image/webp",
511 Some("json") => "application/json",
512 _ => "application/octet-stream",
513 };
514 match contained_file(&app.directory, &relative).and_then(|p| crate::resources::bounded_read(&p))
515 {
516 Ok(bytes) => respond(request, 200, media, bytes),
517 Err(_) => respond(request, 404, "text/plain", b"Artifact unavailable".to_vec()),
518 }
519}
520
521pub(crate) fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
522 request
523 .headers()
524 .iter()
525 .find(|h| h.field.to_string().eq_ignore_ascii_case(name))
526 .map(|h| h.value.as_str())
527}
528
529pub(crate) fn respond(request: Request, code: u16, media: &str, bytes: Vec<u8>) {
530 respond_with(request, code, media, bytes, &[]);
531}
532
533fn respond_with(request: Request, code: u16, media: &str, bytes: Vec<u8>, extra: &[(&str, &str)]) {
534 let mut response = Response::from_data(bytes).with_status_code(StatusCode(code));
535 for (key, value) in [
536 ("Content-Type", media),
537 ("Cache-Control", "no-store"),
538 ("X-Content-Type-Options", "nosniff"),
539 ("Referrer-Policy", "no-referrer"),
540 ("Cross-Origin-Resource-Policy", "same-origin"),
541 (
542 "Content-Security-Policy",
543 "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'",
544 ),
545 ]
546 .into_iter()
547 .chain(extra.iter().copied())
548 {
549 if let Ok(header) = Header::from_bytes(key, value) {
550 response.add_header(header);
551 }
552 }
553 let _ = request.respond(response);
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn only_generated_artifact_names_are_served() {
562 for route in [
563 "/previews/12-webp-85.png",
564 "/candidates/0-png-0.png",
565 "/originals/7.heic",
566 "/analysis.json",
567 ] {
568 assert!(artifact_path(route).is_some(), "{route}");
569 }
570 for route in [
571 "/previews/../analysis.json",
572 "/previews/..%2f..%2fetc",
573 "/operations/0/transaction.json",
574 "/previews/",
575 "/previews/a/b.png",
576 "/candidates/x.png",
577 "/previews/1\\..\\x",
578 "//etc/passwd",
579 "/report.html/../x",
580 ] {
581 assert!(artifact_path(route).is_none(), "{route}");
582 }
583 }
584
585 #[test]
586 fn legacy_alpha_flag_maps_to_both_alpha_warnings() {
587 let action: Action =
588 serde_json::from_str(r#"{"resource":0,"candidate":1,"approve_alpha_loss":true}"#)
589 .unwrap();
590 assert_eq!(
591 action.warnings(),
592 [
593 "alpha_error_exceeds_policy",
594 "transparency_presence_changed"
595 ]
596 );
597 assert!(serde_json::from_str::<Action>(r#"{"resource":0,"path":"/etc/passwd"}"#).is_err());
598 }
599}