Coverage Report

Created: 2026-06-09 03:07

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}