Rewild Your Web
web
browser
dweb
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! vhost http server to serve local content.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use axum::Router;
9use axum::extract::{Request, State};
10use axum::http::Request as HttpRequest;
11use axum::routing::get;
12use log::error;
13use parking_lot::Mutex;
14use servo::prefs::{PreferencesObserver, get_embedder_pref};
15use servo::{PrefValue, prefs};
16use tower_http::compression::CompressionLayer;
17use tower_http::cors::{Any, CorsLayer};
18use tower_http::services::ServeFile;
19use tower_http::services::fs::ServeFileSystemResponseBody;
20use url::Host::Domain;
21use url::Url;
22
23use crate::resources::ancestor_dir_path;
24
25fn vhost_handler(state: ServerState, request: Request) -> Option<PathBuf> {
26 let Some(host) = request.headers().get("Host") else {
27 error!("No Host header!");
28 return None;
29 };
30
31 let Ok(url) = format!("http://{}", host.to_str().unwrap_or_default()).parse::<Url>() else {
32 error!(
33 "Failed to parse http://{}",
34 host.to_str().unwrap_or_default()
35 );
36 return None;
37 };
38
39 let Some(Domain(domain)) = url.host() else {
40 return None;
41 };
42
43 let parts: Vec<String> = domain.split(".").map(|s| s.to_owned()).collect();
44
45 if parts.len() != 2 || parts[1] != "localhost" {
46 return None;
47 }
48
49 let app = &parts[0];
50
51 // Currently recognized: system, shared, keyboard, homescreen, theme
52 // TODO: don't hardcode
53 if app != "homescreen" &&
54 app != "keyboard" &&
55 app != "settings" &&
56 app != "shared" &&
57 app != "system" &&
58 app != "theme"
59 {
60 return None;
61 }
62
63 let uri_parts: Vec<String> = request
64 .uri()
65 .path()
66 .split("/")
67 .filter_map(|s| {
68 if s.is_empty() || s.contains("..") {
69 None
70 } else {
71 Some(s.to_owned())
72 }
73 })
74 .collect();
75
76 if app == "theme" {
77 let theme = state.theme.lock();
78 Some(
79 state
80 .ui_dir
81 .join("themes")
82 .join(&*theme)
83 .join(uri_parts.join("/")),
84 )
85 } else {
86 Some(state.ui_dir.join(app).join(uri_parts.join("/")))
87 }
88}
89
90#[derive(Clone)]
91struct ServerState {
92 theme: Arc<Mutex<String>>,
93 ui_dir: PathBuf,
94}
95
96impl PreferencesObserver for ServerState {
97 fn prefs_changed(&self, changes: &[(&'static str, PrefValue)]) {
98 for (name, value) in changes {
99 if *name == "browserhtml.theme" {
100 let PrefValue::Str(theme) = value else {
101 error!("Unexpected value for browserhtml.theme: {value:?}");
102 return;
103 };
104 *self.theme.lock() = theme.to_string();
105 }
106 }
107 }
108}
109
110async fn get_file(
111 State(state): State<ServerState>,
112 request: Request,
113) -> impl axum::response::IntoResponse {
114 let path = vhost_handler(state, request).unwrap_or_default();
115 let service = ServeFile::new(path);
116 <ServeFile as tower::ServiceExt<HttpRequest<ServeFileSystemResponseBody>>>::oneshot(
117 service,
118 HttpRequest::default(),
119 )
120 .await
121}
122
123pub(crate) fn start_vhost(port: u16) {
124 // Get the initial value from the embedder preferences.
125 let theme = match get_embedder_pref("browserhtml.theme") {
126 Some(PrefValue::Str(value)) => value,
127 _ => "default".to_owned(),
128 };
129 let state = ServerState {
130 theme: Arc::new(Mutex::new(theme)),
131 ui_dir: ancestor_dir_path("ui"),
132 };
133
134 prefs::add_observer(Box::new(state.clone()));
135
136 // Configure CORS to allow cross-origin requests between localhost subdomains
137 let cors = CorsLayer::new()
138 .allow_origin(Any)
139 .allow_methods(Any)
140 .allow_headers(Any);
141
142 let compression = CompressionLayer::new().zstd(true);
143
144 let app = Router::new()
145 .route("/{*key}", get(get_file))
146 .layer(cors)
147 .layer(compression)
148 .with_state(state);
149
150 servo::Servo::spawn_task(async move {
151 let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
152 .await
153 .expect("Failed to bind on 127.0.0.1:{port}");
154 let _ = axum::serve(listener, app).await;
155 });
156}