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