Skip to main content

psibase/
http.rs

1#![allow(non_snake_case)]
2
3use crate::{AccountNumber, Hex, Pack, ToKey, ToSchema, Unpack};
4use anyhow::anyhow;
5use percent_encoding::percent_decode;
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7use std::borrow::Cow;
8use std::collections::HashMap;
9
10/// An HTTP header
11///
12/// Note: `http-server` aborts when most services set HTTP headers. It only allows services
13/// it trust to set them in order to enforce security rules.
14#[derive(
15    Debug, Default, PartialEq, Eq, Clone, Pack, Unpack, ToKey, ToSchema, Serialize, Deserialize,
16)]
17#[fracpack(definition_will_not_change, fracpack_mod = "fracpack")]
18#[to_key(psibase_mod = "crate")]
19pub struct HttpHeader {
20    /// Name of header, e.g. "Content-Security-Policy"
21    pub name: String,
22
23    /// Value of header
24    pub value: String,
25}
26
27impl HttpHeader {
28    pub fn new(name: &str, value: &str) -> Self {
29        HttpHeader {
30            name: name.to_string(),
31            value: value.to_string(),
32        }
33    }
34    pub fn matches(&self, name: &str) -> bool {
35        self.name.eq_ignore_ascii_case(name)
36    }
37}
38
39/// HTTP Status codes
40#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
41#[repr(u16)]
42pub enum HttpStatus {
43    Ok = 200,
44    MovedPermanently = 301,
45    Found = 302,
46    NotModified = 304,
47    Unauthorized = 401,
48    Forbidden = 403,
49    NotFound = 404,
50    MethodNotAllowed = 405,
51    NotAcceptable = 406,
52    UnsupportedMediaType = 415,
53    InternalServerError = 500,
54    ServiceUnavailable = 503,
55}
56
57/// An HTTP Request
58///
59/// Most services receive this via their [serveSys](crate::server_interface::ServerActions::serveSys)
60/// action. The `http-server` service receives it via its `serve` exported function.
61
62#[derive(
63    Debug, Default, PartialEq, Eq, Clone, Pack, Unpack, ToKey, ToSchema, Serialize, Deserialize,
64)]
65#[fracpack(fracpack_mod = "fracpack")]
66#[to_key(psibase_mod = "crate")]
67pub struct HttpRequest {
68    /// Fully-qualified domain name
69    pub host: String,
70
71    /// "GET" or "POST"
72    pub method: String,
73
74    /// Absolute path, e.g. "/index.js"
75    pub target: String,
76
77    /// "application/json", "text/html", ...
78    pub contentType: String,
79
80    /// HTTP Headers
81    pub headers: Vec<HttpHeader>,
82
83    /// Request body, e.g. POST data
84    pub body: Hex<Vec<u8>>,
85}
86
87impl HttpRequest {
88    pub fn path<'a>(&'a self) -> Cow<'a, str> {
89        let encoded = self
90            .target
91            .split_once('?')
92            .map_or(self.target.as_str(), |s| s.0);
93        return percent_decode(encoded.as_bytes()).decode_utf8_lossy();
94    }
95    pub fn query(&self) -> HashMap<String, String> {
96        let encoded = self.target.split_once('?').map_or("", |s| s.1);
97        return form_urlencoded::parse(encoded.as_bytes())
98            .into_owned()
99            .collect();
100    }
101    pub fn get_header(&self, name: &str) -> Option<&str> {
102        self.headers
103            .iter()
104            .find(|h| h.matches(name))
105            .map(|h| h.value.as_str())
106    }
107}
108
109pub struct HttpBody {
110    pub contentType: String,
111    pub body: Hex<Vec<u8>>,
112}
113
114impl HttpBody {
115    pub fn json(data: &str) -> Self {
116        HttpBody {
117            contentType: "application/json".into(),
118            body: data.to_string().into_bytes().into(),
119        }
120    }
121    pub fn graphql(query: &str) -> Self {
122        HttpBody {
123            contentType: "application/graphql".into(),
124            body: query.to_string().into_bytes().into(),
125        }
126    }
127}
128
129/// An HTTP reply
130///
131/// Services return this from their [serveSys](crate::server_interface::ServerActions::serveSys) action.
132#[derive(
133    Debug, Default, PartialEq, Eq, Clone, Pack, Unpack, ToSchema, ToKey, Serialize, Deserialize,
134)]
135#[fracpack(fracpack_mod = "fracpack")]
136#[to_key(psibase_mod = "crate")]
137pub struct HttpReply {
138    pub status: u16,
139
140    /// "application/json", "text/html", ...
141    pub contentType: String,
142
143    /// Response body
144    pub body: Hex<Vec<u8>>,
145
146    /// HTTP Headers
147    pub headers: Vec<HttpHeader>,
148}
149
150impl HttpReply {
151    pub fn text(self) -> Result<String, anyhow::Error> {
152        Ok(String::from_utf8(self.body.0)?)
153    }
154    pub fn json<T: DeserializeOwned>(self) -> Result<T, anyhow::Error> {
155        if self.status != 200 {
156            let status = self.status;
157            if self.contentType == "text/html" {
158                if let Ok(msg) = self.text() {
159                    Err(anyhow!("Request returned {} {}", status, msg))?
160                }
161            }
162            return Err(anyhow!("Request returned {}", status));
163        }
164        Ok(serde_json::de::from_str(&self.text()?)?)
165    }
166}
167
168struct Origin {
169    scheme: String,
170    host: String,
171}
172
173impl Origin {
174    fn new(url: &str) -> Self {
175        let mut scheme = String::new();
176        let mut host = String::new();
177        if let Some(pos) = url.find("://") {
178            scheme = url[..pos].to_string();
179            let after_scheme = &url[pos + 3..];
180            if let Some(colon_pos) = after_scheme.rfind(':') {
181                if !after_scheme[..colon_pos].contains(']') {
182                    host = after_scheme[..colon_pos].to_string();
183                } else {
184                    host = after_scheme.to_string();
185                }
186            } else {
187                host = after_scheme.to_string();
188            }
189        }
190        Origin { scheme, host }
191    }
192
193    fn is_secure(&self) -> bool {
194        self.scheme == "https" || self.host == "localhost" || self.host.ends_with(".localhost")
195    }
196
197    fn is_service(&self, root_host: &str, account: AccountNumber) -> bool {
198        self.is_secure() && self.host == format!("{}.{}", account, root_host)
199    }
200
201    fn is_subdomain(&self, root_host: &str) -> bool {
202        self.is_secure()
203            && (self.host == root_host || self.host.ends_with(&format!(".{}", root_host)))
204    }
205}
206
207pub fn root_host(req: &HttpRequest, host_is_subdomain: bool) -> &str {
208    if host_is_subdomain {
209        let pos = req.host.find('.').expect("Subdomain expected");
210        &req.host[pos + 1..]
211    } else {
212        &req.host
213    }
214}
215
216pub fn allow_cors_for_account(
217    req: &HttpRequest,
218    account: AccountNumber,
219    host_is_subdomain: bool,
220) -> Vec<HttpHeader> {
221    if let Some(o) = req.get_header("origin") {
222        let origin = Origin::new(o);
223        if origin.is_service(root_host(req, host_is_subdomain), account) {
224            return allow_cors_with_origin(o);
225        }
226    }
227    Vec::new()
228}
229
230pub fn allow_cors_for_subdomains(req: &HttpRequest, host_is_subdomain: bool) -> Vec<HttpHeader> {
231    if let Some(origin) = req.get_header("origin") {
232        let origin_obj = Origin::new(origin);
233        if origin_obj.is_subdomain(root_host(req, host_is_subdomain)) {
234            return allow_cors_with_origin(origin);
235        }
236    }
237    Vec::new()
238}
239
240pub fn allow_cors_with_origin(origin: &str) -> Vec<HttpHeader> {
241    vec![
242        HttpHeader::new("Access-Control-Allow-Origin", origin),
243        HttpHeader::new("Access-Control-Allow-Methods", "POST, GET, OPTIONS, HEAD"),
244        HttpHeader::new("Access-Control-Allow-Headers", "*"),
245    ]
246}