Skip to main content

netget/server/nostr/
http.rs

1//! The HTTP half of a Nostr relay: one request head, then either a WebSocket upgrade (RFC 6455
2//! §4.2), the NIP-11 relay information document, or a one-line page for a browser.
3//!
4//! Hand-written for the same reason `websocket` hand-writes its handshake: the head has to be
5//! read before it is known whether the peer wants a WebSocket at all, and NIP-11 is served on
6//! the same URL to a plain GET that carries `Accept: application/nostr+json`. Everything below
7//! the upgrade — framing, masking, pings, the closing handshake — is tungstenite's.
8
9use serde_json::{json, Value};
10
11/// Largest request head accepted. A real upgrade is well under 2 KiB.
12pub const MAX_REQUEST_HEAD: usize = 16 * 1024;
13
14/// A request line and its headers.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RequestHead {
17    pub method: String,
18    pub target: String,
19    pub version: String,
20    /// Names lowercased, values trimmed, in order.
21    pub headers: Vec<(String, String)>,
22}
23
24impl RequestHead {
25    pub fn header(&self, name: &str) -> Option<&str> {
26        self.headers
27            .iter()
28            .find(|(k, _)| k.eq_ignore_ascii_case(name))
29            .map(|(_, v)| v.as_str())
30    }
31
32    fn header_has_token(&self, name: &str, token: &str) -> bool {
33        self.headers
34            .iter()
35            .filter(|(k, _)| k.eq_ignore_ascii_case(name))
36            .any(|(_, v)| v.split(',').any(|t| t.trim().eq_ignore_ascii_case(token)))
37    }
38
39    /// The peer asked for a WebSocket.
40    pub fn wants_upgrade(&self) -> bool {
41        self.header_has_token("upgrade", "websocket")
42    }
43
44    /// The peer asked for the NIP-11 document.
45    pub fn wants_relay_info(&self) -> bool {
46        self.header("accept")
47            .is_some_and(|a| a.to_ascii_lowercase().contains("application/nostr+json"))
48    }
49}
50
51/// Index of the blank line ending the head.
52pub fn find_head_end(buf: &[u8]) -> Option<usize> {
53    buf.windows(4).position(|w| w == b"\r\n\r\n")
54}
55
56/// Parse a request head (without its terminating blank line).
57pub fn parse_request_head(bytes: &[u8]) -> Result<RequestHead, String> {
58    let text = std::str::from_utf8(bytes).map_err(|_| "request head is not UTF-8".to_string())?;
59    let mut lines = text.split("\r\n");
60    let mut parts = lines.next().unwrap_or("").split(' ');
61    let method = parts.next().unwrap_or("").to_string();
62    let target = parts.next().unwrap_or("").to_string();
63    let version = parts.next().unwrap_or("").to_string();
64    if method.is_empty() || target.is_empty() || !version.starts_with("HTTP/") {
65        return Err("malformed request line".to_string());
66    }
67    let mut headers = Vec::new();
68    for line in lines {
69        if line.is_empty() {
70            break;
71        }
72        let (name, value) = line
73            .split_once(':')
74            .ok_or_else(|| "malformed header line".to_string())?;
75        if name.is_empty() || name.ends_with(' ') || name.starts_with([' ', '\t']) {
76            return Err("malformed header name".to_string());
77        }
78        headers.push((name.to_ascii_lowercase(), value.trim().to_string()));
79    }
80    Ok(RequestHead {
81        method,
82        target,
83        version,
84        headers,
85    })
86}
87
88/// Check an upgrade request against RFC 6455 §4.2.1 and return its `Sec-WebSocket-Key`.
89/// The refusal is the status and a fixed reason.
90pub fn validate_upgrade(head: &RequestHead) -> Result<String, (u16, &'static str)> {
91    if !head.method.eq_ignore_ascii_case("GET") {
92        return Err((405, "a WebSocket upgrade is a GET"));
93    }
94    if head.version != "HTTP/1.1" {
95        return Err((505, "a WebSocket upgrade needs HTTP/1.1"));
96    }
97    if !head.header_has_token("connection", "upgrade") {
98        return Err((400, "missing Connection: Upgrade"));
99    }
100    if head.header("sec-websocket-version") != Some("13") {
101        return Err((426, "this relay speaks WebSocket version 13"));
102    }
103    let key = head
104        .header("sec-websocket-key")
105        .ok_or((400, "missing Sec-WebSocket-Key"))?;
106    use base64::Engine as _;
107    match base64::engine::general_purpose::STANDARD.decode(key) {
108        Ok(raw) if raw.len() == 16 => Ok(key.to_string()),
109        _ => Err((400, "Sec-WebSocket-Key is not a base64 16-byte nonce")),
110    }
111}
112
113/// The `101 Switching Protocols` answer.
114pub fn accept_response(key: &str) -> String {
115    let accept = tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes());
116    format!(
117        "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
118         Sec-WebSocket-Accept: {accept}\r\n\r\n"
119    )
120}
121
122/// NIP-11: "Relays MUST accept CORS requests by sending Access-Control-Allow-Origin,
123/// Access-Control-Allow-Headers, and Access-Control-Allow-Methods headers."
124const CORS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Headers: *\r\n\
125                    Access-Control-Allow-Methods: GET, OPTIONS\r\n";
126
127/// A complete HTTP response that closes the connection.
128pub fn response(status: u16, content_type: &str, extra_headers: &str, body: &str) -> Vec<u8> {
129    let phrase = match status {
130        200 => "OK",
131        204 => "No Content",
132        400 => "Bad Request",
133        405 => "Method Not Allowed",
134        408 => "Request Timeout",
135        426 => "Upgrade Required",
136        431 => "Request Header Fields Too Large",
137        503 => "Service Unavailable",
138        505 => "HTTP Version Not Supported",
139        _ => "Error",
140    };
141    let mut out = format!("HTTP/1.1 {status} {phrase}\r\n{extra_headers}");
142    if status == 426 {
143        out.push_str("Sec-WebSocket-Version: 13\r\n");
144    }
145    out.push_str("Connection: close\r\n");
146    if !body.is_empty() {
147        out.push_str(&format!("Content-Type: {content_type}\r\n"));
148    }
149    out.push_str(&format!("Content-Length: {}\r\n\r\n{body}", body.len()));
150    out.into_bytes()
151}
152
153/// A plain-text error response.
154pub fn error_response(status: u16, reason: &str) -> Vec<u8> {
155    response(
156        status,
157        "text/plain; charset=utf-8",
158        "",
159        &format!("{reason}\n"),
160    )
161}
162
163/// What a browser (a GET that asks for neither) is shown — the same page nostr-rs-relay serves.
164pub fn browser_response() -> Vec<u8> {
165    response(
166        200,
167        "text/plain; charset=utf-8",
168        CORS,
169        "Please use a Nostr client to connect.\n",
170    )
171}
172
173/// The CORS preflight answer.
174pub fn preflight_response() -> Vec<u8> {
175    response(204, "", CORS, "")
176}
177
178/// The NIP-11 answer.
179pub fn relay_info_response(document: &Value) -> Vec<u8> {
180    let body = serde_json::to_string(document).expect("relay info is plain JSON");
181    response(200, "application/nostr+json", CORS, &body)
182}
183
184/// What NIP-11 describes, all from startup parameters and the relay's own limits.
185#[derive(Debug, Clone)]
186pub struct RelayInfo {
187    pub name: String,
188    pub description: String,
189    pub supported_nips: Vec<u64>,
190    /// The key the relay signs the model's events with (NIP-11 `self`).
191    pub relay_pubkey: String,
192}
193
194impl RelayInfo {
195    pub fn document(&self) -> Value {
196        use super::wire;
197        json!({
198            "name": self.name,
199            "description": self.description,
200            "self": self.relay_pubkey,
201            "supported_nips": self.supported_nips,
202            "software": "https://github.com/netget-net/netget",
203            "version": env!("CARGO_PKG_VERSION"),
204            "limitation": {
205                "max_message_length": wire::MAX_MESSAGE_BYTES,
206                "max_subscriptions": wire::MAX_SUBSCRIPTIONS,
207                "max_filters": wire::MAX_FILTERS,
208                "max_subid_length": wire::MAX_SUBSCRIPTION_ID_CHARS,
209                "max_event_tags": wire::MAX_TAGS,
210                "auth_required": false,
211                "payment_required": false,
212            },
213        })
214    }
215}