diff --git a/gpodder/src/models.rs b/gpodder/src/models.rs index 2f77ba1..fb9339b 100644 --- a/gpodder/src/models.rs +++ b/gpodder/src/models.rs @@ -5,6 +5,7 @@ pub struct User { pub id: i64, pub username: String, pub password_hash: String, + pub admin: bool, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/gpodder/src/repository/admin.rs b/gpodder/src/repository/admin.rs new file mode 100644 index 0000000..8a3e845 --- /dev/null +++ b/gpodder/src/repository/admin.rs @@ -0,0 +1,13 @@ +use crate::{AuthErr, Page, models}; + +/// Admin view of the repository, providing methods only allowed by admins +pub struct AdminRepository<'a> { + pub(crate) store: &'a (dyn super::GpodderStore + Send + Sync), + pub(crate) user: &'a models::User, +} + +impl<'a> AdminRepository<'a> { + pub fn paginated_users(&self, page: Page) -> Result, AuthErr> { + self.store.paginated_users(page) + } +} diff --git a/gpodder/src/repository.rs b/gpodder/src/repository/authenticated.rs similarity index 51% rename from gpodder/src/repository.rs rename to gpodder/src/repository/authenticated.rs index 5c086e6..9dd05ee 100644 --- a/gpodder/src/repository.rs +++ b/gpodder/src/repository/authenticated.rs @@ -1,93 +1,47 @@ -use std::{collections::HashSet, sync::Arc}; +use std::collections::HashSet; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use chrono::{DateTime, TimeDelta, Utc}; -use rand::{Rng, rngs::OsRng}; +use rand::Rng; -use crate::{ - models, - store::{AuthErr, GpodderStore}, -}; +use crate::{AuthErr, GpodderStore, models}; -const MAX_SESSION_AGE: i64 = 60 * 60 * 24 * 7; - -#[derive(Clone)] -pub struct GpodderRepository { - store: Arc, +/// Authenticated view of the repository, providing methods that take the authenticated user +/// explicitely into account +pub struct AuthenticatedRepository<'a> { + pub(crate) store: &'a (dyn GpodderStore + Send + Sync), + pub(crate) user: &'a models::User, } -impl GpodderRepository { - pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self { - Self { - store: Arc::new(store), - } - } - +impl<'a> AuthenticatedRepository<'a> { + /// Retrieve the given session from the database, if it exists and is visible to the user pub fn get_session(&self, session_id: i64) -> Result { let session = self .store .get_session(session_id)? .ok_or(AuthErr::UnknownSession)?; - // Expired sessions still in the database are considered removed - if Utc::now() - session.last_seen > TimeDelta::new(MAX_SESSION_AGE, 0).unwrap() { + // Users can't see sessions from other users, and expired sessions still in the database + // are considered removed + if session.user.id != self.user.id + || Utc::now() - session.last_seen > super::MAX_SESSION_AGE + { Err(AuthErr::UnknownSession) } else { Ok(session) } } - pub fn paginated_sessions( - &self, - user: &models::User, - page: models::Page, - ) -> Result, AuthErr> { - self.store.paginated_sessions(user, page) + /// Retrieve a paginated list of the user's sessions + pub fn paginated_sessions(&self, page: models::Page) -> Result, AuthErr> { + self.store.paginated_sessions(self.user, page) } - pub fn get_user(&self, username: &str) -> Result { - self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) - } - - pub fn create_user(&self, username: &str, password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - - let password_hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .unwrap() - .to_string(); - - self.store.insert_user(username, &password_hash) - } - - pub fn validate_credentials( - &self, - username: &str, - password: &str, - ) -> Result { - let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?; - - let password_hash = PasswordHash::new(&user.password_hash).unwrap(); - - if Argon2::default() - .verify_password(password.as_bytes(), &password_hash) - .is_ok() - { - Ok(user) - } else { - Err(AuthErr::InvalidPassword) - } - } - - pub fn create_session( - &self, - user: &models::User, - user_agent: Option, - ) -> Result { + /// Create a new session for the authenticated user + pub fn create_session(&self, user_agent: Option) -> Result { let session = models::Session { id: rand::thread_rng().r#gen(), last_seen: Utc::now(), - user: user.clone(), + user: self.user.clone(), user_agent, }; @@ -96,38 +50,38 @@ impl GpodderRepository { Ok(session) } + /// Set the session's last seen value to the current time pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { let now = Utc::now(); self.store.refresh_session(session, now) } + /// Remove the given session, if it belongs to the authenticated user pub fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> { - self.store.remove_session(session_id) + // This fails if the session doesn't exist for the user, so it's basically a "exists" check + let session = self.get_session(session_id)?; + + 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) + /// Return the devices for the authenticated user + pub fn devices(&self) -> Result, AuthErr> { + self.store.devices_for_user(self.user) } + /// Update the metadata of a device pub fn update_device_info( &self, - user: &models::User, device_id: &str, patch: models::DevicePatch, ) -> Result<(), AuthErr> { - self.store.update_device_info(user, device_id, patch) + self.store.update_device_info(self.user, device_id, patch) } + /// Update the sync status for some of the user's devices pub fn update_device_sync_status( &self, - user: &models::User, sync: Vec>, unsync: Vec<&str>, ) -> Result<(), AuthErr> { @@ -146,71 +100,72 @@ impl GpodderRepository { unsync.remove(device_id); } - let group_id = self.store.merge_sync_groups(user, remaining)?; + let group_id = self.store.merge_sync_groups(self.user, remaining)?; self.store.synchronize_sync_group(group_id, now)?; } // Finally we unsync the remaining devices self.store - .remove_from_sync_group(user, unsync.into_iter().collect())?; + .remove_from_sync_group(self.user, unsync.into_iter().collect())?; Ok(()) } - pub fn devices_by_sync_group( - &self, - user: &models::User, - ) -> Result<(Vec, Vec>), AuthErr> { - self.store.devices_by_sync_group(user) + /// Return the user's devices, grouped per sync group + pub fn devices_by_sync_group(&self) -> Result<(Vec, Vec>), AuthErr> { + self.store.devices_by_sync_group(self.user) } + /// Retrieve the user's subscriptions for a device pub fn subscriptions_for_device( &self, - user: &models::User, device_id: &str, ) -> Result, AuthErr> { - self.store.subscriptions_for_device(user, device_id) + self.store.subscriptions_for_device(self.user, device_id) } - pub fn subscriptions_for_user( - &self, - user: &models::User, - ) -> Result, AuthErr> { - self.store.subscriptions_for_user(user) + /// Retrieve the user's subscriptions + pub fn subscriptions(&self) -> Result, AuthErr> { + self.store.subscriptions_for_user(self.user) } + /// Set the subscriptions for a given device pub fn set_subscriptions_for_device( &self, - user: &models::User, device_id: &str, urls: Vec, ) -> Result, AuthErr> { let time_changed = Utc::now(); self.store - .set_subscriptions_for_device(user, device_id, urls, time_changed)?; + .set_subscriptions_for_device(self.user, device_id, urls, time_changed)?; Ok(time_changed + TimeDelta::seconds(1)) } + /// Add and remove subscriptions to and from a given device. pub fn update_subscriptions_for_device( &self, - user: &models::User, device_id: &str, add: Vec, remove: Vec, ) -> Result, AuthErr> { let time_changed = Utc::now(); - self.store - .update_subscriptions_for_device(user, device_id, add, remove, time_changed)?; + self.store.update_subscriptions_for_device( + self.user, + device_id, + add, + remove, + time_changed, + )?; Ok(time_changed + TimeDelta::seconds(1)) } + /// Get the changes in subscriptions for a given device after a given timestamp. pub fn subscription_updates_for_device( &self, - user: &models::User, device_id: &str, since: DateTime, ) -> Result< @@ -225,7 +180,7 @@ impl GpodderRepository { let (added, removed) = self .store - .subscription_updates_for_device(user, device_id, since)?; + .subscription_updates_for_device(self.user, device_id, since)?; let max_time_changed = added .iter() @@ -236,22 +191,22 @@ impl GpodderRepository { Ok((max_time_changed + TimeDelta::seconds(1), added, removed)) } + /// Add episode actions to the database pub fn add_episode_actions( &self, - user: &models::User, actions: Vec, ) -> Result, AuthErr> { let time_changed = Utc::now(); self.store - .add_episode_actions(user, actions, time_changed)?; + .add_episode_actions(self.user, actions, time_changed)?; Ok(time_changed + TimeDelta::seconds(1)) } + /// Get episode actions for the currently authenticated user pub fn episode_actions_for_user( &self, - user: &models::User, since: Option>, podcast: Option, device: Option, @@ -260,7 +215,7 @@ impl GpodderRepository { let now = chrono::Utc::now(); let actions = self .store - .episode_actions_for_user(user, since, podcast, device, aggregated)?; + .episode_actions_for_user(self.user, since, podcast, device, aggregated)?; let max_time_changed = actions.iter().map(|a| a.time_changed).max().unwrap_or(now); Ok((max_time_changed + TimeDelta::seconds(1), actions)) diff --git a/gpodder/src/repository/mod.rs b/gpodder/src/repository/mod.rs new file mode 100644 index 0000000..ef87237 --- /dev/null +++ b/gpodder/src/repository/mod.rs @@ -0,0 +1,120 @@ +mod admin; +mod authenticated; + +use std::sync::Arc; + +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use chrono::{TimeDelta, Utc}; +use rand::rngs::OsRng; + +use crate::{ + models, + store::{AuthErr, GpodderStore}, +}; + +const MAX_SESSION_AGE: TimeDelta = TimeDelta::seconds(60 * 60 * 24 * 7); + +/// Main abstraction over the database that provides API-compatible methods for querying and +/// modifying the underlying database +#[derive(Clone)] +pub struct GpodderRepository { + store: Arc, +} + +impl GpodderRepository { + pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self { + Self { + store: Arc::new(store), + } + } + + /// Return an authenticated view of the repository for the given user + pub fn user<'a>( + &'a self, + user: &'a models::User, + ) -> authenticated::AuthenticatedRepository<'a> { + authenticated::AuthenticatedRepository { + store: self.store.as_ref(), + user, + } + } + + /// Return an admin view of the repository, if the user is an admin + pub fn admin<'a>( + &'a self, + user: &'a models::User, + ) -> Result, AuthErr> { + if user.admin { + Ok(admin::AdminRepository { + store: self.store.as_ref(), + user, + }) + } else { + Err(AuthErr::NotAnAdmin) + } + } + + pub fn get_session(&self, session_id: i64) -> Result { + let session = self + .store + .get_session(session_id)? + .ok_or(AuthErr::UnknownSession)?; + + // Expired sessions still in the database are considered removed + if Utc::now() - session.last_seen > MAX_SESSION_AGE { + Err(AuthErr::UnknownSession) + } else { + Ok(session) + } + } + + pub fn get_user(&self, username: &str) -> Result { + self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) + } + + pub fn create_user(&self, username: &str, password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .unwrap() + .to_string(); + + self.store.insert_user(username, &password_hash) + } + + pub fn validate_credentials( + &self, + username: &str, + password: &str, + ) -> Result { + let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?; + + let password_hash = PasswordHash::new(&user.password_hash).unwrap(); + + if Argon2::default() + .verify_password(password.as_bytes(), &password_hash) + .is_ok() + { + Ok(user) + } else { + Err(AuthErr::InvalidPassword) + } + } + + 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() - MAX_SESSION_AGE; + + self.store.remove_old_sessions(min_last_seen) + } +} diff --git a/gpodder/src/store.rs b/gpodder/src/store.rs index 4b60b24..1daa3c2 100644 --- a/gpodder/src/store.rs +++ b/gpodder/src/store.rs @@ -8,6 +8,7 @@ pub enum AuthErr { UnknownSession, UnknownUser, InvalidPassword, + NotAnAdmin, Other(Box), } @@ -17,6 +18,7 @@ impl Display for AuthErr { Self::UnknownUser => write!(f, "unknown user"), Self::UnknownSession => write!(f, "unknown session"), Self::InvalidPassword => write!(f, "invalid password"), + Self::NotAnAdmin => write!(f, "not an admin"), Self::Other(err) => err.fmt(f), } } @@ -62,6 +64,9 @@ pub trait GpodderAuthStore { /// Remove any sessions whose last_seen timestamp is before the given minimum value fn remove_old_sessions(&self, min_last_seen: DateTime) -> Result; + + /// Return the given page of users, ordered by username + fn paginated_users(&self, page: Page) -> Result, AuthErr>; } pub trait GpodderDeviceStore { diff --git a/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/down.sql b/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/down.sql new file mode 100644 index 0000000..40a39a7 --- /dev/null +++ b/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/down.sql @@ -0,0 +1,2 @@ +alter table users + drop column admin; diff --git a/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/up.sql b/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/up.sql new file mode 100644 index 0000000..3a04ac9 --- /dev/null +++ b/gpodder_sqlite/migrations/2025-06-24-110307_user_admin_field/up.sql @@ -0,0 +1,2 @@ +alter table users + add column admin boolean not null default false; diff --git a/gpodder_sqlite/src/models/user.rs b/gpodder_sqlite/src/models/user.rs index 72ea62b..d0222ec 100644 --- a/gpodder_sqlite/src/models/user.rs +++ b/gpodder_sqlite/src/models/user.rs @@ -9,6 +9,7 @@ pub struct User { pub id: i64, pub username: String, pub password_hash: String, + pub admin: bool, } #[derive(Insertable)] diff --git a/gpodder_sqlite/src/repository/auth.rs b/gpodder_sqlite/src/repository/auth.rs index e32ff85..ced2606 100644 --- a/gpodder_sqlite/src/repository/auth.rs +++ b/gpodder_sqlite/src/repository/auth.rs @@ -18,6 +18,7 @@ impl From for gpodder::User { id: value.id, username: value.username, password_hash: value.password_hash, + admin: value.admin, } } } @@ -141,4 +142,17 @@ impl gpodder::GpodderAuthStore for SqliteRepository { })() .map_err(AuthErr::from) } + + fn paginated_users(&self, page: gpodder::Page) -> Result, AuthErr> { + Ok(users::table + .select(User::as_select()) + .order(users::username.asc()) + .offset((page.page * page.per_page) as i64) + .limit(page.per_page as i64) + .get_results(&mut self.pool.get().map_err(DbError::from)?) + .map_err(DbError::from)? + .into_iter() + .map(gpodder::User::from) + .collect()) + } } diff --git a/gpodder_sqlite/src/schema.rs b/gpodder_sqlite/src/schema.rs index 990293d..438a63d 100644 --- a/gpodder_sqlite/src/schema.rs +++ b/gpodder_sqlite/src/schema.rs @@ -58,6 +58,7 @@ diesel::table! { id -> BigInt, username -> Text, password_hash -> Text, + admin -> Bool, } } diff --git a/otter/src/cli/gpo.rs b/otter/src/cli/gpo.rs index 1cc2f65..f2d0cf0 100644 --- a/otter/src/cli/gpo.rs +++ b/otter/src/cli/gpo.rs @@ -23,15 +23,14 @@ impl Command { match self { Self::Sync { username, devices } => { let user = store.get_user(username)?; - store.update_device_sync_status( - &user, + store.user(&user).update_device_sync_status( vec![devices.iter().map(|s| s.as_ref()).collect()], Vec::new(), )?; } Self::Devices { username } => { let user = store.get_user(username)?; - let devices = store.devices_for_user(&user)?; + let devices = store.user(&user).devices()?; for device in devices { println!("{} ({} subscriptions)", device.id, device.subscriptions); diff --git a/otter/src/server/gpodder/advanced/auth.rs b/otter/src/server/gpodder/advanced/auth.rs index 9c920dc..7e1c8fc 100644 --- a/otter/src/server/gpodder/advanced/auth.rs +++ b/otter/src/server/gpodder/advanced/auth.rs @@ -66,7 +66,7 @@ async fn post_login( .validate_credentials(auth.username(), auth.password())?; let user_agent = user_agent.map(|header| header.to_string()); - let session = ctx.store.create_session(&user, user_agent)?; + let session = ctx.store.user(&user).create_session(user_agent)?; Ok::<_, AuthErr>(session) }) diff --git a/otter/src/server/gpodder/advanced/devices.rs b/otter/src/server/gpodder/advanced/devices.rs index 30bec37..e1bbc12 100644 --- a/otter/src/server/gpodder/advanced/devices.rs +++ b/otter/src/server/gpodder/advanced/devices.rs @@ -39,7 +39,7 @@ async fn get_devices( } Ok( - tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user)) + tokio::task::spawn_blocking(move || ctx.store.user(&user).devices()) .await .unwrap() .map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?, @@ -56,9 +56,11 @@ async fn post_device( return Err(AppError::NotFound); } - tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into())) - .await - .unwrap()?; + tokio::task::spawn_blocking(move || { + ctx.store.user(&user).update_device_info(&id, patch.into()) + }) + .await + .unwrap()?; Ok(()) } diff --git a/otter/src/server/gpodder/advanced/episodes.rs b/otter/src/server/gpodder/advanced/episodes.rs index 2d62333..19e4e1d 100644 --- a/otter/src/server/gpodder/advanced/episodes.rs +++ b/otter/src/server/gpodder/advanced/episodes.rs @@ -46,7 +46,8 @@ async fn post_episode_actions( Ok(tokio::task::spawn_blocking(move || { ctx.store - .add_episode_actions(&user, actions.into_iter().map(Into::into).collect()) + .user(&user) + .add_episode_actions(actions.into_iter().map(Into::into).collect()) }) .await .unwrap() @@ -90,8 +91,7 @@ async fn get_episode_actions( let since = filter.since.and_then(|ts| DateTime::from_timestamp(ts, 0)); Ok(tokio::task::spawn_blocking(move || { - ctx.store.episode_actions_for_user( - &user, + ctx.store.user(&user).episode_actions_for_user( since, filter.podcast, filter.device, diff --git a/otter/src/server/gpodder/advanced/subscriptions.rs b/otter/src/server/gpodder/advanced/subscriptions.rs index b430b3e..6687d32 100644 --- a/otter/src/server/gpodder/advanced/subscriptions.rs +++ b/otter/src/server/gpodder/advanced/subscriptions.rs @@ -44,7 +44,8 @@ pub async fn post_subscription_changes( Ok(tokio::task::spawn_blocking(move || { ctx.store - .update_subscriptions_for_device(&user, &id, delta.add, delta.remove) + .user(&user) + .update_subscriptions_for_device(&id, delta.add, delta.remove) }) .await .unwrap() @@ -79,7 +80,9 @@ pub async fn get_subscription_changes( let since = chrono::DateTime::from_timestamp(query.since, 0).ok_or(AppError::BadRequest)?; Ok(tokio::task::spawn_blocking(move || { - ctx.store.subscription_updates_for_device(&user, &id, since) + ctx.store + .user(&user) + .subscription_updates_for_device(&id, since) }) .await .unwrap() diff --git a/otter/src/server/gpodder/advanced/sync.rs b/otter/src/server/gpodder/advanced/sync.rs index 1ac2c33..6ba3ea2 100644 --- a/otter/src/server/gpodder/advanced/sync.rs +++ b/otter/src/server/gpodder/advanced/sync.rs @@ -41,7 +41,7 @@ pub async fn get_sync_status( } Ok( - tokio::task::spawn_blocking(move || ctx.store.devices_by_sync_group(&user)) + tokio::task::spawn_blocking(move || ctx.store.user(&user).devices_by_sync_group()) .await .unwrap() .map(|(not_synchronized, synchronized)| { @@ -68,8 +68,7 @@ pub async fn post_sync_status_changes( } Ok(tokio::task::spawn_blocking(move || { - ctx.store.update_device_sync_status( - &user, + ctx.store.user(&user).update_device_sync_status( delta .synchronize .iter() @@ -78,7 +77,7 @@ pub async fn post_sync_status_changes( delta.stop_synchronize.iter().map(|s| s.as_ref()).collect(), )?; - ctx.store.devices_by_sync_group(&user) + ctx.store.user(&user).devices_by_sync_group() }) .await .unwrap() diff --git a/otter/src/server/gpodder/mod.rs b/otter/src/server/gpodder/mod.rs index e8f77e3..69e86f2 100644 --- a/otter/src/server/gpodder/mod.rs +++ b/otter/src/server/gpodder/mod.rs @@ -123,7 +123,8 @@ impl From for AppError { match value { gpodder::AuthErr::UnknownUser | gpodder::AuthErr::UnknownSession - | gpodder::AuthErr::InvalidPassword => Self::Unauthorized, + | gpodder::AuthErr::InvalidPassword + | gpodder::AuthErr::NotAnAdmin => Self::Unauthorized, gpodder::AuthErr::Other(err) => Self::Other(err), } } diff --git a/otter/src/server/gpodder/simple/subscriptions.rs b/otter/src/server/gpodder/simple/subscriptions.rs index cd960f1..404e242 100644 --- a/otter/src/server/gpodder/simple/subscriptions.rs +++ b/otter/src/server/gpodder/simple/subscriptions.rs @@ -34,7 +34,7 @@ pub async fn get_device_subscriptions( } Ok( - tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_device(&user, &id)) + tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions_for_device(&id)) .await .unwrap() .map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?, @@ -51,7 +51,7 @@ pub async fn get_user_subscriptions( } Ok( - tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_user(&user)) + tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions()) .await .unwrap() .map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?, @@ -69,7 +69,9 @@ pub async fn put_device_subscriptions( } Ok(tokio::task::spawn_blocking(move || { - ctx.store.set_subscriptions_for_device(&user, &id, urls) + ctx.store + .user(&user) + .set_subscriptions_for_device(&id, urls) }) .await .unwrap() diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index 24da8ca..e9c5cb2 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -1,4 +1,5 @@ mod sessions; +mod users; use axum::{ Form, RequestExt, Router, @@ -76,6 +77,7 @@ pub fn router(ctx: Context) -> Router { .route("/login", get(get_login).post(post_login)) .route("/logout", post(post_logout)) .merge(sessions::router(ctx.clone())) + .merge(users::router(ctx.clone())) } async fn get_index( @@ -125,7 +127,7 @@ async fn post_login( .validate_credentials(&login.username, &login.password)?; let user_agent = user_agent.map(|header| header.to_string()); - let session = ctx.store.create_session(&user, user_agent)?; + let session = ctx.store.user(&user).create_session(user_agent)?; Ok::<_, AuthErr>(session) }) diff --git a/otter/src/server/web/sessions.rs b/otter/src/server/web/sessions.rs index d05e3a7..8c25f30 100644 --- a/otter/src/server/web/sessions.rs +++ b/otter/src/server/web/sessions.rs @@ -31,7 +31,9 @@ pub async fn get_sessions( ) -> AppResult>> { let next_page = page.next_page(); let sessions = tokio::task::spawn_blocking(move || { - ctx.store.paginated_sessions(&session.user, page.into()) + ctx.store + .user(&session.user) + .paginated_sessions(page.into()) }) .await .unwrap()?; diff --git a/otter/src/server/web/users.rs b/otter/src/server/web/users.rs new file mode 100644 index 0000000..f5d3467 --- /dev/null +++ b/otter/src/server/web/users.rs @@ -0,0 +1,47 @@ +use axum::{ + Extension, Router, + extract::{Path, Query, State}, + http::HeaderMap, + routing::{delete, get}, +}; + +use crate::{ + server::{ + Context, + error::{AppError, AppResult}, + }, + web::{Page, TemplateExt, TemplateResponse, ToQuery, View}, +}; + +pub fn router(ctx: Context) -> Router { + Router::new() + .route("/users", get(get_users)) + .route_layer(axum::middleware::from_fn_with_state( + ctx.clone(), + super::auth_web_middleware, + )) +} + +pub async fn get_users( + State(ctx): State, + headers: HeaderMap, + Extension(session): Extension, + Query(page): Query, +) -> AppResult>> { + let next_page = page.next_page(); + let user_id = session.user.id; + let users = tokio::task::spawn_blocking(move || { + ctx.store.admin(&session.user)?.paginated_users(page.into()) + }) + .await + .unwrap()?; + + let next_page_query = + (users.len() == next_page.per_page as usize).then_some(next_page.to_query()); + + Ok(View::Users(users, user_id, next_page_query) + .page(&headers) + .headers(&headers) + .authenticated(true) + .response(&ctx.tera)) +} diff --git a/otter/src/web/mod.rs b/otter/src/web/mod.rs index 93a7119..d151186 100644 --- a/otter/src/web/mod.rs +++ b/otter/src/web/mod.rs @@ -85,6 +85,10 @@ pub fn initialize_tera() -> tera::Result { View::Sessions(Vec::new(), 0, None).template(), include_str!("templates/views/sessions.html"), ), + ( + View::Users(Vec::new(), 0, None).template(), + include_str!("templates/views/users.html"), + ), ])?; Ok(tera) diff --git a/otter/src/web/templates/views/users.html b/otter/src/web/templates/views/users.html new file mode 100644 index 0000000..f53f815 --- /dev/null +++ b/otter/src/web/templates/views/users.html @@ -0,0 +1,21 @@ +

Users

+ + + + + + + {% for user in users %} + + {% endfor %} + + {%- if next_page_query %} + + {% endif %} + +
Username
{{ user.username }}
diff --git a/otter/src/web/view.rs b/otter/src/web/view.rs index b7e45ec..689f3eb 100644 --- a/otter/src/web/view.rs +++ b/otter/src/web/view.rs @@ -7,6 +7,7 @@ pub enum View { Index, Login, Sessions(Vec, i64, Option), + Users(Vec, i64, Option), } #[derive(Serialize)] @@ -16,12 +17,19 @@ struct Session { last_seen: DateTime, } +#[derive(Serialize)] +struct User { + id: i64, + username: String, +} + impl Template for View { fn template(&self) -> &'static str { match self { Self::Index => "views/index.html", Self::Login => "views/login.html", Self::Sessions(..) => "views/sessions.html", + Self::Users(..) => "views/users.html", } } @@ -41,6 +49,18 @@ impl Template for View { ctx.insert("next_page_query", &query.encode()); } } + Self::Users(users, current_user_id, query) => { + ctx.insert( + "users", + &users.into_iter().map(User::from).collect::>(), + ); + + ctx.insert("current_user_id", ¤t_user_id); + + if let Some(query) = query { + ctx.insert("next_page_query", &query.encode()); + } + } _ => {} }; @@ -57,3 +77,12 @@ impl From for Session { } } } + +impl From for User { + fn from(value: gpodder::User) -> Self { + Self { + id: value.id, + username: value.username, + } + } +}