use crate::call::Call;
use crate::cookie::Cookie;
use crate::error::Result;
use crate::extract::FromCallParts;
use crate::pipeline::{Middleware, Next};
use crate::response::{IntoResponse, Response};
use async_trait::async_trait;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Debug, Default)]
struct Inner {
data: BTreeMap<String, String>,
dirty: bool,
}
#[derive(Clone, Debug, Default)]
pub struct Session(Arc<Mutex<Inner>>);
impl Session {
pub fn get(&self, key: &str) -> Option<String> {
self.0.lock().ok()?.data.get(key).cloned()
}
pub fn set(&self, key: impl Into<String>, value: impl Into<String>) {
if let Ok(mut g) = self.0.lock() {
g.data.insert(key.into(), value.into());
g.dirty = true;
}
}
pub fn remove(&self, key: &str) {
if let Ok(mut g) = self.0.lock() {
if g.data.remove(key).is_some() {
g.dirty = true;
}
}
}
pub fn clear(&self) {
if let Ok(mut g) = self.0.lock() {
g.data.clear();
g.dirty = true;
}
}
pub fn rotate(&self) {
if let Ok(mut g) = self.0.lock() {
g.data.remove(SESSION_ID_KEY);
g.dirty = true;
}
}
fn changed(&self) -> bool {
self.0.lock().map(|g| g.dirty).unwrap_or(false)
}
fn snapshot(&self) -> BTreeMap<String, String> {
self.0.lock().map(|g| g.data.clone()).unwrap_or_default()
}
fn from_map(data: BTreeMap<String, String>) -> Self {
Session(Arc::new(Mutex::new(Inner { data, dirty: false })))
}
}
#[async_trait]
impl FromCallParts for Session {
async fn from_call_parts(call: &mut Call) -> Result<Self> {
Ok(call.get::<Session>().unwrap_or_default())
}
}
pub const SESSION_ID_KEY: &str = "__churust_sid";
#[async_trait]
pub trait SessionStore: Send + Sync + 'static {
async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>>;
async fn store(
&self,
data: &BTreeMap<String, String>,
previous: Option<&str>,
) -> Result<Option<String>>;
fn with_session_max_age(&self, _secs: i64) -> Option<Box<dyn SessionStore>> {
None
}
}
pub struct CookieStore {
key: Vec<u8>,
max_age: Option<i64>,
}
const EXPIRY_KEY: &str = "__churust_exp";
impl CookieStore {
pub fn new(key: impl Into<Vec<u8>>) -> Self {
Self {
key: key.into(),
max_age: None,
}
}
pub fn with_max_age(mut self, secs: i64) -> Self {
self.max_age = Some(secs);
self
}
fn now_secs() -> Option<i64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
}
fn sign(&self, payload: &str) -> String {
use hmac::Mac;
let mut mac = <hmac::Hmac<sha2::Sha256> as hmac::Mac>::new_from_slice(&self.key)
.expect("HMAC accepts a key of any length");
mac.update(payload.as_bytes());
let tag = mac.finalize().into_bytes();
tag.iter().map(|b| format!("{b:02x}")).collect()
}
}
#[async_trait]
impl SessionStore for CookieStore {
fn with_session_max_age(&self, secs: i64) -> Option<Box<dyn SessionStore>> {
Some(Box::new(CookieStore {
key: self.key.clone(),
max_age: Some(secs),
}))
}
async fn load(&self, raw: &str) -> Option<BTreeMap<String, String>> {
let (sig, payload) = raw.split_once('.')?;
if !crate::secure_compare(sig, self.sign(payload)) {
return None;
}
let mut out = BTreeMap::new();
for pair in payload.split('&') {
if pair.is_empty() {
continue;
}
let (k, v) = pair.split_once('=')?;
out.insert(crate::cookie::decode(k), crate::cookie::decode(v));
}
if let Some(raw_exp) = out.remove(EXPIRY_KEY) {
let expires_at = raw_exp.parse::<i64>().ok()?;
if Self::now_secs().is_some_and(|now| now >= expires_at) {
return None;
}
}
Some(out)
}
async fn store(
&self,
data: &BTreeMap<String, String>,
_previous: Option<&str>,
) -> Result<Option<String>> {
let mut pairs: Vec<String> = data
.iter()
.filter(|(k, _)| k.as_str() != EXPIRY_KEY)
.map(|(k, v)| format!("{}={}", esc(k), esc(v)))
.collect();
if let (Some(age), Some(now)) = (self.max_age, Self::now_secs()) {
pairs.push(format!("{}={}", esc(EXPIRY_KEY), now + age));
}
let payload = pairs.join("&");
Ok(Some(format!("{}.{}", self.sign(&payload), payload)))
}
}
fn esc(s: &str) -> String {
s.replace('%', "%25")
.replace('&', "%26")
.replace('=', "%3D")
.replace('.', "%2E")
}
pub struct Sessions {
store: Arc<dyn SessionStore>,
cookie_name: String,
secure: bool,
max_age: Option<i64>,
}
impl Sessions {
pub fn cookie(key: impl Into<Vec<u8>>) -> Self {
Self::with_store(CookieStore::new(key))
}
pub fn with_store<S: SessionStore>(store: S) -> Self {
Self {
store: Arc::new(store),
cookie_name: "churust_session".into(),
secure: false,
max_age: None,
}
}
pub fn cookie_name(mut self, n: impl Into<String>) -> Self {
self.cookie_name = n.into();
self
}
pub fn secure(mut self, yes: bool) -> Self {
self.secure = yes;
self
}
pub fn max_age(mut self, secs: i64) -> Self {
self.max_age = Some(secs);
if let Some(updated) = self.store.with_session_max_age(secs) {
self.store = Arc::from(updated);
}
self
}
}
impl crate::app::Plugin for Sessions {
fn install(self: Box<Self>, app: &mut crate::app::AppBuilder) {
app.add_middleware(Arc::new(SessionMiddleware {
store: self.store.clone(),
cookie_name: self.cookie_name.clone(),
secure: self.secure,
max_age: self.max_age,
}));
}
}
struct SessionMiddleware {
store: Arc<dyn SessionStore>,
cookie_name: String,
secure: bool,
max_age: Option<i64>,
}
#[async_trait]
impl Middleware for SessionMiddleware {
async fn handle(&self, mut call: Call, next: Next) -> Response {
let arrived_with = call.cookie(&self.cookie_name);
let loaded = match arrived_with.as_deref() {
Some(raw) => self.store.load(raw).await.unwrap_or_default(),
None => BTreeMap::new(),
};
let session = Session::from_map(loaded);
call.insert(session.clone());
let mut res = next.run(call).await;
if session.changed() {
match self
.store
.store(&session.snapshot(), arrived_with.as_deref())
.await
{
Ok(Some(value)) => {
let mut c = Cookie::new(self.cookie_name.clone(), value).secure(self.secure);
if let Some(age) = self.max_age {
c = c.max_age(age);
}
res = res.with_cookie(c);
}
Ok(None) => {}
Err(e) => return e.into_response(),
}
}
res
}
}