Compare commits
No commits in common. "30609b1cef528bdea68ca1375ddb14260c1a9780" and "0e91eef0e80f051873c2e28e4a30c2a247d9683a" have entirely different histories.
30609b1cef
...
0e91eef0e8
|
@ -5,7 +5,6 @@ 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)]
|
||||||
|
|
|
@ -1,47 +1,93 @@
|
||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, sync::Arc};
|
||||||
|
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use rand::Rng;
|
use rand::{Rng, rngs::OsRng};
|
||||||
|
|
||||||
use crate::{AuthErr, GpodderStore, models};
|
use crate::{
|
||||||
|
models,
|
||||||
|
store::{AuthErr, GpodderStore},
|
||||||
|
};
|
||||||
|
|
||||||
/// Authenticated view of the repository, providing methods that take the authenticated user
|
const MAX_SESSION_AGE: i64 = 60 * 60 * 24 * 7;
|
||||||
/// explicitely into account
|
|
||||||
pub struct AuthenticatedRepository<'a> {
|
#[derive(Clone)]
|
||||||
pub(crate) store: &'a (dyn GpodderStore + Send + Sync),
|
pub struct GpodderRepository {
|
||||||
pub(crate) user: &'a models::User,
|
store: Arc<dyn GpodderStore + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> AuthenticatedRepository<'a> {
|
impl GpodderRepository {
|
||||||
/// Retrieve the given session from the database, if it exists and is visible to the user
|
pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self {
|
||||||
|
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)?;
|
||||||
|
|
||||||
// Users can't see sessions from other users, and expired sessions still in the database
|
// Expired sessions still in the database are considered removed
|
||||||
// are considered removed
|
if Utc::now() - session.last_seen > TimeDelta::new(MAX_SESSION_AGE, 0).unwrap() {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a paginated list of the user's sessions
|
pub fn paginated_sessions(
|
||||||
pub fn paginated_sessions(&self, page: models::Page) -> Result<Vec<models::Session>, AuthErr> {
|
&self,
|
||||||
self.store.paginated_sessions(self.user, page)
|
user: &models::User,
|
||||||
|
page: models::Page,
|
||||||
|
) -> Result<Vec<models::Session>, AuthErr> {
|
||||||
|
self.store.paginated_sessions(user, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new session for the authenticated user
|
pub fn get_user(&self, username: &str) -> Result<models::User, AuthErr> {
|
||||||
pub fn create_session(&self, user_agent: Option<String>) -> Result<models::Session, AuthErr> {
|
self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
||||||
|
let password_hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
self.store.insert_user(username, &password_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_credentials(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<models::User, AuthErr> {
|
||||||
|
let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?;
|
||||||
|
|
||||||
|
let password_hash = PasswordHash::new(&user.password_hash).unwrap();
|
||||||
|
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &password_hash)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(AuthErr::InvalidPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(
|
||||||
|
&self,
|
||||||
|
user: &models::User,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
) -> Result<models::Session, AuthErr> {
|
||||||
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: self.user.clone(),
|
user: user.clone(),
|
||||||
user_agent,
|
user_agent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -50,38 +96,38 @@ impl<'a> AuthenticatedRepository<'a> {
|
||||||
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> {
|
||||||
// This fails if the session doesn't exist for the user, so it's basically a "exists" check
|
self.store.remove_session(session_id)
|
||||||
let session = self.get_session(session_id)?;
|
|
||||||
|
|
||||||
self.store.remove_session(session.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the devices for the authenticated user
|
pub fn remove_old_sessions(&self) -> Result<usize, AuthErr> {
|
||||||
pub fn devices(&self) -> Result<Vec<models::Device>, AuthErr> {
|
let min_last_seen = Utc::now() - TimeDelta::seconds(MAX_SESSION_AGE);
|
||||||
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(self.user, device_id, patch)
|
self.store.update_device_info(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> {
|
||||||
|
@ -100,72 +146,71 @@ impl<'a> AuthenticatedRepository<'a> {
|
||||||
unsync.remove(device_id);
|
unsync.remove(device_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let group_id = self.store.merge_sync_groups(self.user, remaining)?;
|
let group_id = self.store.merge_sync_groups(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(self.user, unsync.into_iter().collect())?;
|
.remove_from_sync_group(user, unsync.into_iter().collect())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the user's devices, grouped per sync group
|
pub fn devices_by_sync_group(
|
||||||
pub fn devices_by_sync_group(&self) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr> {
|
&self,
|
||||||
self.store.devices_by_sync_group(self.user)
|
user: &models::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(self.user, device_id)
|
self.store.subscriptions_for_device(user, device_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the user's subscriptions
|
pub fn subscriptions_for_user(
|
||||||
pub fn subscriptions(&self) -> Result<Vec<models::Subscription>, AuthErr> {
|
&self,
|
||||||
self.store.subscriptions_for_user(self.user)
|
user: &models::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(self.user, device_id, urls, time_changed)?;
|
.set_subscriptions_for_device(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.update_subscriptions_for_device(
|
self.store
|
||||||
self.user,
|
.update_subscriptions_for_device(user, device_id, add, remove, time_changed)?;
|
||||||
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<
|
||||||
|
@ -180,7 +225,7 @@ impl<'a> AuthenticatedRepository<'a> {
|
||||||
|
|
||||||
let (added, removed) = self
|
let (added, removed) = self
|
||||||
.store
|
.store
|
||||||
.subscription_updates_for_device(self.user, device_id, since)?;
|
.subscription_updates_for_device(user, device_id, since)?;
|
||||||
|
|
||||||
let max_time_changed = added
|
let max_time_changed = added
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -191,22 +236,22 @@ impl<'a> AuthenticatedRepository<'a> {
|
||||||
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(self.user, actions, time_changed)?;
|
.add_episode_actions(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>,
|
||||||
|
@ -215,7 +260,7 @@ impl<'a> AuthenticatedRepository<'a> {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let actions = self
|
let actions = self
|
||||||
.store
|
.store
|
||||||
.episode_actions_for_user(self.user, since, podcast, device, aggregated)?;
|
.episode_actions_for_user(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))
|
|
@ -1,13 +0,0 @@
|
||||||
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,120 +0,0 @@
|
||||||
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,7 +8,6 @@ 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>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,7 +17,6 @@ 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -64,9 +62,6 @@ 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 {
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
alter table users
|
|
||||||
drop column admin;
|
|
|
@ -1,2 +0,0 @@
|
||||||
alter table users
|
|
||||||
add column admin boolean not null default false;
|
|
|
@ -9,7 +9,6 @@ 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,7 +18,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,17 +141,4 @@ 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,7 +58,6 @@ diesel::table! {
|
||||||
id -> BigInt,
|
id -> BigInt,
|
||||||
username -> Text,
|
username -> Text,
|
||||||
password_hash -> Text,
|
password_hash -> Text,
|
||||||
admin -> Bool,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,14 +23,15 @@ 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.user(&user).update_device_sync_status(
|
store.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.user(&user).devices()?;
|
let devices = store.devices_for_user(&user)?;
|
||||||
|
|
||||||
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.user(&user).create_session(user_agent)?;
|
let session = ctx.store.create_session(&user, 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.user(&user).devices())
|
tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user))
|
||||||
.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,11 +56,9 @@ async fn post_device(
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into()))
|
||||||
ctx.store.user(&user).update_device_info(&id, patch.into())
|
.await
|
||||||
})
|
.unwrap()?;
|
||||||
.await
|
|
||||||
.unwrap()?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,8 +46,7 @@ async fn post_episode_actions(
|
||||||
|
|
||||||
Ok(tokio::task::spawn_blocking(move || {
|
Ok(tokio::task::spawn_blocking(move || {
|
||||||
ctx.store
|
ctx.store
|
||||||
.user(&user)
|
.add_episode_actions(&user, actions.into_iter().map(Into::into).collect())
|
||||||
.add_episode_actions(actions.into_iter().map(Into::into).collect())
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -91,7 +90,8 @@ 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.user(&user).episode_actions_for_user(
|
ctx.store.episode_actions_for_user(
|
||||||
|
&user,
|
||||||
since,
|
since,
|
||||||
filter.podcast,
|
filter.podcast,
|
||||||
filter.device,
|
filter.device,
|
||||||
|
|
|
@ -44,8 +44,7 @@ pub async fn post_subscription_changes(
|
||||||
|
|
||||||
Ok(tokio::task::spawn_blocking(move || {
|
Ok(tokio::task::spawn_blocking(move || {
|
||||||
ctx.store
|
ctx.store
|
||||||
.user(&user)
|
.update_subscriptions_for_device(&user, &id, delta.add, delta.remove)
|
||||||
.update_subscriptions_for_device(&id, delta.add, delta.remove)
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -80,9 +79,7 @@ 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
|
ctx.store.subscription_updates_for_device(&user, &id, since)
|
||||||
.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.user(&user).devices_by_sync_group())
|
tokio::task::spawn_blocking(move || ctx.store.devices_by_sync_group(&user))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|(not_synchronized, synchronized)| {
|
.map(|(not_synchronized, synchronized)| {
|
||||||
|
@ -68,7 +68,8 @@ pub async fn post_sync_status_changes(
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(tokio::task::spawn_blocking(move || {
|
Ok(tokio::task::spawn_blocking(move || {
|
||||||
ctx.store.user(&user).update_device_sync_status(
|
ctx.store.update_device_sync_status(
|
||||||
|
&user,
|
||||||
delta
|
delta
|
||||||
.synchronize
|
.synchronize
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -77,7 +78,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.user(&user).devices_by_sync_group()
|
ctx.store.devices_by_sync_group(&user)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
@ -123,8 +123,7 @@ 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
|
| gpodder::AuthErr::InvalidPassword => Self::Unauthorized,
|
||||||
| 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.user(&user).subscriptions_for_device(&id))
|
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_device(&user, &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.user(&user).subscriptions())
|
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_user(&user))
|
||||||
.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,9 +69,7 @@ pub async fn put_device_subscriptions(
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(tokio::task::spawn_blocking(move || {
|
Ok(tokio::task::spawn_blocking(move || {
|
||||||
ctx.store
|
ctx.store.set_subscriptions_for_device(&user, &id, urls)
|
||||||
.user(&user)
|
|
||||||
.set_subscriptions_for_device(&id, urls)
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod users;
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, RequestExt, Router,
|
Form, RequestExt, Router,
|
||||||
|
@ -77,7 +76,6 @@ 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(
|
||||||
|
@ -127,7 +125,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.user(&user).create_session(user_agent)?;
|
let session = ctx.store.create_session(&user, user_agent)?;
|
||||||
|
|
||||||
Ok::<_, AuthErr>(session)
|
Ok::<_, AuthErr>(session)
|
||||||
})
|
})
|
||||||
|
|
|
@ -31,9 +31,7 @@ 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
|
ctx.store.paginated_sessions(&session.user, page.into())
|
||||||
.user(&session.user)
|
|
||||||
.paginated_sessions(page.into())
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()?;
|
.unwrap()?;
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
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,10 +85,6 @@ 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)
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
<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,7 +7,6 @@ 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)]
|
||||||
|
@ -17,19 +16,12 @@ 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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,18 +41,6 @@ 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,12 +57,3 @@ 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