/home/runner/work/lyquor/lyquor/node/src/http/lyquid_site.rs
Line | Count | Source |
1 | | use std::str::FromStr; |
2 | | |
3 | | use axum::{ |
4 | | body::Body, |
5 | | extract::{Request, State}, |
6 | | http::{ |
7 | | HeaderValue, Method, StatusCode, Uri, |
8 | | header::{CONTENT_LENGTH, CONTENT_TYPE, HOST}, |
9 | | uri::PathAndQuery, |
10 | | }, |
11 | | middleware::Next, |
12 | | response::Response, |
13 | | }; |
14 | | use lyquor_hosting::ProcessManagerHandle; |
15 | | use lyquor_primitives::{Bytes, LyquidID}; |
16 | | |
17 | | use super::jsonrpc; |
18 | | |
19 | | const SPECIAL_API_PATH: &str = "/lyquid/api"; |
20 | | const SPECIAL_WS_PATH: &str = "/lyquid/ws"; |
21 | | |
22 | | #[derive(Clone)] |
23 | | pub(super) struct LyquidSiteRouteState { |
24 | | node_dns_label: String, |
25 | | jsonrpc_state: jsonrpc::JsonRpcRouteState, |
26 | | process_manager: ProcessManagerHandle, |
27 | | } |
28 | | |
29 | | impl LyquidSiteRouteState { |
30 | 0 | pub(super) fn new( |
31 | 0 | node_dns_label: String, jsonrpc_state: jsonrpc::JsonRpcRouteState, process_manager: ProcessManagerHandle, |
32 | 0 | ) -> Self { |
33 | 0 | Self { |
34 | 0 | node_dns_label: node_dns_label.to_ascii_lowercase(), |
35 | 0 | jsonrpc_state, |
36 | 0 | process_manager, |
37 | 0 | } |
38 | 0 | } |
39 | | } |
40 | | |
41 | 0 | pub(super) async fn route(State(state): State<LyquidSiteRouteState>, mut req: Request, next: Next) -> Response { |
42 | 0 | let Some(lyquid_id) = lyquid_id_from_request_host(&req, &state.node_dns_label) else { |
43 | 0 | return next.run(req).await; |
44 | | }; |
45 | | |
46 | 0 | match req.uri().path() { |
47 | 0 | SPECIAL_API_PATH => { |
48 | 0 | rewrite_request_path(&mut req, "/api"); |
49 | 0 | jsonrpc::api_route(req, state.jsonrpc_state).await |
50 | | } |
51 | 0 | SPECIAL_WS_PATH => { |
52 | 0 | rewrite_request_path(&mut req, "/ws"); |
53 | 0 | jsonrpc::ws_route(req, state.jsonrpc_state).await |
54 | | } |
55 | 0 | path if path.starts_with("/lyquid/") => text_response(StatusCode::NOT_FOUND, "Not found"), |
56 | 0 | _ => serve_asset(req, state, lyquid_id).await, |
57 | | } |
58 | 0 | } |
59 | | |
60 | 0 | async fn serve_asset(req: Request, state: LyquidSiteRouteState, lyquid_id: LyquidID) -> Response { |
61 | 0 | let method = req.method().clone(); |
62 | 0 | if method != Method::GET && method != Method::HEAD { |
63 | 0 | return text_response(StatusCode::METHOD_NOT_ALLOWED, "Method not allowed"); |
64 | 0 | } |
65 | | |
66 | 0 | let Some(asset_name) = normalize_asset_path(req.uri().path()) else { |
67 | 0 | return text_response(StatusCode::NOT_FOUND, "Not found"); |
68 | | }; |
69 | | |
70 | 0 | let lyquid = match state.process_manager.get_lyquid(lyquid_id).await { |
71 | 0 | Ok(Some(lyquid)) => lyquid, |
72 | 0 | Ok(None) => return text_response(StatusCode::NOT_FOUND, "Not found"), |
73 | 0 | Err(err) => { |
74 | 0 | tracing::warn!( |
75 | | %lyquid_id, |
76 | | error = ?err, |
77 | | "Failed to resolve hosted Lyquid for static asset request" |
78 | | ); |
79 | 0 | return text_response(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"); |
80 | | } |
81 | | }; |
82 | | |
83 | 0 | match lyquid.load_asset(&asset_name) { |
84 | 0 | Some(asset) => asset_response(&asset_name, asset, method == Method::HEAD), |
85 | 0 | None => text_response(StatusCode::NOT_FOUND, "Not found"), |
86 | | } |
87 | 0 | } |
88 | | |
89 | 0 | fn lyquid_id_from_request_host(req: &Request, node_dns_label: &str) -> Option<LyquidID> { |
90 | 0 | let host = req |
91 | 0 | .headers() |
92 | 0 | .get(HOST) |
93 | 0 | .and_then(|host| host.to_str().ok()) |
94 | 0 | .or_else(|| req.uri().authority().map(|authority| authority.as_str()))?; |
95 | 0 | lyquid_id_from_host(host, node_dns_label) |
96 | 0 | } |
97 | | |
98 | 3 | fn lyquid_id_from_host(host: &str, node_dns_label: &str) -> Option<LyquidID> { |
99 | 3 | let host = host |
100 | 3 | .split_once(':') |
101 | 3 | .map(|(host, _)| host) |
102 | 3 | .unwrap_or(host) |
103 | 3 | .trim_end_matches('.') |
104 | 3 | .to_ascii_lowercase(); |
105 | 3 | let mut labels = host.split('.'); |
106 | 3 | let lyquid_label = labels.next()?0 ; |
107 | 3 | let node_label = labels.next()?0 ; |
108 | 3 | if node_label != node_dns_label { |
109 | 1 | return None; |
110 | 2 | } |
111 | | |
112 | | // Match only `<lyquid_id>.<node_id>` and intentionally leave the DNS suffix |
113 | | // unconstrained. ACME/DNS publication for a configured suffix is optional, |
114 | | // and operators may serve the same node under a different canonical FQDN. |
115 | | // Network-wide suffix enforcement belongs to the sequence/UPC layer, not |
116 | | // this HTTP host routing path. |
117 | 2 | lyquid_id_from_dns_label(lyquid_label) |
118 | 3 | } |
119 | | |
120 | 2 | fn lyquid_id_from_dns_label(label: &str) -> Option<LyquidID> { |
121 | 2 | LyquidID::from_str(&format!("Lyquid-{label}")).ok() |
122 | 2 | } |
123 | | |
124 | 6 | fn normalize_asset_path(path: &str) -> Option<String> { |
125 | 6 | let path = path.strip_prefix('/')?0 ; |
126 | 6 | let asset_name = if path.is_empty() { |
127 | 1 | "index.html".to_owned() |
128 | 5 | } else if path.ends_with('/') { |
129 | 1 | format!("{path}index.html") |
130 | | } else { |
131 | 4 | path.to_owned() |
132 | | }; |
133 | | |
134 | 6 | if asset_name.contains('\\') || |
135 | 5 | asset_name |
136 | 5 | .split('/') |
137 | 8 | .any5 (|segment| segment.is_empty() || segment == "."7 || segment == ".."7 ) |
138 | | { |
139 | 3 | return None; |
140 | 3 | } |
141 | | |
142 | 3 | Some(asset_name) |
143 | 6 | } |
144 | | |
145 | 0 | fn rewrite_request_path(req: &mut Request, path: &'static str) { |
146 | 0 | let mut parts = req.uri().clone().into_parts(); |
147 | 0 | let path_and_query = match req.uri().query() { |
148 | 0 | Some(query) => format!("{path}?{query}"), |
149 | 0 | None => path.to_owned(), |
150 | | }; |
151 | 0 | parts.path_and_query = |
152 | 0 | Some(PathAndQuery::from_str(&path_and_query).expect("rewritten Lyquid special path should be valid")); |
153 | 0 | *req.uri_mut() = Uri::from_parts(parts).expect("rewritten Lyquid special URI should be valid"); |
154 | 0 | } |
155 | | |
156 | 0 | fn asset_response(asset_name: &str, asset: Bytes, head: bool) -> Response { |
157 | 0 | let content_type = content_type_for_asset(asset_name); |
158 | 0 | let length = asset.len().to_string(); |
159 | 0 | let body = if head { Body::empty() } else { Body::from(asset) }; |
160 | | |
161 | 0 | Response::builder() |
162 | 0 | .status(StatusCode::OK) |
163 | 0 | .header(CONTENT_TYPE, content_type) |
164 | 0 | .header(CONTENT_LENGTH, length) |
165 | 0 | .body(body) |
166 | 0 | .expect("asset response should be valid") |
167 | 0 | } |
168 | | |
169 | 0 | fn content_type_for_asset(asset_name: &str) -> HeaderValue { |
170 | 0 | let content_type = match asset_name.rsplit('.').next().unwrap_or_default() { |
171 | 0 | "css" => "text/css; charset=utf-8", |
172 | 0 | "gif" => "image/gif", |
173 | 0 | "html" | "htm" => "text/html; charset=utf-8", |
174 | 0 | "ico" => "image/x-icon", |
175 | 0 | "jpeg" | "jpg" => "image/jpeg", |
176 | 0 | "js" | "mjs" => "text/javascript; charset=utf-8", |
177 | 0 | "json" => "application/json; charset=utf-8", |
178 | 0 | "map" => "application/json; charset=utf-8", |
179 | 0 | "png" => "image/png", |
180 | 0 | "svg" => "image/svg+xml", |
181 | 0 | "txt" => "text/plain; charset=utf-8", |
182 | 0 | "wasm" => "application/wasm", |
183 | 0 | "webp" => "image/webp", |
184 | 0 | _ => "application/octet-stream", |
185 | | }; |
186 | 0 | HeaderValue::from_static(content_type) |
187 | 0 | } |
188 | | |
189 | 0 | fn text_response(status: StatusCode, body: &'static str) -> Response { |
190 | 0 | Response::builder() |
191 | 0 | .status(status) |
192 | 0 | .header(CONTENT_TYPE, "text/plain; charset=utf-8") |
193 | 0 | .body(Body::from(body)) |
194 | 0 | .expect("text response should be valid") |
195 | 0 | } |
196 | | |
197 | | #[cfg(test)] |
198 | | mod tests { |
199 | | use super::*; |
200 | | |
201 | | #[test] |
202 | 1 | fn host_parser_accepts_raw_lyquid_labels() { |
203 | 1 | let id = LyquidID::from(42_u64); |
204 | 1 | let node = "node-label"; |
205 | 1 | let raw_host = format!("{}.{}.example.test", id.as_dns_label(), node); |
206 | 1 | let prefixed_host = format!("lyquid-{}.{}.example.test", id.as_dns_label(), node); |
207 | | |
208 | 1 | assert_eq!(lyquid_id_from_host(&raw_host, node), Some(id)); |
209 | 1 | assert_eq!(lyquid_id_from_host(&prefixed_host, node), None); |
210 | 1 | assert_eq!(lyquid_id_from_host(&raw_host, "other-node"), None); |
211 | 1 | } |
212 | | |
213 | | #[test] |
214 | 1 | fn asset_paths_are_normalized_without_traversal() { |
215 | 1 | assert_eq!(normalize_asset_path("/"), Some("index.html".to_owned())); |
216 | 1 | assert_eq!(normalize_asset_path("/src/app.js"), Some("src/app.js".to_owned())); |
217 | 1 | assert_eq!(normalize_asset_path("/docs/"), Some("docs/index.html".to_owned())); |
218 | 1 | assert_eq!(normalize_asset_path("/../secret"), None); |
219 | 1 | assert_eq!(normalize_asset_path("/src//app.js"), None); |
220 | 1 | assert_eq!(normalize_asset_path(r"/src\app.js"), None); |
221 | 1 | } |
222 | | } |