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(Deserialize)]
76#[serde(deny_unknown_fields)]
77struct RestoreRequest {
78 resources: Vec<usize>,
79}
80
81enum BatchJob {
82 Apply(BatchPlan),
83 Restore(Option<Vec<usize>>),
84}
85
86#[derive(Default)]
88pub(crate) struct Live {
89 pub rows: Vec<(usize, ResourceAnalysis)>,
91 pub total: usize,
92 pub error: Option<String>,
93 pub cancelled: bool,
94}
95
96#[derive(Serialize)]
97struct ResultsPage<'a> {
98 phase: &'static str,
99 completed: usize,
100 total: usize,
101 candidates: usize,
102 savings_bytes: u64,
103 next: usize,
104 rows: Vec<Row<'a>>,
105 error: Option<&'a str>,
106}
107#[derive(Serialize)]
108struct Row<'a> {
109 index: usize,
110 row: &'a ResourceAnalysis,
111}
112
113pub(crate) struct App {
114 pub directory: PathBuf,
115 pub project: PathBuf,
116 pub live: Mutex<Live>,
117 pub control: AnalysisControl,
118 pub review: OnceLock<Review>,
120 animation: Mutex<Option<(usize, Arc<crate::svga_render::Renderer>)>>,
122 batch_status: Mutex<BatchStatus>,
123 batch_cancel: AtomicBool,
124 batch_running: AtomicBool,
125}
126
127impl App {
128 pub fn new(directory: PathBuf, project: PathBuf) -> Self {
129 Self {
130 directory,
131 project,
132 live: Mutex::new(Live::default()),
133 control: AnalysisControl::default(),
134 review: OnceLock::new(),
135 animation: Mutex::new(None),
136 batch_status: Mutex::new(BatchStatus::default()),
137 batch_cancel: AtomicBool::new(false),
138 batch_running: AtomicBool::new(false),
139 }
140 }
141
142 pub fn finish(&self, review: Review) {
144 {
145 let mut live = self.live.lock().unwrap_or_else(|e| e.into_inner());
146 live.total = review.report.resources.len();
147 live.cancelled = review.report.cancelled;
148 live.rows = review
149 .report
150 .resources
151 .iter()
152 .cloned()
153 .enumerate()
154 .collect();
155 }
156 let _ = self.review.set(review);
157 }
158
159 fn review(&self) -> Result<&Review> {
160 self.review
161 .get()
162 .context("analysis is still running; changes can be applied once it completes")
163 }
164}
165
166pub fn serve(directory: impl AsRef<Path>, port: u16) -> Result<()> {
169 let server = Server::http(("127.0.0.1", port)).map_err(|e| anyhow::anyhow!("{e}"))?;
170 let review = Review::open(directory.as_ref())?;
171 let app = Arc::new(App::new(
172 review.directory.clone(),
173 review.report.root.clone(),
174 ));
175 app.finish(review);
176 let token = session_token()?;
177 println!(
178 "Review server: {}\nProject: {}\nStop with Ctrl-C. Sources change only after an explicit Apply request.",
179 launch_url(&server, &token),
180 app.project.display()
181 );
182 std::io::stdout().flush()?;
183 run(Arc::new(server), app, token)
184}
185
186pub(crate) fn session_token() -> Result<String> {
187 let mut random = [0_u8; 32];
188 getrandom::fill(&mut random).map_err(|e| anyhow::anyhow!("random token: {e}"))?;
189 Ok(random.iter().map(|b| format!("{b:02x}")).collect())
190}
191
192pub(crate) fn launch_url(server: &Server, token: &str) -> String {
194 format!("http://{}/?k={token}", server.server_addr())
195}
196
197pub(crate) fn run(server: Arc<Server>, app: Arc<App>, token: String) -> Result<()> {
199 let address = server.server_addr().to_string();
200 let page = crate::report::render_live_page(&app.project, &token)?;
201 let session = Arc::new(Session {
202 cookie: format!("resopt_{}", address.rsplit(':').next().unwrap_or_default()),
203 origin: format!("http://{address}"),
204 address,
205 token,
206 page,
207 });
208 let workers: Vec<_> = (0..HTTP_WORKERS)
209 .map(|_| {
210 let (server, app, session) = (server.clone(), app.clone(), session.clone());
211 std::thread::spawn(move || {
212 for request in server.incoming_requests() {
213 handle(request, &app, &session);
214 }
215 })
216 })
217 .collect();
218 for worker in workers {
219 let _ = worker.join();
220 }
221 Ok(())
222}
223
224struct Session {
225 cookie: String,
227 address: String,
228 origin: String,
229 token: String,
230 page: String,
231}
232
233fn json(request: Request, code: u16, value: &impl Serialize) {
234 let body = serde_json::to_vec(value)
235 .unwrap_or_else(|_| br#"{"error":"response serialization failed"}"#.to_vec());
236 respond(request, code, "application/json", body);
237}
238
239fn error(request: Request, code: u16, message: &str) {
240 json(request, code, &serde_json::json!({ "error": message }));
241}
242
243fn handle(mut request: Request, app: &Arc<App>, session: &Session) {
244 if header(&request, "Host") != Some(session.address.as_str()) {
245 return error(request, 403, "invalid Host");
246 }
247 let url = request.url().to_string();
248 let (route, query) = url.split_once('?').unwrap_or((&url, ""));
249 let get = request.method() == &Method::Get;
250 let post = request.method() == &Method::Post;
251 if !get && !post {
252 return error(
253 request,
254 405,
255 "method not allowed; this server accepts no uploads",
256 );
257 }
258 let has_cookie = header(&request, "Cookie").is_some_and(|cookies| {
259 cookies
260 .split(';')
261 .filter_map(|pair| pair.trim().split_once('='))
262 .any(|(name, value)| name == session.cookie && value == session.token)
263 });
264 if get && matches!(route, "/" | "/report.html") {
265 let has_key = query
266 .split('&')
267 .any(|pair| pair.strip_prefix("k=") == Some(session.token.as_str()));
268 if !has_key && !has_cookie {
269 return respond(
270 request,
271 403,
272 "text/plain; charset=utf-8",
273 b"Open the full URL that resopt printed in your terminal (it contains the session key).".to_vec(),
274 );
275 }
276 let cookie = format!(
277 "{}={}; HttpOnly; SameSite=Strict; Path=/",
278 session.cookie, session.token
279 );
280 return respond_with(
281 request,
282 200,
283 "text/html; charset=utf-8",
284 session.page.as_bytes().to_vec(),
285 &[("Set-Cookie", cookie.as_str())],
286 );
287 }
288 if get && route == "/favicon.ico" {
289 return respond(request, 204, "image/x-icon", vec![]);
290 }
291 if get && !route.starts_with("/api/") {
292 if !has_cookie && header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
294 return error(
295 request,
296 403,
297 "invalid session; open the URL printed by resopt",
298 );
299 }
300 if let Some(index) = route.strip_prefix("/source/") {
301 return serve_source(request, app, index);
302 }
303 if let Some(target) = route.strip_prefix("/animation/") {
304 return serve_animation_frame(request, app, target, query);
305 }
306 return serve_artifact(request, app, route);
307 }
308 if !route.starts_with("/api/") {
309 return error(request, 404, "not found");
310 }
311 if header(&request, "X-Resopt-Token") != Some(session.token.as_str()) {
312 return error(
313 request,
314 403,
315 "invalid session; reload the page opened by resopt",
316 );
317 }
318 if post
319 && (header(&request, "Origin") != Some(session.origin.as_str())
320 || header(&request, "Content-Type") != Some("application/json"))
321 {
322 return error(request, 403, "invalid origin or content type");
323 }
324 let result = if get {
325 api_get(app, route, query)
326 } else {
327 read_body(&mut request).and_then(|body| api_post(app, route, &body))
328 };
329 match result {
330 Ok(Some(value)) => json(request, 200, &value),
331 Ok(None) => error(request, 404, "not found"),
332 Err(failure) => {
333 let states = app.review.get().map(Review::states);
334 json(
335 request,
336 409,
337 &serde_json::json!({"error": format!("{failure:#}"), "states": states}),
338 );
339 }
340 }
341}
342
343fn read_body(request: &mut Request) -> Result<Vec<u8>> {
344 ensure!(
345 request.body_length().is_some_and(|n| n <= MAX_BODY_BYTES),
346 "request too large or missing content length"
347 );
348 let mut body = vec![];
349 request
350 .as_reader()
351 .take(MAX_BODY_BYTES as u64 + 1)
352 .read_to_end(&mut body)?;
353 ensure!(body.len() <= MAX_BODY_BYTES, "request too large");
354 Ok(body)
355}
356
357fn api_get(app: &Arc<App>, route: &str, query: &str) -> Result<Option<serde_json::Value>> {
358 Ok(Some(match route {
359 "/api/capabilities" => serde_json::to_value(crate::capabilities())?,
360 "/api/results" => {
361 let after = query
362 .split('&')
363 .find_map(|pair| pair.strip_prefix("after="))
364 .map(|value| value.parse::<usize>())
365 .transpose()
366 .context("invalid after parameter")?
367 .unwrap_or(0);
368 let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
369 let rows: Vec<_> = live
370 .rows
371 .iter()
372 .skip(after)
373 .take(RESULTS_PAGE)
374 .map(|(index, row)| Row { index: *index, row })
375 .collect();
376 serde_json::to_value(ResultsPage {
377 phase: if live.error.is_some() {
378 "failed"
379 } else if app.review.get().is_some() {
380 "ready"
381 } else {
382 "analyzing"
383 },
384 completed: live.rows.len(),
385 total: live.total,
386 candidates: live
387 .rows
388 .iter()
389 .filter(|(_, r)| r.recommended_savings() > 0)
390 .count(),
391 savings_bytes: live.rows.iter().map(|(_, r)| r.recommended_savings()).sum(),
392 next: after + rows.len(),
393 rows,
394 error: live.error.as_deref(),
395 })?
396 }
397 "/api/report" => crate::report::meta(&app.review()?.report),
398 "/api/state" => match app.review.get() {
399 Some(review) => review.states(),
400 None => serde_json::json!({}),
401 },
402 "/api/batch" => {
403 serde_json::to_value(&*app.batch_status.lock().unwrap_or_else(|e| e.into_inner()))?
404 }
405 _ => return Ok(None),
406 }))
407}
408
409fn api_post(app: &Arc<App>, route: &str, body: &[u8]) -> Result<Option<serde_json::Value>> {
410 match route {
411 "/api/cancel" => {
412 app.control.cancel();
413 return Ok(Some(serde_json::json!({"ok": true})));
414 }
415 "/api/batch/cancel" => {
416 app.batch_cancel.store(true, Ordering::SeqCst);
417 return Ok(Some(serde_json::json!({"ok": true})));
418 }
419 _ => {}
420 }
421 let review = app.review()?;
422 Ok(Some(match route {
423 "/api/preview" => {
424 let action: Action = serde_json::from_slice(body)?;
425 review.preview_with_warnings(
426 action.resource,
427 action.candidate.context("missing candidate")?,
428 &action.warnings(),
429 )?
430 }
431 "/api/apply" | "/api/restore" => {
432 ensure!(
433 !app.batch_running.load(Ordering::SeqCst),
434 "a batch is running; wait for it to finish or cancel it"
435 );
436 let action: Action = serde_json::from_slice(body)?;
437 if route == "/api/apply" {
438 review.apply_with_warnings(
439 action.resource,
440 action.candidate.context("missing candidate")?,
441 &Approvals {
442 lossy: action.approve_lossy,
443 warnings: action.warnings(),
444 },
445 action.plan_token.as_deref(),
446 true,
447 )?;
448 } else {
449 review.restore(action.resource)?;
450 }
451 serde_json::json!({"ok": true, "states": review.states()})
452 }
453 "/api/batch/preview" => {
454 let request: BatchRequest = serde_json::from_slice(body)?;
455 serde_json::to_value(batch::plan(review, &request.policy)?)?
456 }
457 "/api/batch/apply" => {
458 let request: BatchRequest = serde_json::from_slice(body)?;
459 let plan = batch::plan(review, &request.policy)?;
460 ensure!(
461 request.token.as_deref() == Some(plan.token.as_str()),
462 "the project or policy changed after the preview; review the batch again"
463 );
464 start_batch(app, BatchJob::Apply(plan))?;
465 serde_json::json!({"ok": true})
466 }
467 "/api/batch/restore" => {
468 let mut request: RestoreRequest = serde_json::from_slice(body)?;
469 ensure!(
470 !request.resources.is_empty(),
471 "select at least one resource to restore"
472 );
473 request.resources.sort_unstable();
474 request.resources.dedup();
475 ensure!(
476 request
477 .resources
478 .iter()
479 .all(|&index| index < review.report.resources.len()),
480 "invalid resource index"
481 );
482 start_batch(app, BatchJob::Restore(Some(request.resources)))?;
483 serde_json::json!({"ok": true})
484 }
485 "/api/restore-all" => {
486 start_batch(app, BatchJob::Restore(None))?;
487 serde_json::json!({"ok": true})
488 }
489 _ => return Ok(None),
490 }))
491}
492
493fn start_batch(app: &Arc<App>, job: BatchJob) -> Result<()> {
495 ensure!(
496 !app.batch_running.swap(true, Ordering::SeqCst),
497 "another batch is already running"
498 );
499 app.batch_cancel.store(false, Ordering::SeqCst);
500 *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = BatchStatus {
503 running: true,
504 total: match &job {
505 BatchJob::Apply(plan) => plan.items.len(),
506 BatchJob::Restore(Some(resources)) => resources.len(),
507 BatchJob::Restore(None) => 0,
508 },
509 ..Default::default()
510 };
511 let app = app.clone();
512 std::thread::spawn(move || {
513 if let Some(review) = app.review.get() {
514 match job {
515 BatchJob::Apply(plan) => {
516 batch::run(review, &plan, &app.batch_status, &app.batch_cancel)
517 }
518 BatchJob::Restore(resources) => {
519 let status =
520 batch::restore_many(review, resources.as_deref(), &app.batch_cancel);
521 *app.batch_status.lock().unwrap_or_else(|e| e.into_inner()) = status;
522 }
523 }
524 }
525 app.batch_running.store(false, Ordering::SeqCst);
526 });
527 Ok(())
528}
529
530fn artifact_path(route: &str) -> Option<PathBuf> {
532 if route == "/analysis.json" {
533 return Some(PathBuf::from("analysis.json"));
534 }
535 let (folder, name) = route.strip_prefix('/')?.split_once('/')?;
536 let generated = !name.is_empty()
537 && name.len() <= 96
538 && name.as_bytes()[0].is_ascii_digit()
539 && name
540 .bytes()
541 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.'))
542 && !name.contains("..");
543 (matches!(folder, "previews" | "candidates" | "originals") && generated)
544 .then(|| Path::new(folder).join(name))
545}
546
547fn serve_source(request: Request, app: &App, index: &str) {
550 let found = index.parse::<usize>().ok().and_then(|index| {
551 let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
552 live.rows
553 .iter()
554 .find(|(row, _)| *row == index)
555 .filter(|(_, analysis)| analysis.resource.kind == "image")
556 .map(|(_, analysis)| {
557 (
558 analysis.resource.path.clone(),
559 analysis.resource.format.clone(),
560 )
561 })
562 });
563 let Some((path, format)) = found else {
564 return respond(request, 404, "text/plain", b"Not found".to_vec());
565 };
566 let media = match format.as_str() {
567 "png" => "image/png",
568 "jpeg" => "image/jpeg",
569 "webp" => "image/webp",
570 "gif" => "image/gif",
571 "heic" | "heif" => "image/heic",
572 _ => "application/octet-stream",
573 };
574 match contained_file(&app.project, &path).and_then(|p| crate::resources::bounded_read(&p)) {
575 Ok(bytes) => respond(request, 200, media, bytes),
576 Err(_) => respond(request, 404, "text/plain", b"Source unavailable".to_vec()),
577 }
578}
579
580const MAX_FRAME_SIDE: u32 = 1024;
582
583fn serve_animation_frame(request: Request, app: &App, target: &str, query: &str) {
587 let parsed = target.split_once('/').and_then(|(index, frame)| {
588 Some((index.parse::<usize>().ok()?, frame.parse::<usize>().ok()?))
589 });
590 let Some((index, frame)) = parsed else {
591 return respond(request, 404, "text/plain", b"Not found".to_vec());
592 };
593 let side = query
594 .split('&')
595 .find_map(|pair| pair.strip_prefix("side="))
596 .and_then(|value| value.parse::<u32>().ok())
597 .unwrap_or(512)
598 .clamp(16, MAX_FRAME_SIDE);
599 let rendered = animation_renderer(app, index)
600 .and_then(|renderer| crate::svga_render::encode_png(&renderer.render(frame, side)?));
601 match rendered {
602 Ok(png) => respond_with(
605 request,
606 200,
607 "image/png",
608 png,
609 &[("Cache-Control", "private, max-age=600")],
610 ),
611 Err(_) => respond(request, 404, "text/plain", b"Frame unavailable".to_vec()),
612 }
613}
614
615fn animation_renderer(app: &App, index: usize) -> Result<Arc<crate::svga_render::Renderer>> {
616 if let Some((cached, renderer)) = &*app.animation.lock().unwrap_or_else(|e| e.into_inner())
617 && *cached == index
618 {
619 return Ok(renderer.clone());
620 }
621 let path = {
622 let live = app.live.lock().unwrap_or_else(|e| e.into_inner());
623 live.rows
624 .iter()
625 .find(|(row, analysis)| *row == index && analysis.resource.format == "svga")
626 .map(|(_, analysis)| analysis.resource.path.clone())
627 .context("not an animation")?
628 };
629 let bytes = crate::resources::bounded_read(&contained_file(&app.project, &path)?)?;
630 let renderer = Arc::new(crate::svga_render::Renderer::new(&bytes)?);
631 *app.animation.lock().unwrap_or_else(|e| e.into_inner()) = Some((index, renderer.clone()));
632 Ok(renderer)
633}
634
635fn serve_artifact(request: Request, app: &App, route: &str) {
636 let Some(relative) = artifact_path(route) else {
637 return respond(request, 404, "text/plain", b"Not found".to_vec());
638 };
639 let media = match relative.extension().and_then(|v| v.to_str()) {
640 Some("png") => "image/png",
641 Some("jpeg" | "jpg") => "image/jpeg",
642 Some("heic") => "image/heic",
643 Some("webp") => "image/webp",
644 Some("json") => "application/json",
645 _ => "application/octet-stream",
646 };
647 match contained_file(&app.directory, &relative).and_then(|p| crate::resources::bounded_read(&p))
648 {
649 Ok(bytes) => respond(request, 200, media, bytes),
650 Err(_) => respond(request, 404, "text/plain", b"Artifact unavailable".to_vec()),
651 }
652}
653
654pub(crate) fn header<'a>(request: &'a Request, name: &str) -> Option<&'a str> {
655 request
656 .headers()
657 .iter()
658 .find(|h| h.field.to_string().eq_ignore_ascii_case(name))
659 .map(|h| h.value.as_str())
660}
661
662pub(crate) fn respond(request: Request, code: u16, media: &str, bytes: Vec<u8>) {
663 respond_with(request, code, media, bytes, &[]);
664}
665
666fn respond_with(request: Request, code: u16, media: &str, bytes: Vec<u8>, extra: &[(&str, &str)]) {
667 let mut response = Response::from_data(bytes).with_status_code(StatusCode(code));
668 for (key, value) in [
669 ("Content-Type", media),
670 ("Cache-Control", "no-store"),
671 ("X-Content-Type-Options", "nosniff"),
672 ("Referrer-Policy", "no-referrer"),
673 ("Cross-Origin-Resource-Policy", "same-origin"),
674 (
675 "Content-Security-Policy",
676 "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'",
677 ),
678 ]
679 .into_iter()
680 .filter(|(key, _)| !extra.iter().any(|(name, _)| name.eq_ignore_ascii_case(key)))
682 .chain(extra.iter().copied())
683 {
684 if let Ok(header) = Header::from_bytes(key, value) {
685 response.add_header(header);
686 }
687 }
688 let _ = request.respond(response);
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn only_generated_artifact_names_are_served() {
697 for route in [
698 "/previews/12-webp-85.png",
699 "/candidates/0-png-0.png",
700 "/originals/7.heic",
701 "/analysis.json",
702 ] {
703 assert!(artifact_path(route).is_some(), "{route}");
704 }
705 for route in [
706 "/previews/../analysis.json",
707 "/previews/..%2f..%2fetc",
708 "/operations/0/transaction.json",
709 "/previews/",
710 "/previews/a/b.png",
711 "/candidates/x.png",
712 "/previews/1\\..\\x",
713 "//etc/passwd",
714 "/report.html/../x",
715 ] {
716 assert!(artifact_path(route).is_none(), "{route}");
717 }
718 }
719
720 #[test]
721 fn legacy_alpha_flag_maps_to_both_alpha_warnings() {
722 let action: Action =
723 serde_json::from_str(r#"{"resource":0,"candidate":1,"approve_alpha_loss":true}"#)
724 .unwrap();
725 assert_eq!(
726 action.warnings(),
727 [
728 "alpha_error_exceeds_policy",
729 "transparency_presence_changed"
730 ]
731 );
732 assert!(serde_json::from_str::<Action>(r#"{"resource":0,"path":"/etc/passwd"}"#).is_err());
733 }
734}