use std::borrow::Cow;
use std::sync::Mutex;
use super::request::encode;
use super::session::CSRF_FIELD;
use super::{Request, Response, Router};
pub struct Client<'a> {
routes: &'a Router,
peer: std::net::IpAddr,
headers: Vec<(String, String)>,
cookies: Mutex<Vec<(String, String)>>,
token: Mutex<Option<String>>,
}
impl<'a> Client<'a> {
pub fn new(routes: &'a Router) -> Self {
Self {
routes,
peer: [127, 0, 0, 1].into(),
headers: Vec::new(),
cookies: Mutex::default(),
token: Mutex::default(),
}
}
#[must_use]
pub fn from(mut self, peer: &str) -> Self {
self.peer = peer.parse().expect("an IP address");
self
}
#[must_use]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_owned(), value.to_owned()));
self
}
pub fn get(&self, path: &str) -> Response {
self.send(Request::get(path))
}
pub fn post(&self, path: &str, fields: &[(&str, &str)]) -> Response {
let token = self.token.lock().unwrap_or_else(|e| e.into_inner()).clone();
let mut all: Vec<(&str, &str)> = fields.to_vec();
if let Some(token) = &token {
all.push((CSRF_FIELD, token));
}
self.post_raw(path, &all)
}
pub fn post_raw(&self, path: &str, fields: &[(&str, &str)]) -> Response {
let body = fields
.iter()
.map(|(name, value)| format!("{}={}", encode(name), encode(value)))
.collect::<Vec<_>>()
.join("&");
self.send(Request::post(path).with_body(body))
}
pub fn cookie(&self, name: &str) -> Option<String> {
let jar = self.cookies.lock().unwrap_or_else(|e| e.into_inner());
jar.iter()
.find(|(kept, _)| kept == name)
.map(|(_, value)| value.clone())
}
fn send(&self, request: Request) -> Response {
let jar = self
.cookies
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut request = request.with_peer(self.peer);
for (name, value) in &self.headers {
request = request.with_header(name, value);
}
if !jar.is_empty() {
let header = jar
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("; ");
request = request.with_header("Cookie", &header);
}
let response = self.routes.handle(&request);
self.remember(&response);
response
}
fn remember(&self, response: &Response) {
let mut jar = self.cookies.lock().unwrap_or_else(|e| e.into_inner());
for (name, value) in &response.headers {
if !name.eq_ignore_ascii_case("set-cookie") {
continue;
}
let pair = value.split(';').next().unwrap_or_default();
if let Some((cookie, value)) = pair.split_once('=') {
jar.retain(|(kept, _)| kept != cookie.trim());
jar.push((cookie.trim().to_owned(), value.trim().to_owned()));
}
}
let marker = format!(r#"name="{CSRF_FIELD}" value=""#);
let page = response.body_text();
if let Some(start) = page.find(&marker) {
let rest = &page[start + marker.len()..];
let token = rest.split('"').next().unwrap_or_default().to_owned();
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = Some(token);
}
}
}
impl Response {
pub fn body_text(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
pub fn see(&self, text: &str) -> bool {
self.body_text().contains(text)
}
pub fn location(&self) -> Option<&str> {
self.header("location")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::web::{Sessions, csrf, redirect};
use std::time::Duration;
#[test]
fn keeps_the_session_and_sends_the_csrf_token() {
let app = Router::new().group(|web| {
web.middleware(Sessions::new(Duration::from_secs(60)).middleware())
.middleware(csrf())
.get("/form", |request| {
let field = request.session().unwrap().csrf_field();
Response::new(200, "text/html", format!("<form>{field}</form>"))
})
.post("/form", |_| redirect("/done"))
});
let client = Client::new(&app);
assert_eq!(client.post_raw("/form", &[("a", "1")]).status, 419);
client.get("/form");
let sent = client.post("/form", &[("a", "1 & 2")]);
assert_eq!((sent.status, sent.location()), (302, Some("/done")));
assert_eq!(
client.post_raw("/form", &[]).status,
419,
"no token, refused"
);
}
}