Skip to main content

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}