Compare commits
	
		
			5 Commits 
		
	
	
		
			0e91eef0e8
			...
			30609b1cef
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 30609b1cef | |
|  | 4854c84601 | |
|  | 2524eb5807 | |
|  | 669aa475ca | |
|  | 346c27fc3f | 
|  | @ -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)] | ||||
|  |  | |||
|  | @ -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<Vec<models::User>, AuthErr> { | ||||
|         self.store.paginated_users(page) | ||||
|     } | ||||
| } | ||||
|  | @ -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<dyn GpodderStore + Send + Sync>, | ||||
| /// 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<models::Session, AuthErr> { | ||||
|         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<Vec<models::Session>, 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<Vec<models::Session>, AuthErr> { | ||||
|         self.store.paginated_sessions(self.user, page) | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_user(&self, username: &str) -> Result<models::User, AuthErr> { | ||||
|         self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) | ||||
|     } | ||||
| 
 | ||||
|     pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> { | ||||
|         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<models::User, AuthErr> { | ||||
|         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<String>, | ||||
|     ) -> Result<models::Session, AuthErr> { | ||||
|     /// Create a new session for the authenticated user
 | ||||
|     pub fn create_session(&self, user_agent: Option<String>) -> Result<models::Session, AuthErr> { | ||||
|         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<usize, AuthErr> { | ||||
|         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<Vec<models::Device>, AuthErr> { | ||||
|         self.store.devices_for_user(user) | ||||
|     /// Return the devices for the authenticated user
 | ||||
|     pub fn devices(&self) -> Result<Vec<models::Device>, 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<Vec<&str>>, | ||||
|         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<String>, Vec<Vec<String>>), 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<String>, Vec<Vec<String>>), 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<Vec<models::Subscription>, 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<Vec<models::Subscription>, AuthErr> { | ||||
|         self.store.subscriptions_for_user(user) | ||||
|     /// Retrieve the user's subscriptions
 | ||||
|     pub fn subscriptions(&self) -> Result<Vec<models::Subscription>, 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<String>, | ||||
|     ) -> Result<DateTime<Utc>, 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<String>, | ||||
|         remove: Vec<String>, | ||||
|     ) -> Result<DateTime<Utc>, 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<Utc>, | ||||
|     ) -> 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<models::EpisodeAction>, | ||||
|     ) -> Result<DateTime<Utc>, 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<DateTime<Utc>>, | ||||
|         podcast: Option<String>, | ||||
|         device: Option<String>, | ||||
|  | @ -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)) | ||||
|  | @ -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<dyn GpodderStore + Send + Sync>, | ||||
| } | ||||
| 
 | ||||
| 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<admin::AdminRepository<'a>, 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<models::Session, AuthErr> { | ||||
|         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<models::User, AuthErr> { | ||||
|         self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) | ||||
|     } | ||||
| 
 | ||||
|     pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> { | ||||
|         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<models::User, AuthErr> { | ||||
|         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<usize, AuthErr> { | ||||
|         let min_last_seen = Utc::now() - MAX_SESSION_AGE; | ||||
| 
 | ||||
|         self.store.remove_old_sessions(min_last_seen) | ||||
|     } | ||||
| } | ||||
|  | @ -8,6 +8,7 @@ pub enum AuthErr { | |||
|     UnknownSession, | ||||
|     UnknownUser, | ||||
|     InvalidPassword, | ||||
|     NotAnAdmin, | ||||
|     Other(Box<dyn std::error::Error + Sync + Send>), | ||||
| } | ||||
| 
 | ||||
|  | @ -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<Utc>) -> Result<usize, AuthErr>; | ||||
| 
 | ||||
|     /// Return the given page of users, ordered by username
 | ||||
|     fn paginated_users(&self, page: Page) -> Result<Vec<User>, AuthErr>; | ||||
| } | ||||
| 
 | ||||
| pub trait GpodderDeviceStore { | ||||
|  |  | |||
|  | @ -0,0 +1,2 @@ | |||
| alter table users | ||||
|     drop column admin; | ||||
|  | @ -0,0 +1,2 @@ | |||
| alter table users | ||||
|     add column admin boolean not null default false; | ||||
|  | @ -9,6 +9,7 @@ pub struct User { | |||
|     pub id: i64, | ||||
|     pub username: String, | ||||
|     pub password_hash: String, | ||||
|     pub admin: bool, | ||||
| } | ||||
| 
 | ||||
| #[derive(Insertable)] | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ impl From<User> 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<Vec<gpodder::User>, 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()) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -58,6 +58,7 @@ diesel::table! { | |||
|         id -> BigInt, | ||||
|         username -> Text, | ||||
|         password_hash -> Text, | ||||
|         admin -> Bool, | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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) | ||||
|     }) | ||||
|  |  | |||
|  | @ -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(()) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -123,7 +123,8 @@ impl From<gpodder::AuthErr> 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), | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| mod sessions; | ||||
| mod users; | ||||
| 
 | ||||
| use axum::{ | ||||
|     Form, RequestExt, Router, | ||||
|  | @ -76,6 +77,7 @@ pub fn router(ctx: Context) -> Router<Context> { | |||
|         .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) | ||||
|     }) | ||||
|  |  | |||
|  | @ -31,7 +31,9 @@ pub async fn get_sessions( | |||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ||||
|     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()?; | ||||
|  |  | |||
|  | @ -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<Context> { | ||||
|     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<Context>, | ||||
|     headers: HeaderMap, | ||||
|     Extension(session): Extension<gpodder::Session>, | ||||
|     Query(page): Query<super::Pagination>, | ||||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ||||
|     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)) | ||||
| } | ||||
|  | @ -85,6 +85,10 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> { | |||
|             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) | ||||
|  |  | |||
|  | @ -0,0 +1,21 @@ | |||
| <h1>Users</h1> | ||||
| 
 | ||||
| <table> | ||||
|     <thead> | ||||
|         <th>Username</th> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         {% for user in users %} | ||||
|         <th>{{ user.username }}</th> | ||||
|         {% endfor %} | ||||
| 
 | ||||
|         {%- if next_page_query %} | ||||
|         <tr  | ||||
|             hx-get="/users?{{ next_page_query }}" | ||||
|             hx-trigger="revealed" | ||||
|             hx-swap="outerHTML" | ||||
|             hx-select="table > tbody > tr" | ||||
|         ></tr> | ||||
|         {% endif %} | ||||
|     </tbody> | ||||
| </table> | ||||
|  | @ -7,6 +7,7 @@ pub enum View { | |||
|     Index, | ||||
|     Login, | ||||
|     Sessions(Vec<gpodder::Session>, i64, Option<Query>), | ||||
|     Users(Vec<gpodder::User>, i64, Option<Query>), | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
|  | @ -16,12 +17,19 @@ struct Session { | |||
|     last_seen: DateTime<Utc>, | ||||
| } | ||||
| 
 | ||||
| #[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::<Vec<_>>(), | ||||
|                 ); | ||||
| 
 | ||||
|                 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<gpodder::Session> for Session { | |||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<gpodder::User> for User { | ||||
|     fn from(value: gpodder::User) -> Self { | ||||
|         Self { | ||||
|             id: value.id, | ||||
|             username: value.username, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue