Rewild Your Web
web browser dweb
at main 156 lines 4.3 kB view raw
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}