use crate::request::Request;
use rustlavel_core::Json;
use std::sync::Arc;
pub const ERRORS_KEY: &str = "_errors";
pub const OLD_INPUT_KEY: &str = "_old";
pub const PREVIOUS_URL_KEY: &str = "_previous";
pub trait Flash: std::fmt::Debug + Send + Sync + 'static {
fn flash(&self, key: &str, value: Json);
fn take(&self, key: &str) -> Option<Json>;
fn peek(&self, key: &str) -> Option<Json>;
}
impl Request {
pub fn flash(&self) -> Option<&Arc<dyn Flash>> {
self.extension::<Arc<dyn Flash>>()
}
pub fn errors(&self) -> Json {
self.flash()
.and_then(|flash| flash.peek(ERRORS_KEY))
.unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
}
pub fn old(&self) -> Json {
self.flash()
.and_then(|flash| flash.peek(OLD_INPUT_KEY))
.unwrap_or_else(|| Json::object([] as [(&str, Json); 0]))
}
pub fn old_field(&self, name: &str) -> String {
self.old().get(name).and_then(Json::as_str).unwrap_or_default().to_string()
}
pub fn has_errors(&self) -> bool {
self.errors().as_object().is_some_and(|fields| !fields.is_empty())
}
pub fn previous_url(&self) -> String {
let recorded = self
.flash()
.and_then(|flash| flash.peek(PREVIOUS_URL_KEY))
.and_then(|value| value.as_str().map(str::to_string));
recorded
.or_else(|| self.header("referer").map(str::to_string))
.filter(|target| is_local_path(target))
.unwrap_or_else(|| "/".to_string())
}
}
pub fn is_local_path(target: &str) -> bool {
target.starts_with('/') && !target.starts_with("//") && !target.contains('\\')
}
pub fn old_input_of(request: &mut Request) -> Json {
let sensitive = |name: &str| {
let name = name.to_ascii_lowercase();
["password", "secret", "token", "_token", "otp", "code", "pin", "cvv", "card"]
.iter()
.any(|needle| name.contains(needle))
};
let pairs: Vec<(String, Json)> = request
.form()
.iter()
.filter(|(name, _)| !sensitive(name))
.map(|(name, value)| (name.clone(), Json::from(value.as_str())))
.collect();
Json::object(pairs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::method::Method;
use std::sync::Mutex;
#[derive(Debug, Default)]
struct Notebook(Mutex<std::collections::BTreeMap<String, Json>>);
impl Flash for Notebook {
fn flash(&self, key: &str, value: Json) {
self.0.lock().unwrap().insert(key.to_string(), value);
}
fn take(&self, key: &str) -> Option<Json> {
self.0.lock().unwrap().remove(key)
}
fn peek(&self, key: &str) -> Option<Json> {
self.0.lock().unwrap().get(key).cloned()
}
}
fn with_flash(request: Request, notebook: Notebook) -> Request {
let mut request = request;
let store: Arc<dyn Flash> = Arc::new(notebook);
request.extend(store);
request
}
#[test]
fn a_request_with_no_flash_reports_empty_rather_than_failing() {
let request = Request::new(Method::Get, "/posts/create");
assert!(!request.has_errors());
assert_eq!(request.errors().as_object().map(|f| f.len()), Some(0));
assert_eq!(request.old_field("title"), "");
assert_eq!(request.previous_url(), "/");
}
#[test]
fn errors_and_old_input_survive_to_the_next_request() {
let notebook = Notebook::default();
notebook.flash(
ERRORS_KEY,
Json::object([("title", Json::Array(vec![Json::from("The title field is required.")]))]),
);
notebook.flash(OLD_INPUT_KEY, Json::object([("body", Json::from("half a draft"))]));
let request = with_flash(Request::new(Method::Get, "/posts/create"), notebook);
assert!(request.has_errors());
assert_eq!(
request.errors().get("title.0").and_then(Json::as_str),
Some("The title field is required.")
);
assert_eq!(request.old_field("body"), "half a draft");
assert_eq!(request.old_field("title"), "", "a field with no old value is empty, not missing");
}
#[test]
fn reading_does_not_consume_them() {
let notebook = Notebook::default();
notebook.flash(ERRORS_KEY, Json::object([("a", Json::Array(vec![Json::from("x")]))]));
let request = with_flash(Request::new(Method::Get, "/"), notebook);
assert!(request.has_errors());
assert!(request.has_errors());
}
#[test]
fn old_input_keeps_what_was_typed_and_drops_what_was_secret() {
let mut request = Request::new(Method::Post, "/register")
.with_header("content-type", "application/x-www-form-urlencoded")
.with_body(
b"name=Ada&email=ada%40example.com&password=hunter2&\
password_confirmation=hunter2&_token=abc&api_token=xyz¬e=fine"
.to_vec(),
);
let old = old_input_of(&mut request);
assert_eq!(old.get("name").and_then(Json::as_str), Some("Ada"));
assert_eq!(old.get("email").and_then(Json::as_str), Some("ada@example.com"));
assert_eq!(old.get("note").and_then(Json::as_str), Some("fine"));
for secret in ["password", "password_confirmation", "_token", "api_token"] {
assert!(old.get(secret).is_none(), "{secret} must not be kept");
}
}
#[test]
fn back_goes_to_the_recorded_page_then_the_referer_then_the_root() {
let notebook = Notebook::default();
notebook.flash(PREVIOUS_URL_KEY, Json::from("/posts/create"));
let request = with_flash(
Request::new(Method::Post, "/posts").with_header("referer", "/somewhere-else"),
notebook,
);
assert_eq!(request.previous_url(), "/posts/create", "the recorded page wins");
let no_record = Request::new(Method::Post, "/posts").with_header("referer", "/from-here");
assert_eq!(no_record.previous_url(), "/from-here");
let nothing = Request::new(Method::Post, "/posts");
assert_eq!(nothing.previous_url(), "/");
}
#[test]
fn a_referer_pointing_at_another_site_is_refused() {
for hostile in [
"https://evil.example/login",
"//evil.example/login",
"http://evil.example",
"/\\evil.example",
] {
let request = Request::new(Method::Post, "/posts").with_header("referer", hostile);
assert_eq!(request.previous_url(), "/", "{hostile} should not be followed");
}
}
#[test]
fn a_local_path_is_recognised_and_a_foreign_one_is_not() {
assert!(is_local_path("/posts/create"));
assert!(is_local_path("/"));
assert!(!is_local_path("//evil.example"));
assert!(!is_local_path("https://evil.example"));
assert!(!is_local_path("posts/create"));
assert!(!is_local_path("/\\evil.example"));
}
}