diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 664a1e0..67cf6e7 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::{db, server}; pub fn serve(config: &crate::config::Config) -> u8 { @@ -11,7 +13,7 @@ pub fn serve(config: &crate::config::Config) -> u8 { let ctx = server::Context { store: crate::gpodder::GpodderRepository::new(repo), }; - let app = server::app(ctx); + let app = server::app(ctx.clone()); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -21,7 +23,28 @@ pub fn serve(config: &crate::config::Config) -> u8 { let address = format!("{}:{}", config.domain, config.port); tracing::info!("Starting server on {address}"); + let session_removal_duration = Duration::from_secs(config.session_cleanup_interval); + rt.block_on(async { + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(session_removal_duration); + + loop { + interval.tick().await; + + tracing::info!("Performing session cleanup"); + + match ctx.store.remove_old_sessions() { + Ok(n) => { + tracing::info!("Removed {} old sessions", n); + } + Err(err) => { + tracing::error!("Error occured during session cleanup: {}", err); + } + } + } + }); + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); axum::serve(listener, app.into_make_service()) .await diff --git a/src/config.rs b/src/config.rs index 0f7df52..67bc59a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { pub domain: String, #[serde(default = "default_port")] pub port: u16, + #[serde(default = "default_session_cleanup_interval")] + pub session_cleanup_interval: u64, } fn default_data_dir() -> PathBuf { @@ -23,3 +25,8 @@ fn default_domain() -> String { fn default_port() -> u16 { 8080 } + +fn default_session_cleanup_interval() -> u64 { + // Default is once a day + 60 * 60 * 24 +} diff --git a/src/db/repository/auth.rs b/src/db/repository/auth.rs index e65f046..8bd7905 100644 --- a/src/db/repository/auth.rs +++ b/src/db/repository/auth.rs @@ -181,4 +181,29 @@ impl gpodder::AuthStore for SqliteRepository { .execute(&mut self.pool.get()?) .map(|_| ())?) } + + fn refresh_session( + &self, + session: &gpodder::Session, + timestamp: DateTime, + ) -> Result<(), AuthErr> { + if diesel::update(sessions::table.filter(sessions::id.eq(session.id))) + .set(sessions::last_seen.eq(timestamp.timestamp())) + .execute(&mut self.pool.get()?)? + == 0 + { + Err(AuthErr::UnknownSession) + } else { + Ok(()) + } + } + + fn remove_old_sessions(&self, min_last_seen: DateTime) -> Result { + let min_last_seen = min_last_seen.timestamp(); + + Ok( + diesel::delete(sessions::table.filter(sessions::last_seen.lt(min_last_seen))) + .execute(&mut self.pool.get()?)?, + ) + } } diff --git a/src/gpodder/mod.rs b/src/gpodder/mod.rs index 8336de1..d8ff790 100644 --- a/src/gpodder/mod.rs +++ b/src/gpodder/mod.rs @@ -1,10 +1,13 @@ pub mod models; mod repository; +use std::fmt::Display; + use chrono::{DateTime, Utc}; pub use models::*; pub use repository::GpodderRepository; +#[derive(Debug)] pub enum AuthErr { UnknownSession, UnknownUser, @@ -12,6 +15,19 @@ pub enum AuthErr { Other(Box), } +impl Display for AuthErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownUser => write!(f, "unknown user"), + Self::UnknownSession => write!(f, "unknown session"), + Self::InvalidPassword => write!(f, "invalid password"), + Self::Other(err) => err.fmt(f), + } + } +} + +impl std::error::Error for AuthErr {} + pub trait Store: AuthStore + DeviceRepository + SubscriptionRepository + EpisodeActionRepository { @@ -52,6 +68,12 @@ pub trait AuthStore { /// Remove the session with the given session ID fn remove_session(&self, session_id: i64) -> Result<(), AuthErr>; + + /// Update the session's timestamp + fn refresh_session(&self, session: &Session, timestamp: DateTime) -> Result<(), AuthErr>; + + /// Remove any sessions whose last_seen timestamp is before the given minimum value + fn remove_old_sessions(&self, min_last_seen: DateTime) -> Result; } pub trait DeviceRepository { diff --git a/src/gpodder/repository.rs b/src/gpodder/repository.rs index 411f9cf..e55d010 100644 --- a/src/gpodder/repository.rs +++ b/src/gpodder/repository.rs @@ -20,7 +20,7 @@ impl GpodderRepository { } } - pub fn validate_session(&self, session_id: i64) -> Result { + pub fn get_session(&self, session_id: i64) -> Result { let session = self .store .get_session(session_id)? @@ -65,10 +65,22 @@ impl GpodderRepository { Ok(session) } + pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { + let now = Utc::now(); + + self.store.refresh_session(session, now) + } + pub fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> { self.store.remove_session(session_id) } + pub fn remove_old_sessions(&self) -> Result { + let min_last_seen = Utc::now() - TimeDelta::seconds(MAX_SESSION_AGE); + + self.store.remove_old_sessions(min_last_seen) + } + pub fn devices_for_user(&self, user: &models::User) -> Result, AuthErr> { self.store.devices_for_user(user) } diff --git a/src/server/gpodder/advanced/auth.rs b/src/server/gpodder/advanced/auth.rs index ac5cbe6..242082a 100644 --- a/src/server/gpodder/advanced/auth.rs +++ b/src/server/gpodder/advanced/auth.rs @@ -4,13 +4,11 @@ use axum::{ Router, }; use axum_extra::{ - extract::{ - cookie::{Cookie, Expiration}, - CookieJar, - }, + extract::{cookie::Cookie, CookieJar}, headers::{authorization::Basic, Authorization}, TypedHeader, }; +use cookie::time::Duration; use crate::server::{ error::{AppError, AppResult}, @@ -45,7 +43,7 @@ async fn post_login( .unwrap()?; Ok(jar.add( - Cookie::build((SESSION_ID_COOKIE, session.id.to_string())).expires(Expiration::Session), + Cookie::build((SESSION_ID_COOKIE, session.id.to_string())).max_age(Duration::days(365)), )) } diff --git a/src/server/gpodder/mod.rs b/src/server/gpodder/mod.rs index 776100e..4746323 100644 --- a/src/server/gpodder/mod.rs +++ b/src/server/gpodder/mod.rs @@ -47,10 +47,15 @@ pub async fn auth_middleware(State(ctx): State, mut req: Request, next: .get(SESSION_ID_COOKIE) .and_then(|c| c.value().parse::().ok()) { - let ctx_clone = ctx.clone(); - match tokio::task::spawn_blocking(move || ctx_clone.store.validate_session(session_id)) - .await - .unwrap() + let ctx = ctx.clone(); + match tokio::task::spawn_blocking(move || { + let session = ctx.store.get_session(session_id)?; + ctx.store.refresh_session(&session)?; + + Ok(session) + }) + .await + .unwrap() { Ok(session) => { auth_user = Some(session.user);