Skip to main content

ocre/
request.rs

1//! Request helpers: the client's IP address, a request id, the response
2//! format a client asks for, Markdown responses, and redirecting back to
3//! the previous page.
4
5use std::{convert::Infallible, net::IpAddr};
6
7use axum::{
8    extract::FromRequestParts,
9    http::{HeaderMap, HeaderValue, header, request::Parts},
10    response::{IntoResponse, Redirect, Response},
11};
12
13/// Extractor for the client's IP address, from Cloudflare's `CF-Connecting-IP` header.
14///
15/// Cloudflare sets `CF-Connecting-IP` on every request that reaches a Worker
16/// and replaces any value the client sent, so it can be trusted, unlike
17/// `X-Forwarded-For` (Rails' `request.remote_ip`, Loco's `RemoteIP`). It is
18/// `RemoteIp(None)` only when the header is missing or malformed, e.g. in unit
19/// tests that build requests by hand. Never rejects. Use it to key rate limits
20/// or to record where a sign-in came from; it is personal data under the GDPR.
21///
22/// # Examples
23///
24/// ```no_run
25/// use ocre::RemoteIp;
26///
27/// async fn whoami(RemoteIp(ip): RemoteIp) -> String {
28///     ip.map_or_else(|| "unknown".to_owned(), |ip| ip.to_string())
29/// }
30/// # let _ = whoami;
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct RemoteIp(pub Option<IpAddr>);
34
35impl<S: Send + Sync> FromRequestParts<S> for RemoteIp {
36    type Rejection = Infallible;
37
38    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
39        Ok(Self(remote_ip(&parts.headers)))
40    }
41}
42
43/// The client's IP address from the `CF-Connecting-IP` header, for code that has the headers but no extractor.
44///
45/// See [`RemoteIp`].
46///
47/// # Examples
48///
49/// ```
50/// use axum::http::HeaderMap;
51///
52/// let mut headers = HeaderMap::new();
53/// headers.insert("cf-connecting-ip", "203.0.113.7".parse().unwrap());
54/// assert_eq!(ocre::remote_ip(&headers).unwrap().to_string(), "203.0.113.7");
55/// assert_eq!(ocre::remote_ip(&HeaderMap::new()), None);
56/// ```
57pub fn remote_ip(headers: &HeaderMap) -> Option<IpAddr> {
58    headers.get("cf-connecting-ip")?.to_str().ok()?.trim().parse().ok()
59}
60
61/// Extractor for an identifier of the request, to correlate log lines and error reports.
62///
63/// Cloudflare's `CF-Ray` id (e.g. `8c2f1a0b9d3e4f5a-CDG`) when present, which
64/// is also shown in the Cloudflare dashboard and in Workers Logs; otherwise
65/// the client's `X-Request-Id` when it is 1 to 64 letters, digits, `-` or
66/// `_` (Loco's `request_id`); otherwise 16 random hex characters. Never
67/// rejects; no binding call. [`serve`](crate::serve) picks it once per
68/// request: the same id is in every line of [`Ctx::log`](crate::Ctx::log),
69/// in error reports and in the `X-Request-Id` response header.
70///
71/// # Examples
72///
73/// ```no_run
74/// use ocre::RequestId;
75///
76/// async fn checkout(RequestId(id): RequestId) -> &'static str {
77///     worker::console_log!("[{id}] checkout started");
78///     "OK"
79/// }
80/// # let _ = checkout;
81/// ```
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct RequestId(pub String);
84
85impl<S: Send + Sync> FromRequestParts<S> for RequestId {
86    type Rejection = Infallible;
87
88    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
89        match parts.extensions.get::<Self>() {
90            Some(id) => Ok(id.clone()),
91            None => Ok(Self(request_id(&parts.headers))),
92        }
93    }
94}
95
96pub(crate) fn request_id(headers: &HeaderMap) -> String {
97    let valid = |id: &&str| {
98        (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
99    };
100    ["cf-ray", "x-request-id"]
101        .iter()
102        .find_map(|name| headers.get(*name)?.to_str().ok().filter(valid).map(str::to_owned))
103        .unwrap_or_else(|| crate::token::random_bytes::<8>().iter().map(|b| format!("{b:02x}")).collect())
104}
105
106/// Response format a client asks for in its `Accept` header, for actions that answer HTML or JSON (Rails' `respond_to`).
107///
108/// The media type with the highest `q` wins (first listed on ties):
109/// `text/html` and `application/xhtml+xml` are [`Html`](Self::Html),
110/// `application/json` and any `+json` type [`Json`](Self::Json),
111/// `application/xml`, `text/xml` and `+xml` types [`Xml`](Self::Xml),
112/// `text/plain` [`Text`](Self::Text), `text/markdown` [`Markdown`](Self::Markdown),
113/// `*/*` and `text/*` the first format of the list above. A missing or empty
114/// `Accept` header is `Html`, like
115/// Rails; types Ocre does not know are [`Other`](Self::Other), usually
116/// answered with `406 Not Acceptable`. Never rejects; no binding call.
117///
118/// # Examples
119///
120/// ```no_run
121/// use axum::{extract::{Path, State}, response::{Html, IntoResponse, Response}};
122/// use ocre::{Ctx, Format, Json, OptionExt, Result, params};
123///
124/// async fn show(State(ctx): State<Ctx>, format: Format, Path(id): Path<i64>) -> Result<Response> {
125///     let post: serde_json::Value =
126///         ctx.db()?.first("SELECT id, title FROM posts WHERE id = ?1", params![id]).await?.or_404()?;
127///     Ok(match format {
128///         Format::Json => Json(post).into_response(),
129///         _ => Html(format!("<h1>{}</h1>", post["title"])).into_response(),
130///     })
131/// }
132/// # let _ = show;
133/// ```
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Format {
136    /// `text/html`: a page.
137    Html,
138    /// `application/json`.
139    Json,
140    /// `application/xml` or `text/xml`, e.g. an RSS or Atom feed.
141    Xml,
142    /// `text/plain`.
143    Text,
144    /// `text/markdown`, e.g. for LLM clients (Rails' `format.md`); answer with [`Markdown`].
145    Markdown,
146    /// Only types Ocre does not know, e.g. `application/pdf`.
147    Other,
148}
149
150impl Format {
151    /// Reads the `Accept` header; see [`Format`] for the rules.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use axum::http::HeaderMap;
157    /// use ocre::Format;
158    ///
159    /// let mut headers = HeaderMap::new();
160    /// headers.insert("accept", "text/html;q=0.9, application/json".parse().unwrap());
161    /// assert_eq!(Format::from_headers(&headers), Format::Json);
162    /// assert_eq!(Format::from_headers(&HeaderMap::new()), Format::Html);
163    /// ```
164    pub fn from_headers(headers: &HeaderMap) -> Self {
165        let Some(accept) = headers.get(header::ACCEPT).and_then(|value| value.to_str().ok()) else {
166            return Self::Html;
167        };
168        let mut best: Option<(f32, Self)> = None;
169        for item in accept.split(',') {
170            let mut parts = item.split(';');
171            let media = parts.next().unwrap_or_default().trim().to_ascii_lowercase();
172            if media.is_empty() {
173                continue;
174            }
175            let q = parts
176                .filter_map(|param| param.trim().strip_prefix("q="))
177                .find_map(|q| q.trim().parse::<f32>().ok())
178                .unwrap_or(1.0);
179            if q <= 0.0 {
180                continue;
181            }
182            let format = Self::from_media(&media);
183            if best.is_none_or(|(best_q, _)| q > best_q) {
184                best = Some((q, format));
185            }
186        }
187        best.map_or(Self::Html, |(_, format)| format)
188    }
189
190    fn from_media(media: &str) -> Self {
191        match media {
192            "text/html" | "application/xhtml+xml" | "*/*" | "text/*" => Self::Html,
193            "application/json" => Self::Json,
194            "application/xml" | "text/xml" => Self::Xml,
195            "text/plain" => Self::Text,
196            "text/markdown" => Self::Markdown,
197            _ if media.ends_with("+json") => Self::Json,
198            _ if media.ends_with("+xml") => Self::Xml,
199            _ => Self::Other,
200        }
201    }
202}
203
204impl<S: Send + Sync> FromRequestParts<S> for Format {
205    type Rejection = Infallible;
206
207    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
208        Ok(Self::from_headers(&parts.headers))
209    }
210}
211
212/// A Markdown response: `text/markdown; charset=utf-8` (Rails' `render markdown:`).
213///
214/// Pairs with [`Format::Markdown`] to offer a page as Markdown to clients
215/// that ask for it, such as LLM agents; build the text with `format!` or an
216/// askama template (`templates/posts/show.md`). No binding call.
217///
218/// # Examples
219///
220/// ```
221/// use axum::response::{Html, IntoResponse, Response};
222/// use ocre::{Format, Markdown};
223///
224/// fn show(format: Format, title: &str, body: &str) -> Response {
225///     match format {
226///         Format::Markdown => Markdown(format!("# {title}\n\n{body}\n")).into_response(),
227///         _ => Html(format!("<h1>{title}</h1><p>{body}</p>")).into_response(),
228///     }
229/// }
230///
231/// let response = show(Format::Markdown, "Hello", "World");
232/// assert_eq!(response.headers()["content-type"], "text/markdown; charset=utf-8");
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Markdown(pub String);
236
237impl IntoResponse for Markdown {
238    fn into_response(self) -> Response {
239        ([(header::CONTENT_TYPE, HeaderValue::from_static("text/markdown; charset=utf-8"))], self.0).into_response()
240    }
241}
242
243/// Redirects to the page the request came from, or to `fallback` (Rails' `redirect_back_or_to`).
244///
245/// Uses the `Referer` header only when it points to this app (same host as
246/// the request's `Host` header), so a link from another site cannot turn
247/// the app into an open redirect; the redirect keeps the referring path and
248/// query string. Answers `303 See Other`, like [`Redirect::to`]. Browsers
249/// send `Referer` for same-origin requests under Ocre's default
250/// `Referrer-Policy`.
251///
252/// # Examples
253///
254/// ```
255/// use axum::{http::HeaderMap, response::IntoResponse};
256///
257/// let mut headers = HeaderMap::new();
258/// headers.insert("host", "blog.example".parse().unwrap());
259/// headers.insert("referer", "https://blog.example/posts?page=2".parse().unwrap());
260/// let response = ocre::redirect_back(&headers, "/").into_response();
261/// assert_eq!(response.headers()["location"], "/posts?page=2");
262///
263/// headers.insert("referer", "https://evil.example/".parse().unwrap());
264/// let response = ocre::redirect_back(&headers, "/").into_response();
265/// assert_eq!(response.headers()["location"], "/");
266/// ```
267pub fn redirect_back(headers: &HeaderMap, fallback: &str) -> Redirect {
268    let text = |name| headers.get(name).and_then(|value: &axum::http::HeaderValue| value.to_str().ok());
269    let back = (|| {
270        let (host, referer) = (text(header::HOST)?, text(header::REFERER)?);
271        let rest = referer.strip_prefix("https://").or_else(|| referer.strip_prefix("http://"))?;
272        let (referer_host, path) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
273        let path = if path.is_empty() { "/" } else { path };
274        let path = path.split('#').next().unwrap_or(path);
275        (referer_host.eq_ignore_ascii_case(host) && !path.starts_with("//")).then_some(path)
276    })();
277    Redirect::to(back.unwrap_or(fallback))
278}
279
280/// A route path with its non-ASCII characters percent-encoded, so Unicode routes match (Rails' Unicode routes).
281///
282/// Browsers send `/über-uns` as `/%C3%BCber-uns`, and axum matches the path
283/// as sent. Write the route in Unicode and wrap it:
284/// `.route(&ocre::encode_path("/über-uns"), get(about))`. Bytes outside
285/// printable ASCII (UTF-8 multibyte characters, spaces) become `%XX` with
286/// uppercase hex, as browsers send them; `{id}` captures and `/` are kept,
287/// and axum decodes captured values (`Path<String>`) back to Unicode. Pure
288/// CPU, once when the router is built.
289///
290/// # Examples
291///
292/// ```
293/// use axum::{Router, routing::get};
294///
295/// assert_eq!(ocre::encode_path("/über-uns"), "/%C3%BCber-uns");
296/// assert_eq!(ocre::encode_path("/café/{id}"), "/caf%C3%A9/{id}");
297///
298/// let app: Router = Router::new().route(&ocre::encode_path("/über-uns"), get(|| async { "Über uns" }));
299/// # let _ = app;
300/// ```
301pub fn encode_path(path: &str) -> String {
302    const HEX: &[u8; 16] = b"0123456789ABCDEF";
303    let mut encoded = String::with_capacity(path.len());
304    for byte in path.bytes() {
305        if (0x21..=0x7E).contains(&byte) {
306            encoded.push(char::from(byte));
307        } else {
308            encoded.extend(['%', char::from(HEX[usize::from(byte >> 4)]), char::from(HEX[usize::from(byte & 15)])]);
309        }
310    }
311    encoded
312}
313
314#[cfg(test)]
315#[path = "../tests/request.rs"]
316mod tests;