Compare commits

...

5 Commits

24 changed files with 352 additions and 127 deletions

View File

@ -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)]

View File

@ -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)
}
}

View File

@ -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 {
pub fn new(store: impl GpodderStore + Send + Sync + 'static) -> Self {
Self {
store: Arc::new(store),
}
} }
impl<'a> AuthenticatedRepository<'a> {
/// Retrieve the given session from the database, if it exists and is visible to the user
pub fn get_session(&self, session_id: i64) -> Result<models::Session, AuthErr> { 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))

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -0,0 +1,2 @@
alter table users
drop column admin;

View File

@ -0,0 +1,2 @@
alter table users
add column admin boolean not null default false;

View File

@ -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)]

View File

@ -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())
}
} }

View File

@ -58,6 +58,7 @@ diesel::table! {
id -> BigInt, id -> BigInt,
username -> Text, username -> Text,
password_hash -> Text, password_hash -> Text,
admin -> Bool,
} }
} }

View File

@ -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);

View File

@ -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)
}) })

View File

@ -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,7 +56,9 @@ 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 || {
ctx.store.user(&user).update_device_info(&id, patch.into())
})
.await .await
.unwrap()?; .unwrap()?;

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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),
} }
} }

View File

@ -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()

View File

@ -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)
}) })

View File

@ -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()?;

View File

@ -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))
}

View File

@ -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)

View File

@ -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>

View File

@ -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", &current_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,
}
}
}