Skip to main content

rws_server/flash/
mod.rs

1//! Flash messages and redirects (18.35.0, #94): a message set on one
2//! response — usually a redirect after a form post — and shown on the next
3//! page, once.
4//!
5//! ```rust
6//! # extern crate rws_server as rust_web_server;
7//! use rust_web_server::app::App;
8//! use rust_web_server::error::IntoResponse;
9//! use rust_web_server::extract::FromRequest;
10//! use rust_web_server::flash::{Flash, FlashLayer, Redirect};
11//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
12//! use rust_web_server::test_client::TestClient;
13//!
14//! let app = App::with_state(())
15//!     .post("/posts", |_, _, _, _| Redirect::to("/posts").notice("Post saved").into_response())
16//!     .get("/posts", |request, _, _, _| {
17//!         let flash = Flash::from_request(request).unwrap();
18//!         Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, flash.notice().unwrap_or(""))
19//!     })
20//!     .wrap(FlashLayer::new());
21//!
22//! let client = TestClient::new(app);
23//! let saved = client.post("/posts").send();
24//! assert_eq!(303, saved.status());
25//! ```
26//!
27//! The messages travel in the `rws_flash` cookie (`HttpOnly`,
28//! `SameSite=Lax`, `Path=/`), percent-encoded: readable and changeable by
29//! the browser, so never put anything in a flash that the page trusts —
30//! templates escape it like any other text. [`FlashLayer`] removes the
31//! cookie from the browser after the request that carried it, unless the
32//! response sets a new flash. A flash cookie is capped at
33//! [`MAX_COOKIE_BYTES`]; messages past it are dropped.
34
35#[cfg(test)]
36mod tests;
37
38use crate::application::Application;
39use crate::cookie::{CookieJar, SetCookie};
40use crate::error::IntoResponse;
41use crate::extract::FromRequest;
42use crate::header::Header;
43use crate::middleware::Middleware;
44use crate::request::Request;
45use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
46use crate::router::names::encode;
47use crate::server::ConnectionInfo;
48
49/// The cookie flash messages travel in.
50pub const COOKIE_NAME: &str = "rws_flash";
51
52/// The largest flash cookie value written; later messages are dropped.
53pub const MAX_COOKIE_BYTES: usize = 3800;
54
55/// One message: its kind (`notice`, `alert`, or your own) and its text.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct FlashMessage {
58    pub kind: String,
59    pub text: String,
60}
61
62/// The flash messages a request carried. A handler argument
63/// (`FromRequest`, never fails); empty when there are none.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct Flash {
66    messages: Vec<FlashMessage>,
67}
68
69impl Flash {
70    /// The messages in `request`'s flash cookie.
71    pub fn read(request: &Request) -> Flash {
72        let Some(header) = request.header("cookie") else {
73            return Flash::default();
74        };
75        match CookieJar::parse(&header.value).get(COOKIE_NAME) {
76            Some(cookie) => Flash { messages: decode_messages(&cookie.value) },
77            None => Flash::default(),
78        }
79    }
80
81    /// Every message, in the order they were set.
82    pub fn messages(&self) -> &[FlashMessage] {
83        &self.messages
84    }
85
86    /// The first message of `kind`.
87    pub fn get(&self, kind: &str) -> Option<&str> {
88        self.messages.iter().find(|m| m.kind == kind).map(|m| m.text.as_str())
89    }
90
91    /// The first `notice` message.
92    pub fn notice(&self) -> Option<&str> {
93        self.get("notice")
94    }
95
96    /// The first `alert` message.
97    pub fn alert(&self) -> Option<&str> {
98        self.get("alert")
99    }
100
101    pub fn is_empty(&self) -> bool {
102        self.messages.is_empty()
103    }
104}
105
106impl FromRequest for Flash {
107    fn from_request(request: &Request) -> Result<Self, Response> {
108        Ok(Flash::read(request))
109    }
110}
111
112/// Add a flash message to `response` (after any it already sets).
113pub fn set(response: &mut Response, kind: &str, text: &str) {
114    let mut messages = Vec::new();
115    response.headers.retain(|h| {
116        let ours = h.name.eq_ignore_ascii_case(Header::_SET_COOKIE) && h.value.starts_with(&format!("{COOKIE_NAME}="));
117        if ours {
118            let value = h.value[COOKIE_NAME.len() + 1..].split(';').next().unwrap_or("");
119            messages = decode_messages(value);
120        }
121        !ours
122    });
123    messages.push(FlashMessage { kind: kind.to_string(), text: text.to_string() });
124    response.headers.push(Header::new(Header::_SET_COOKIE, cookie(&encode_messages(&messages))));
125}
126
127fn cookie(value: &str) -> String {
128    SetCookie::new(COOKIE_NAME, value).path("/").http_only().same_site("Lax").build()
129}
130
131fn clear_cookie() -> String {
132    SetCookie::new(COOKIE_NAME, "").path("/").http_only().same_site("Lax").max_age(0).build()
133}
134
135/// `kind=text&kind=text`, percent-encoded, cut at [`MAX_COOKIE_BYTES`].
136fn encode_messages(messages: &[FlashMessage]) -> String {
137    let mut out = String::new();
138    for message in messages {
139        let pair = format!("{}={}", encode(&message.kind, false), encode(&message.text, false));
140        if out.len() + pair.len() + 1 > MAX_COOKIE_BYTES {
141            break;
142        }
143        if !out.is_empty() {
144            out.push('&');
145        }
146        out.push_str(&pair);
147    }
148    out
149}
150
151fn decode_messages(value: &str) -> Vec<FlashMessage> {
152    value
153        .split('&')
154        .filter(|pair| !pair.is_empty())
155        .filter_map(|pair| {
156            let (kind, text) = pair.split_once('=')?;
157            Some(FlashMessage { kind: percent_decode(kind)?, text: percent_decode(text)? })
158        })
159        .collect()
160}
161
162/// `%XX` decoding (`+` stays `+`); `None` for a bad escape or non-UTF-8.
163pub(crate) fn percent_decode(text: &str) -> Option<String> {
164    let bytes = text.as_bytes();
165    let mut out = Vec::with_capacity(bytes.len());
166    let mut i = 0;
167    while i < bytes.len() {
168        if bytes[i] == b'%' {
169            let hex = text.get(i + 1..i + 3)?;
170            out.push(u8::from_str_radix(hex, 16).ok()?);
171            i += 3;
172        } else {
173            out.push(bytes[i]);
174            i += 1;
175        }
176    }
177    String::from_utf8(out).ok()
178}
179
180/// Removes the flash cookie after the request that carried it, unless the
181/// response sets a new flash. Wrap the app in it once: `app.wrap(FlashLayer::new())`.
182#[derive(Clone, Copy, Debug, Default)]
183pub struct FlashLayer;
184
185impl FlashLayer {
186    pub fn new() -> Self {
187        FlashLayer
188    }
189}
190
191fn carries_flash(request: &Request) -> bool {
192    request.header("cookie").is_some_and(|h| CookieJar::parse(&h.value).get(COOKIE_NAME).is_some())
193}
194
195impl Middleware for FlashLayer {
196    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
197        let mut response = next.execute(request, connection)?;
198        if carries_flash(request) {
199            let sets_flash = response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_SET_COOKIE) && h.value.starts_with(&format!("{COOKIE_NAME}=")));
200            if !sets_flash {
201                response.headers.push(Header::new(Header::_SET_COOKIE, clear_cookie()));
202            }
203        }
204        Ok(response)
205    }
206
207    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
208        true
209    }
210
211    /// Requests without a flash cookie don't need it: async handlers stay async.
212    fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
213        !carries_flash(request)
214    }
215
216    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
217        true
218    }
219
220    fn layer_name(&self) -> &'static str {
221        "FlashLayer"
222    }
223}
224
225/// A redirect, optionally with flash messages: `303 See Other` by default
226/// (the method after a form post becomes `GET`).
227#[derive(Debug, Clone)]
228pub struct Redirect {
229    location: String,
230    status: &'static crate::response::StatusCodeReasonPhrase,
231    flash: Vec<FlashMessage>,
232}
233
234impl Redirect {
235    /// `303 See Other` to `location` (CR and LF are removed from it).
236    pub fn to(location: &str) -> Self {
237        Redirect { location: location.replace(['\r', '\n'], ""), status: STATUS_CODE_REASON_PHRASE.n303_see_other, flash: Vec::new() }
238    }
239
240    /// `302 Found` to `location`.
241    pub fn found(location: &str) -> Self {
242        Redirect { status: STATUS_CODE_REASON_PHRASE.n302_found, ..Redirect::to(location) }
243    }
244
245    /// `301 Moved Permanently` to `location`.
246    pub fn permanent(location: &str) -> Self {
247        Redirect { status: STATUS_CODE_REASON_PHRASE.n301_moved_permanently, ..Redirect::to(location) }
248    }
249
250    /// To the route named `name` ([`url_for`](crate::router::url_for));
251    /// `/` when there's no such route.
252    pub fn to_route(name: &str, params: &[(&str, &str)]) -> Self {
253        Redirect::to(&crate::router::url_for(name, params).unwrap_or_else(|| "/".to_string()))
254    }
255
256    /// Add a flash message of `kind`.
257    pub fn flash(mut self, kind: &str, text: &str) -> Self {
258        self.flash.push(FlashMessage { kind: kind.to_string(), text: text.to_string() });
259        self
260    }
261
262    /// Add a `notice` flash message.
263    pub fn notice(self, text: &str) -> Self {
264        self.flash("notice", text)
265    }
266
267    /// Add an `alert` flash message.
268    pub fn alert(self, text: &str) -> Self {
269        self.flash("alert", text)
270    }
271
272    /// Where it redirects.
273    pub fn location(&self) -> &str {
274        &self.location
275    }
276}
277
278impl IntoResponse for Redirect {
279    fn into_response(self) -> Response {
280        let mut response = Response::get_response(self.status, None, None);
281        response.headers.push(Header::new(Header::_LOCATION, self.location));
282        for message in &self.flash {
283            set(&mut response, &message.kind, &message.text);
284        }
285        response
286    }
287}