webui_dev_server/serve.rs
1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Static-file actix handler for dev servers.
5//!
6//! Serves files from a single output directory with:
7//! - `basePath` segment-aware stripping (so `/webui-evil/x` does not
8//! match `/webui/`),
9//! - traversal-safe path resolution,
10//! - `<base href>`-aware redirects (`/foo` → `/foo/` for directory URLs),
11//! - automatic livereload script injection into HTML responses,
12//! - caller-controlled 404 strategy (plain text, custom file, etc.).
13//!
14//! webui-cli does NOT use this handler — its serve command renders
15//! requests on the fly via the WebUI handler. webui-press uses it to
16//! serve the prebuilt `out_dir`.
17
18use std::path::{Path, PathBuf};
19
20use actix_web::http::header::{
21 CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, LOCATION, X_CONTENT_TYPE_OPTIONS,
22};
23use actix_web::http::StatusCode;
24use actix_web::{HttpRequest, HttpResponse};
25
26use crate::livereload::LiveReload;
27use crate::path::{resolve_safe_path, strip_base_path};
28
29/// What to serve when a requested file isn't found.
30#[derive(Clone)]
31pub enum NotFoundStrategy {
32 /// Return a `text/plain; charset=utf-8` 404. The default.
33 Plain,
34 /// Serve `<root>/<file>` as the 404 body (typically `404.html`).
35 /// Falls back to [`NotFoundStrategy::Plain`] if the file can't be
36 /// read. Livereload script is injected when the file is HTML.
37 File(PathBuf),
38}
39
40/// Configuration for [`serve_static_file`]. Cheap to clone — paths are
41/// shared across requests.
42#[derive(Clone)]
43pub struct StaticServeConfig {
44 /// Directory from which files are served.
45 pub root: PathBuf,
46 /// Application basePath. Use `"/"` when the app is hosted at root.
47 /// Must be normalized via
48 /// [`normalize_base_path`](crate::path::normalize_base_path).
49 pub base_path: String,
50 /// Live-reload broadcaster — its client script is injected into
51 /// every HTML response served. Pass [`LiveReload::disabled`] (or
52 /// any newly-constructed instance) to skip injection.
53 pub livereload: LiveReload,
54 /// What to serve on miss.
55 pub not_found: NotFoundStrategy,
56}
57
58/// Serve `req` from `cfg`, returning the appropriate `HttpResponse`.
59///
60/// This function is not an actix handler itself — it's invoked by a
61/// caller's `default_service` handler so the caller can attach app
62/// state, middleware, and additional routes around it.
63pub async fn serve_static_file(req: &HttpRequest, cfg: &StaticServeConfig) -> HttpResponse {
64 let path = req.path();
65
66 let remainder = match strip_base_path(path, &cfg.base_path) {
67 Some(r) => r,
68 None => {
69 // Outside basePath: redirect "/" → basePath for browser
70 // convenience, 404 everything else so missing-asset bugs
71 // surface clearly.
72 if path == "/" && cfg.base_path != "/" {
73 return HttpResponse::TemporaryRedirect()
74 .insert_header((LOCATION, cfg.base_path.clone()))
75 .finish();
76 }
77 return not_found_response(cfg).await;
78 }
79 };
80
81 // `/webui` (no trailing slash) → redirect to `/webui/` so relative
82 // URLs resolve correctly in the browser.
83 if cfg.base_path != "/" && format!("{path}/") == cfg.base_path {
84 return HttpResponse::TemporaryRedirect()
85 .insert_header((LOCATION, cfg.base_path.clone()))
86 .finish();
87 }
88
89 let resolved = match resolve_safe_path(&cfg.root, remainder) {
90 Some(p) => p,
91 None => return not_found_response(cfg).await,
92 };
93
94 // If a directory was requested without a trailing slash, redirect.
95 let final_path = if resolved.is_dir() {
96 if !path.ends_with('/') {
97 return HttpResponse::TemporaryRedirect()
98 .insert_header((LOCATION, format!("{path}/")))
99 .finish();
100 }
101 resolved.join("index.html")
102 } else {
103 resolved
104 };
105
106 match tokio::fs::read(&final_path).await {
107 Ok(bytes) => serve_file_response(&cfg.livereload, &final_path, bytes),
108 Err(_) => not_found_response(cfg).await,
109 }
110}
111
112/// Build a 200 response for a successfully-read file, injecting the
113/// livereload script into HTML payloads. Public so callers with custom
114/// routing can serve files using the same headers/injection policy.
115#[must_use]
116pub fn serve_file_response(livereload: &LiveReload, path: &Path, bytes: Vec<u8>) -> HttpResponse {
117 let mime = mime_guess::from_path(path)
118 .first_or_octet_stream()
119 .to_string();
120
121 let is_html = mime.starts_with("text/html");
122 let body = if is_html {
123 // HTML must be valid UTF-8 (or ASCII). Reject invalid bytes
124 // rather than silently corrupting them with replacement chars.
125 match std::str::from_utf8(&bytes) {
126 Ok(html) => livereload.inject(html).into_bytes(),
127 Err(_) => bytes,
128 }
129 } else {
130 bytes
131 };
132
133 let len = body.len();
134 HttpResponse::Ok()
135 .insert_header((CONTENT_TYPE, mime))
136 .insert_header((CONTENT_LENGTH, len.to_string()))
137 .insert_header((CACHE_CONTROL, "no-cache, no-store, must-revalidate"))
138 .insert_header((X_CONTENT_TYPE_OPTIONS, "nosniff"))
139 .body(body)
140}
141
142async fn not_found_response(cfg: &StaticServeConfig) -> HttpResponse {
143 if let NotFoundStrategy::File(rel) = &cfg.not_found {
144 let path = cfg.root.join(rel);
145 if let Ok(bytes) = tokio::fs::read(&path).await {
146 let mut resp = serve_file_response(&cfg.livereload, &path, bytes);
147 *resp.status_mut() = StatusCode::NOT_FOUND;
148 return resp;
149 }
150 }
151 HttpResponse::NotFound()
152 .content_type("text/plain; charset=utf-8")
153 .body("404 Not Found")
154}