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 id: i64, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub password_hash: String, |     pub password_hash: String, | ||||||
|  |     pub admin: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Debug, PartialEq, Eq)] | #[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 chrono::{DateTime, TimeDelta, Utc}; | ||||||
| use rand::{Rng, rngs::OsRng}; | use rand::Rng; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{AuthErr, GpodderStore, models}; | ||||||
|     models, |  | ||||||
|     store::{AuthErr, GpodderStore}, |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| const MAX_SESSION_AGE: i64 = 60 * 60 * 24 * 7; | /// Authenticated view of the repository, providing methods that take the authenticated user
 | ||||||
| 
 | /// explicitely into account
 | ||||||
| #[derive(Clone)] | pub struct AuthenticatedRepository<'a> { | ||||||
| pub struct GpodderRepository { |     pub(crate) store: &'a (dyn GpodderStore + Send + Sync), | ||||||
|     store: Arc<dyn GpodderStore + Send + Sync>, |     pub(crate) user: &'a models::User, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl GpodderRepository { | impl<'a> AuthenticatedRepository<'a> { | ||||||
|     pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self { |     /// Retrieve the given session from the database, if it exists and is visible to the user
 | ||||||
|         Self { |  | ||||||
|             store: Arc::new(store), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn get_session(&self, session_id: i64) -> Result<models::Session, AuthErr> { |     pub fn get_session(&self, session_id: i64) -> Result<models::Session, AuthErr> { | ||||||
|         let session = self |         let session = self | ||||||
|             .store |             .store | ||||||
|             .get_session(session_id)? |             .get_session(session_id)? | ||||||
|             .ok_or(AuthErr::UnknownSession)?; |             .ok_or(AuthErr::UnknownSession)?; | ||||||
| 
 | 
 | ||||||
|         // Expired sessions still in the database are considered removed
 |         // Users can't see sessions from other users, and expired sessions still in the database
 | ||||||
|         if Utc::now() - session.last_seen > TimeDelta::new(MAX_SESSION_AGE, 0).unwrap() { |         // are considered removed
 | ||||||
|  |         if session.user.id != self.user.id | ||||||
|  |             || Utc::now() - session.last_seen > super::MAX_SESSION_AGE | ||||||
|  |         { | ||||||
|             Err(AuthErr::UnknownSession) |             Err(AuthErr::UnknownSession) | ||||||
|         } else { |         } else { | ||||||
|             Ok(session) |             Ok(session) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn paginated_sessions( |     /// Retrieve a paginated list of the user's sessions
 | ||||||
|         &self, |     pub fn paginated_sessions(&self, page: models::Page) -> Result<Vec<models::Session>, AuthErr> { | ||||||
|         user: &models::User, |         self.store.paginated_sessions(self.user, page) | ||||||
|         page: models::Page, |  | ||||||
|     ) -> Result<Vec<models::Session>, AuthErr> { |  | ||||||
|         self.store.paginated_sessions(user, page) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn get_user(&self, username: &str) -> Result<models::User, AuthErr> { |     /// Create a new session for the authenticated user
 | ||||||
|         self.store.get_user(username)?.ok_or(AuthErr::UnknownUser) |     pub fn create_session(&self, user_agent: Option<String>) -> Result<models::Session, AuthErr> { | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     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> { |  | ||||||
|         let session = models::Session { |         let session = models::Session { | ||||||
|             id: rand::thread_rng().r#gen(), |             id: rand::thread_rng().r#gen(), | ||||||
|             last_seen: Utc::now(), |             last_seen: Utc::now(), | ||||||
|             user: user.clone(), |             user: self.user.clone(), | ||||||
|             user_agent, |             user_agent, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  | @ -96,38 +50,38 @@ impl GpodderRepository { | ||||||
|         Ok(session) |         Ok(session) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Set the session's last seen value to the current time
 | ||||||
|     pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { |     pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> { | ||||||
|         let now = Utc::now(); |         let now = Utc::now(); | ||||||
| 
 | 
 | ||||||
|         self.store.refresh_session(session, 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> { |     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> { |     /// Return the devices for the authenticated user
 | ||||||
|         let min_last_seen = Utc::now() - TimeDelta::seconds(MAX_SESSION_AGE); |     pub fn devices(&self) -> Result<Vec<models::Device>, AuthErr> { | ||||||
| 
 |         self.store.devices_for_user(self.user) | ||||||
|         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) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Update the metadata of a device
 | ||||||
|     pub fn update_device_info( |     pub fn update_device_info( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         device_id: &str, |         device_id: &str, | ||||||
|         patch: models::DevicePatch, |         patch: models::DevicePatch, | ||||||
|     ) -> Result<(), AuthErr> { |     ) -> 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( |     pub fn update_device_sync_status( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         sync: Vec<Vec<&str>>, |         sync: Vec<Vec<&str>>, | ||||||
|         unsync: Vec<&str>, |         unsync: Vec<&str>, | ||||||
|     ) -> Result<(), AuthErr> { |     ) -> Result<(), AuthErr> { | ||||||
|  | @ -146,71 +100,72 @@ impl GpodderRepository { | ||||||
|                 unsync.remove(device_id); |                 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)?; |             self.store.synchronize_sync_group(group_id, now)?; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Finally we unsync the remaining devices
 |         // Finally we unsync the remaining devices
 | ||||||
|         self.store |         self.store | ||||||
|             .remove_from_sync_group(user, unsync.into_iter().collect())?; |             .remove_from_sync_group(self.user, unsync.into_iter().collect())?; | ||||||
| 
 | 
 | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn devices_by_sync_group( |     /// Return the user's devices, grouped per sync group
 | ||||||
|         &self, |     pub fn devices_by_sync_group(&self) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr> { | ||||||
|         user: &models::User, |         self.store.devices_by_sync_group(self.user) | ||||||
|     ) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr> { |  | ||||||
|         self.store.devices_by_sync_group(user) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Retrieve the user's subscriptions for a device
 | ||||||
|     pub fn subscriptions_for_device( |     pub fn subscriptions_for_device( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         device_id: &str, |         device_id: &str, | ||||||
|     ) -> Result<Vec<models::Subscription>, AuthErr> { |     ) -> 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( |     /// Retrieve the user's subscriptions
 | ||||||
|         &self, |     pub fn subscriptions(&self) -> Result<Vec<models::Subscription>, AuthErr> { | ||||||
|         user: &models::User, |         self.store.subscriptions_for_user(self.user) | ||||||
|     ) -> Result<Vec<models::Subscription>, AuthErr> { |  | ||||||
|         self.store.subscriptions_for_user(user) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Set the subscriptions for a given device
 | ||||||
|     pub fn set_subscriptions_for_device( |     pub fn set_subscriptions_for_device( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         device_id: &str, |         device_id: &str, | ||||||
|         urls: Vec<String>, |         urls: Vec<String>, | ||||||
|     ) -> Result<DateTime<Utc>, AuthErr> { |     ) -> Result<DateTime<Utc>, AuthErr> { | ||||||
|         let time_changed = Utc::now(); |         let time_changed = Utc::now(); | ||||||
| 
 | 
 | ||||||
|         self.store |         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)) |         Ok(time_changed + TimeDelta::seconds(1)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Add and remove subscriptions to and from a given device.
 | ||||||
|     pub fn update_subscriptions_for_device( |     pub fn update_subscriptions_for_device( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         device_id: &str, |         device_id: &str, | ||||||
|         add: Vec<String>, |         add: Vec<String>, | ||||||
|         remove: Vec<String>, |         remove: Vec<String>, | ||||||
|     ) -> Result<DateTime<Utc>, AuthErr> { |     ) -> Result<DateTime<Utc>, AuthErr> { | ||||||
|         let time_changed = Utc::now(); |         let time_changed = Utc::now(); | ||||||
| 
 | 
 | ||||||
|         self.store |         self.store.update_subscriptions_for_device( | ||||||
|             .update_subscriptions_for_device(user, device_id, add, remove, time_changed)?; |             self.user, | ||||||
|  |             device_id, | ||||||
|  |             add, | ||||||
|  |             remove, | ||||||
|  |             time_changed, | ||||||
|  |         )?; | ||||||
| 
 | 
 | ||||||
|         Ok(time_changed + TimeDelta::seconds(1)) |         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( |     pub fn subscription_updates_for_device( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         device_id: &str, |         device_id: &str, | ||||||
|         since: DateTime<Utc>, |         since: DateTime<Utc>, | ||||||
|     ) -> Result< |     ) -> Result< | ||||||
|  | @ -225,7 +180,7 @@ impl GpodderRepository { | ||||||
| 
 | 
 | ||||||
|         let (added, removed) = self |         let (added, removed) = self | ||||||
|             .store |             .store | ||||||
|             .subscription_updates_for_device(user, device_id, since)?; |             .subscription_updates_for_device(self.user, device_id, since)?; | ||||||
| 
 | 
 | ||||||
|         let max_time_changed = added |         let max_time_changed = added | ||||||
|             .iter() |             .iter() | ||||||
|  | @ -236,22 +191,22 @@ impl GpodderRepository { | ||||||
|         Ok((max_time_changed + TimeDelta::seconds(1), added, removed)) |         Ok((max_time_changed + TimeDelta::seconds(1), added, removed)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Add episode actions to the database
 | ||||||
|     pub fn add_episode_actions( |     pub fn add_episode_actions( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         actions: Vec<models::EpisodeAction>, |         actions: Vec<models::EpisodeAction>, | ||||||
|     ) -> Result<DateTime<Utc>, AuthErr> { |     ) -> Result<DateTime<Utc>, AuthErr> { | ||||||
|         let time_changed = Utc::now(); |         let time_changed = Utc::now(); | ||||||
| 
 | 
 | ||||||
|         self.store |         self.store | ||||||
|             .add_episode_actions(user, actions, time_changed)?; |             .add_episode_actions(self.user, actions, time_changed)?; | ||||||
| 
 | 
 | ||||||
|         Ok(time_changed + TimeDelta::seconds(1)) |         Ok(time_changed + TimeDelta::seconds(1)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// Get episode actions for the currently authenticated user
 | ||||||
|     pub fn episode_actions_for_user( |     pub fn episode_actions_for_user( | ||||||
|         &self, |         &self, | ||||||
|         user: &models::User, |  | ||||||
|         since: Option<DateTime<Utc>>, |         since: Option<DateTime<Utc>>, | ||||||
|         podcast: Option<String>, |         podcast: Option<String>, | ||||||
|         device: Option<String>, |         device: Option<String>, | ||||||
|  | @ -260,7 +215,7 @@ impl GpodderRepository { | ||||||
|         let now = chrono::Utc::now(); |         let now = chrono::Utc::now(); | ||||||
|         let actions = self |         let actions = self | ||||||
|             .store |             .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); |         let max_time_changed = actions.iter().map(|a| a.time_changed).max().unwrap_or(now); | ||||||
| 
 | 
 | ||||||
|         Ok((max_time_changed + TimeDelta::seconds(1), actions)) |         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, |     UnknownSession, | ||||||
|     UnknownUser, |     UnknownUser, | ||||||
|     InvalidPassword, |     InvalidPassword, | ||||||
|  |     NotAnAdmin, | ||||||
|     Other(Box<dyn std::error::Error + Sync + Send>), |     Other(Box<dyn std::error::Error + Sync + Send>), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -17,6 +18,7 @@ impl Display for AuthErr { | ||||||
|             Self::UnknownUser => write!(f, "unknown user"), |             Self::UnknownUser => write!(f, "unknown user"), | ||||||
|             Self::UnknownSession => write!(f, "unknown session"), |             Self::UnknownSession => write!(f, "unknown session"), | ||||||
|             Self::InvalidPassword => write!(f, "invalid password"), |             Self::InvalidPassword => write!(f, "invalid password"), | ||||||
|  |             Self::NotAnAdmin => write!(f, "not an admin"), | ||||||
|             Self::Other(err) => err.fmt(f), |             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
 |     /// 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>; |     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 { | 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 id: i64, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub password_hash: String, |     pub password_hash: String, | ||||||
|  |     pub admin: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Insertable)] | #[derive(Insertable)] | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ impl From<User> for gpodder::User { | ||||||
|             id: value.id, |             id: value.id, | ||||||
|             username: value.username, |             username: value.username, | ||||||
|             password_hash: value.password_hash, |             password_hash: value.password_hash, | ||||||
|  |             admin: value.admin, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -141,4 +142,17 @@ impl gpodder::GpodderAuthStore for SqliteRepository { | ||||||
|         })() |         })() | ||||||
|         .map_err(AuthErr::from) |         .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, |         id -> BigInt, | ||||||
|         username -> Text, |         username -> Text, | ||||||
|         password_hash -> Text, |         password_hash -> Text, | ||||||
|  |         admin -> Bool, | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -23,15 +23,14 @@ impl Command { | ||||||
|         match self { |         match self { | ||||||
|             Self::Sync { username, devices } => { |             Self::Sync { username, devices } => { | ||||||
|                 let user = store.get_user(username)?; |                 let user = store.get_user(username)?; | ||||||
|                 store.update_device_sync_status( |                 store.user(&user).update_device_sync_status( | ||||||
|                     &user, |  | ||||||
|                     vec![devices.iter().map(|s| s.as_ref()).collect()], |                     vec![devices.iter().map(|s| s.as_ref()).collect()], | ||||||
|                     Vec::new(), |                     Vec::new(), | ||||||
|                 )?; |                 )?; | ||||||
|             } |             } | ||||||
|             Self::Devices { username } => { |             Self::Devices { username } => { | ||||||
|                 let user = store.get_user(username)?; |                 let user = store.get_user(username)?; | ||||||
|                 let devices = store.devices_for_user(&user)?; |                 let devices = store.user(&user).devices()?; | ||||||
| 
 | 
 | ||||||
|                 for device in devices { |                 for device in devices { | ||||||
|                     println!("{} ({} subscriptions)", device.id, device.subscriptions); |                     println!("{} ({} subscriptions)", device.id, device.subscriptions); | ||||||
|  |  | ||||||
|  | @ -66,7 +66,7 @@ async fn post_login( | ||||||
|             .validate_credentials(auth.username(), auth.password())?; |             .validate_credentials(auth.username(), auth.password())?; | ||||||
| 
 | 
 | ||||||
|         let user_agent = user_agent.map(|header| header.to_string()); |         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) |         Ok::<_, AuthErr>(session) | ||||||
|     }) |     }) | ||||||
|  |  | ||||||
|  | @ -39,7 +39,7 @@ async fn get_devices( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok( |     Ok( | ||||||
|         tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user)) |         tokio::task::spawn_blocking(move || ctx.store.user(&user).devices()) | ||||||
|             .await |             .await | ||||||
|             .unwrap() |             .unwrap() | ||||||
|             .map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?, |             .map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?, | ||||||
|  | @ -56,9 +56,11 @@ async fn post_device( | ||||||
|         return Err(AppError::NotFound); |         return Err(AppError::NotFound); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into())) |     tokio::task::spawn_blocking(move || { | ||||||
|         .await |         ctx.store.user(&user).update_device_info(&id, patch.into()) | ||||||
|         .unwrap()?; |     }) | ||||||
|  |     .await | ||||||
|  |     .unwrap()?; | ||||||
| 
 | 
 | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -46,7 +46,8 @@ async fn post_episode_actions( | ||||||
| 
 | 
 | ||||||
|     Ok(tokio::task::spawn_blocking(move || { |     Ok(tokio::task::spawn_blocking(move || { | ||||||
|         ctx.store |         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 |     .await | ||||||
|     .unwrap() |     .unwrap() | ||||||
|  | @ -90,8 +91,7 @@ async fn get_episode_actions( | ||||||
|     let since = filter.since.and_then(|ts| DateTime::from_timestamp(ts, 0)); |     let since = filter.since.and_then(|ts| DateTime::from_timestamp(ts, 0)); | ||||||
| 
 | 
 | ||||||
|     Ok(tokio::task::spawn_blocking(move || { |     Ok(tokio::task::spawn_blocking(move || { | ||||||
|         ctx.store.episode_actions_for_user( |         ctx.store.user(&user).episode_actions_for_user( | ||||||
|             &user, |  | ||||||
|             since, |             since, | ||||||
|             filter.podcast, |             filter.podcast, | ||||||
|             filter.device, |             filter.device, | ||||||
|  |  | ||||||
|  | @ -44,7 +44,8 @@ pub async fn post_subscription_changes( | ||||||
| 
 | 
 | ||||||
|     Ok(tokio::task::spawn_blocking(move || { |     Ok(tokio::task::spawn_blocking(move || { | ||||||
|         ctx.store |         ctx.store | ||||||
|             .update_subscriptions_for_device(&user, &id, delta.add, delta.remove) |             .user(&user) | ||||||
|  |             .update_subscriptions_for_device(&id, delta.add, delta.remove) | ||||||
|     }) |     }) | ||||||
|     .await |     .await | ||||||
|     .unwrap() |     .unwrap() | ||||||
|  | @ -79,7 +80,9 @@ pub async fn get_subscription_changes( | ||||||
|     let since = chrono::DateTime::from_timestamp(query.since, 0).ok_or(AppError::BadRequest)?; |     let since = chrono::DateTime::from_timestamp(query.since, 0).ok_or(AppError::BadRequest)?; | ||||||
| 
 | 
 | ||||||
|     Ok(tokio::task::spawn_blocking(move || { |     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 |     .await | ||||||
|     .unwrap() |     .unwrap() | ||||||
|  |  | ||||||
|  | @ -41,7 +41,7 @@ pub async fn get_sync_status( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok( |     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 |             .await | ||||||
|             .unwrap() |             .unwrap() | ||||||
|             .map(|(not_synchronized, synchronized)| { |             .map(|(not_synchronized, synchronized)| { | ||||||
|  | @ -68,8 +68,7 @@ pub async fn post_sync_status_changes( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok(tokio::task::spawn_blocking(move || { |     Ok(tokio::task::spawn_blocking(move || { | ||||||
|         ctx.store.update_device_sync_status( |         ctx.store.user(&user).update_device_sync_status( | ||||||
|             &user, |  | ||||||
|             delta |             delta | ||||||
|                 .synchronize |                 .synchronize | ||||||
|                 .iter() |                 .iter() | ||||||
|  | @ -78,7 +77,7 @@ pub async fn post_sync_status_changes( | ||||||
|             delta.stop_synchronize.iter().map(|s| s.as_ref()).collect(), |             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 |     .await | ||||||
|     .unwrap() |     .unwrap() | ||||||
|  |  | ||||||
|  | @ -123,7 +123,8 @@ impl From<gpodder::AuthErr> for AppError { | ||||||
|         match value { |         match value { | ||||||
|             gpodder::AuthErr::UnknownUser |             gpodder::AuthErr::UnknownUser | ||||||
|             | gpodder::AuthErr::UnknownSession |             | gpodder::AuthErr::UnknownSession | ||||||
|             | gpodder::AuthErr::InvalidPassword => Self::Unauthorized, |             | gpodder::AuthErr::InvalidPassword | ||||||
|  |             | gpodder::AuthErr::NotAnAdmin => Self::Unauthorized, | ||||||
|             gpodder::AuthErr::Other(err) => Self::Other(err), |             gpodder::AuthErr::Other(err) => Self::Other(err), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -34,7 +34,7 @@ pub async fn get_device_subscriptions( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok( |     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 |             .await | ||||||
|             .unwrap() |             .unwrap() | ||||||
|             .map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?, |             .map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?, | ||||||
|  | @ -51,7 +51,7 @@ pub async fn get_user_subscriptions( | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Ok( |     Ok( | ||||||
|         tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_user(&user)) |         tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions()) | ||||||
|             .await |             .await | ||||||
|             .unwrap() |             .unwrap() | ||||||
|             .map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?, |             .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 || { |     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 |     .await | ||||||
|     .unwrap() |     .unwrap() | ||||||
|  |  | ||||||
|  | @ -1,4 +1,5 @@ | ||||||
| mod sessions; | mod sessions; | ||||||
|  | mod users; | ||||||
| 
 | 
 | ||||||
| use axum::{ | use axum::{ | ||||||
|     Form, RequestExt, Router, |     Form, RequestExt, Router, | ||||||
|  | @ -76,6 +77,7 @@ pub fn router(ctx: Context) -> Router<Context> { | ||||||
|         .route("/login", get(get_login).post(post_login)) |         .route("/login", get(get_login).post(post_login)) | ||||||
|         .route("/logout", post(post_logout)) |         .route("/logout", post(post_logout)) | ||||||
|         .merge(sessions::router(ctx.clone())) |         .merge(sessions::router(ctx.clone())) | ||||||
|  |         .merge(users::router(ctx.clone())) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn get_index( | async fn get_index( | ||||||
|  | @ -125,7 +127,7 @@ async fn post_login( | ||||||
|             .validate_credentials(&login.username, &login.password)?; |             .validate_credentials(&login.username, &login.password)?; | ||||||
| 
 | 
 | ||||||
|         let user_agent = user_agent.map(|header| header.to_string()); |         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) |         Ok::<_, AuthErr>(session) | ||||||
|     }) |     }) | ||||||
|  |  | ||||||
|  | @ -31,7 +31,9 @@ pub async fn get_sessions( | ||||||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ) -> AppResult<TemplateResponse<Page<View>>> { | ||||||
|     let next_page = page.next_page(); |     let next_page = page.next_page(); | ||||||
|     let sessions = tokio::task::spawn_blocking(move || { |     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 |     .await | ||||||
|     .unwrap()?; |     .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(), |             View::Sessions(Vec::new(), 0, None).template(), | ||||||
|             include_str!("templates/views/sessions.html"), |             include_str!("templates/views/sessions.html"), | ||||||
|         ), |         ), | ||||||
|  |         ( | ||||||
|  |             View::Users(Vec::new(), 0, None).template(), | ||||||
|  |             include_str!("templates/views/users.html"), | ||||||
|  |         ), | ||||||
|     ])?; |     ])?; | ||||||
| 
 | 
 | ||||||
|     Ok(tera) |     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, |     Index, | ||||||
|     Login, |     Login, | ||||||
|     Sessions(Vec<gpodder::Session>, i64, Option<Query>), |     Sessions(Vec<gpodder::Session>, i64, Option<Query>), | ||||||
|  |     Users(Vec<gpodder::User>, i64, Option<Query>), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize)] | #[derive(Serialize)] | ||||||
|  | @ -16,12 +17,19 @@ struct Session { | ||||||
|     last_seen: DateTime<Utc>, |     last_seen: DateTime<Utc>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[derive(Serialize)] | ||||||
|  | struct User { | ||||||
|  |     id: i64, | ||||||
|  |     username: String, | ||||||
|  | } | ||||||
|  | 
 | ||||||
| impl Template for View { | impl Template for View { | ||||||
|     fn template(&self) -> &'static str { |     fn template(&self) -> &'static str { | ||||||
|         match self { |         match self { | ||||||
|             Self::Index => "views/index.html", |             Self::Index => "views/index.html", | ||||||
|             Self::Login => "views/login.html", |             Self::Login => "views/login.html", | ||||||
|             Self::Sessions(..) => "views/sessions.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()); |                     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