1use crate::request::Request;
19use rustlavel_core::Json;
20use std::sync::Arc;
21
22pub const ERRORS_KEY: &str = "_errors";
24
25pub const OLD_INPUT_KEY: &str = "_old";
27
28pub const PREVIOUS_URL_KEY: &str = "_previous";
31
32pub trait Flash: std::fmt::Debug + Send + Sync + 'static {
39 fn flash(&self, key: &str, value: Json);
41
42 fn take(&self, key: &str) -> Option<Json>;
44
45 fn peek(&self, key: &str) -> Option<Json>;
52}
53
54impl Request {
55 pub fn flash(&self) -> Option<&Arc<dyn Flash>> {
57 self.extension::<Arc<dyn Flash>>()
58 }
59
60 pub fn errors(&self) -> Json {
75 self.flash()
76 .and_then(|flash| flash.peek(ERRORS_KEY))
77 .unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
78 }
79
80 pub fn old(&self) -> Json {
86 self.flash()
87 .and_then(|flash| flash.peek(OLD_INPUT_KEY))
88 .unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
89 }
90
91 pub fn old_field(&self, name: &str) -> String {
93 self.old().get(name).and_then(Json::as_str).unwrap_or_default().to_string()
94 }
95
96 pub fn has_errors(&self) -> bool {
98 self.errors().as_object().is_some_and(|fields| !fields.is_empty())
99 }
100
101 pub fn previous_url(&self) -> String {
107 let recorded = self
108 .flash()
109 .and_then(|flash| flash.peek(PREVIOUS_URL_KEY))
110 .and_then(|value| value.as_str().map(str::to_string));
111
112 recorded
113 .or_else(|| self.header("referer").map(str::to_string))
114 .filter(|target| is_local_path(target))
115 .unwrap_or_else(|| "/".to_string())
116 }
117}
118
119pub fn is_local_path(target: &str) -> bool {
125 target.starts_with('/') && !target.starts_with("//") && !target.contains('\\')
126}
127
128pub fn old_input_of(request: &mut Request) -> Json {
136 let sensitive = |name: &str| {
137 let name = name.to_ascii_lowercase();
138 ["password", "secret", "token", "_token", "otp", "code", "pin", "cvv", "card"]
139 .iter()
140 .any(|needle| name.contains(needle))
141 };
142
143 let pairs: Vec<(String, Json)> = request
144 .form()
145 .iter()
146 .filter(|(name, _)| !sensitive(name))
147 .map(|(name, value)| (name.clone(), Json::from(value.as_str())))
148 .collect();
149
150 Json::object(pairs)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::method::Method;
157 use std::sync::Mutex;
158
159 #[derive(Debug, Default)]
161 struct Notebook(Mutex<std::collections::BTreeMap<String, Json>>);
162
163 impl Flash for Notebook {
164 fn flash(&self, key: &str, value: Json) {
165 self.0.lock().unwrap().insert(key.to_string(), value);
166 }
167 fn take(&self, key: &str) -> Option<Json> {
168 self.0.lock().unwrap().remove(key)
169 }
170 fn peek(&self, key: &str) -> Option<Json> {
171 self.0.lock().unwrap().get(key).cloned()
172 }
173 }
174
175 fn with_flash(request: Request, notebook: Notebook) -> Request {
176 let mut request = request;
177 let store: Arc<dyn Flash> = Arc::new(notebook);
178 request.extend(store);
179 request
180 }
181
182 #[test]
183 fn a_request_with_no_flash_reports_empty_rather_than_failing() {
184 let request = Request::new(Method::Get, "/posts/create");
185 assert!(!request.has_errors());
186 assert_eq!(request.errors().as_object().map(|f| f.len()), Some(0));
187 assert_eq!(request.old_field("title"), "");
188 assert_eq!(request.previous_url(), "/");
189 }
190
191 #[test]
192 fn errors_and_old_input_survive_to_the_next_request() {
193 let notebook = Notebook::default();
194 notebook.flash(
195 ERRORS_KEY,
196 Json::object([("title", Json::Array(vec![Json::from("The title field is required.")]))]),
197 );
198 notebook.flash(OLD_INPUT_KEY, Json::object([("body", Json::from("half a draft"))]));
199
200 let request = with_flash(Request::new(Method::Get, "/posts/create"), notebook);
201
202 assert!(request.has_errors());
203 assert_eq!(
204 request.errors().get("title.0").and_then(Json::as_str),
205 Some("The title field is required.")
206 );
207 assert_eq!(request.old_field("body"), "half a draft");
208 assert_eq!(request.old_field("title"), "", "a field with no old value is empty, not missing");
209 }
210
211 #[test]
212 fn reading_does_not_consume_them() {
213 let notebook = Notebook::default();
216 notebook.flash(ERRORS_KEY, Json::object([("a", Json::Array(vec![Json::from("x")]))]));
217 let request = with_flash(Request::new(Method::Get, "/"), notebook);
218
219 assert!(request.has_errors());
220 assert!(request.has_errors());
221 }
222
223 #[test]
224 fn old_input_keeps_what_was_typed_and_drops_what_was_secret() {
225 let mut request = Request::new(Method::Post, "/register")
226 .with_header("content-type", "application/x-www-form-urlencoded")
227 .with_body(
228 b"name=Ada&email=ada%40example.com&password=hunter2&\
229 password_confirmation=hunter2&_token=abc&api_token=xyz¬e=fine"
230 .to_vec(),
231 );
232
233 let old = old_input_of(&mut request);
234 assert_eq!(old.get("name").and_then(Json::as_str), Some("Ada"));
235 assert_eq!(old.get("email").and_then(Json::as_str), Some("ada@example.com"));
236 assert_eq!(old.get("note").and_then(Json::as_str), Some("fine"));
237
238 for secret in ["password", "password_confirmation", "_token", "api_token"] {
239 assert!(old.get(secret).is_none(), "{secret} must not be kept");
240 }
241 }
242
243 #[test]
244 fn back_goes_to_the_recorded_page_then_the_referer_then_the_root() {
245 let notebook = Notebook::default();
246 notebook.flash(PREVIOUS_URL_KEY, Json::from("/posts/create"));
247 let request = with_flash(
248 Request::new(Method::Post, "/posts").with_header("referer", "/somewhere-else"),
249 notebook,
250 );
251 assert_eq!(request.previous_url(), "/posts/create", "the recorded page wins");
252
253 let no_record = Request::new(Method::Post, "/posts").with_header("referer", "/from-here");
254 assert_eq!(no_record.previous_url(), "/from-here");
255
256 let nothing = Request::new(Method::Post, "/posts");
257 assert_eq!(nothing.previous_url(), "/");
258 }
259
260 #[test]
261 fn a_referer_pointing_at_another_site_is_refused() {
262 for hostile in [
265 "https://evil.example/login",
266 "//evil.example/login",
267 "http://evil.example",
268 "/\\evil.example",
269 ] {
270 let request = Request::new(Method::Post, "/posts").with_header("referer", hostile);
271 assert_eq!(request.previous_url(), "/", "{hostile} should not be followed");
272 }
273 }
274
275 #[test]
276 fn a_local_path_is_recognised_and_a_foreign_one_is_not() {
277 assert!(is_local_path("/posts/create"));
278 assert!(is_local_path("/"));
279 assert!(!is_local_path("//evil.example"));
280 assert!(!is_local_path("https://evil.example"));
281 assert!(!is_local_path("posts/create"));
282 assert!(!is_local_path("/\\evil.example"));
283 }
284}