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;