Compare commits
No commits in common. "main" and "0.2.1" have entirely different histories.
|
@ -1,3 +0,0 @@
|
|||
[submodule "docs/themes/hugo-book"]
|
||||
path = docs/themes/hugo-book
|
||||
url = https://github.com/alex-shpak/hugo-book
|
|
@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter)
|
||||
|
||||
* CLI command to add new users
|
||||
|
||||
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)
|
||||
|
||||
### Fixed
|
||||
|
|
4
Justfile
4
Justfile
|
@ -42,7 +42,8 @@ run:
|
|||
cargo run \
|
||||
--bin otter \
|
||||
-- serve \
|
||||
-c ./otter.toml
|
||||
-c ./otter.toml \
|
||||
--log debug
|
||||
|
||||
doc:
|
||||
cargo doc --workspace --frozen --open
|
||||
|
@ -58,4 +59,3 @@ publish-release-binaries tag: build-release-static
|
|||
--fail \
|
||||
--upload-file target/aarch64-unknown-linux-musl/release/otter \
|
||||
https://git.rustybever.be/api/packages/Chewing_Bever/generic/otter/"{{ tag }}"/otter-linux-arm64
|
||||
just docs/publish
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
public/
|
||||
*.lock
|
||||
*.tar.gz
|
||||
/resources/_gen/
|
||||
auth.txt
|
|
@ -1,20 +0,0 @@
|
|||
build:
|
||||
hugo build --minify
|
||||
|
||||
package: build
|
||||
cd public && \
|
||||
tar --create \
|
||||
--gzip \
|
||||
--file ../docs.tar.gz \
|
||||
*
|
||||
|
||||
publish: package
|
||||
curl \
|
||||
-XPOST \
|
||||
--fail \
|
||||
-H @./auth.txt \
|
||||
-T docs.tar.gz \
|
||||
https://rustybever.be/docs/otter
|
||||
|
||||
serve:
|
||||
hugo serve --buildDrafts
|
|
@ -1,5 +0,0 @@
|
|||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
|
@ -1,32 +0,0 @@
|
|||
# Otter
|
||||
|
||||
Otter is a standalone implementation of the [Gpodder.net
|
||||
API](https://gpoddernet.readthedocs.io/en/latest/). Its goal is to be a
|
||||
lightweight self-hostable alternative to [gpodder.net](https://gpodder.net) for
|
||||
synchronizing podcast subscriptions and episode states between compatible
|
||||
clients.
|
||||
|
||||
{{% columns %}}
|
||||
## Easy to install
|
||||
|
||||
Otter is distributed as a single statically compiled binary, allowing it to be
|
||||
used in any Linux-based context, be it as a Systemd service or in a Docker
|
||||
container.
|
||||
|
||||
<--->
|
||||
|
||||
## Simple to configure
|
||||
|
||||
Only a small amount of configuration is required to get Otter up and running,
|
||||
all of which can be done from a config file, environment variables, or CLI
|
||||
arguments.
|
||||
|
||||
<--->
|
||||
|
||||
## Multi-user
|
||||
|
||||
Otter supports multiple users and provides functionality for making your server
|
||||
either public or private, along with easy ways of inviting new users to your
|
||||
server. Host it for yourself, your friends, or start a public instance!
|
||||
|
||||
{{% /columns %}}
|
|
@ -1,36 +0,0 @@
|
|||
# Configuration
|
||||
|
||||
All configuration variables can be provided either through the configuration
|
||||
file, as environment variables or using CLI flags. All environment variable
|
||||
names are derived from their place in the TOML file, prepended with `OTTER_`.
|
||||
|
||||
Variables are grouped by their section in the configuration file, e.g. the
|
||||
variables under the `net` section should be placed inside the `[net]` group in
|
||||
the TOML file.
|
||||
|
||||
## Top-level settings
|
||||
|
||||
* `data_dir` (`OTTER_DATA_DIR`): directory where Otter stores its data. This
|
||||
directory must exist and be accessible by the server when starting up.
|
||||
* Default: `./data`
|
||||
* `session_cleanup_interval` (`OTTER_SESSION_CLEANUP_INTERVAL`): how frequently
|
||||
(in seconds) the session cleanup background job should be run. This job
|
||||
removes expired user sessions from the database.
|
||||
* Default: `86400` (once a day)
|
||||
* `log_level` (`OTTER_LOG_LEVEL`): how verbose the logging should be; one of
|
||||
`debug`, `info`, `warn` or `error`
|
||||
* Default: `warn`
|
||||
|
||||
## Network (`net`)
|
||||
|
||||
* `type` (`OTTER_NET_TYPE`): type of network connection to establish; one of
|
||||
`tcp`, `unix`
|
||||
* Default: `tcp`
|
||||
* `domain` (`OTTER_NET_DOMAIN`): domain to bind TCP socket to; only applicable
|
||||
when `net.type` is `tcp`.
|
||||
* Default: `127.0.0.1`
|
||||
* `port` (`OTTER_NET_PORT`): port to bind TCP socket to; only applicable when
|
||||
`net.type` is `tcp`.
|
||||
* Default: `8080`
|
||||
* `path` (`OTTER_NET_PATH`): path to bind Unix socket to; only applicable when
|
||||
`net.type` is `unix`.
|
|
@ -1,94 +0,0 @@
|
|||
baseURL = 'https://rustybever.be/docs/otter/'
|
||||
languageCode = 'en-us'
|
||||
title = 'Otter'
|
||||
theme = "hugo-book"
|
||||
|
||||
# Book configuration
|
||||
disablePathToLower = true
|
||||
enableGitInfo = true
|
||||
|
||||
[markup.goldmark.renderer]
|
||||
unsafe = true
|
||||
|
||||
[markup.tableOfContents]
|
||||
startLevel = 1
|
||||
|
||||
[menu]
|
||||
[[menu.after]]
|
||||
name = "Source"
|
||||
url = "https://git.rustybever.be/Chewing_Bever/otter"
|
||||
weight = 10
|
||||
|
||||
[[menu.after]]
|
||||
name = "Devlogs"
|
||||
url = "https://rustybever.be/dev/otter/"
|
||||
weight = 20
|
||||
|
||||
[params]
|
||||
# (Optional, default light) Sets color theme: light, dark or auto.
|
||||
# Theme 'auto' switches between dark and light modes based on browser/os preferences
|
||||
BookTheme = 'auto'
|
||||
|
||||
# (Optional, default true) Controls table of contents visibility on right side of pages.
|
||||
# Start and end levels can be controlled with markup.tableOfContents setting.
|
||||
# You can also specify this parameter per page in front matter.
|
||||
BookToC = true
|
||||
|
||||
# (Optional, default favicon.png) Set the path to a favicon file.
|
||||
# If the favicon is /static/favicon.png then the path would be favicon.png
|
||||
# BookFavicon = 'favicon.png'
|
||||
|
||||
# (Optional, default none) Set the path to a logo for the book.
|
||||
# If the logo is /static/logo.png then the path would be logo.png
|
||||
# BookLogo = 'logo.png'
|
||||
|
||||
# (Optional, default docs) Specify root page to render child pages as menu.
|
||||
# Page is resoled by .GetPage function: https://gohugo.io/functions/getpage/
|
||||
# For backward compatibility you can set '*' to render all sections to menu. Acts same as '/'
|
||||
BookSection = 'docs'
|
||||
|
||||
# Set source repository location.
|
||||
# Used for 'Last Modified' and 'Edit this page' links.
|
||||
BookRepo = 'https://github.com/alex-shpak/hugo-book'
|
||||
|
||||
# (Optional, default 'commit') Specifies commit portion of the link to the page's last modified
|
||||
# commit hash for 'doc' page type.
|
||||
# Requires 'BookRepo' param.
|
||||
# Value used to construct a URL consisting of BookRepo/BookCommitPath/<commit-hash>
|
||||
# Github uses 'commit', Bitbucket uses 'commits'
|
||||
# BookCommitPath = 'commit'
|
||||
|
||||
# Enable "Edit this page" links for 'doc' page type.
|
||||
# Disabled by default. Uncomment to enable. Requires 'BookRepo' param.
|
||||
# Edit path must point to root directory of repo.
|
||||
# BookEditPath = 'edit/main/exampleSite'
|
||||
|
||||
# Configure the date format used on the pages
|
||||
# - In git information
|
||||
# - In blog posts
|
||||
BookDateFormat = 'January 2, 2006'
|
||||
|
||||
# (Optional, default true) Enables search function with flexsearch,
|
||||
# Index is built on fly, therefore it might slowdown your website.
|
||||
# Configuration for indexing can be adjusted in i18n folder per language.
|
||||
BookSearch = true
|
||||
|
||||
# (Optional, default true) Enables comments template on pages
|
||||
# By default partals/docs/comments.html includes Disqus template
|
||||
# See https://gohugo.io/content-management/comments/#configure-disqus
|
||||
# Can be overwritten by same param in page frontmatter
|
||||
# BookComments = true
|
||||
|
||||
# /!\ This is an experimental feature, might be removed or changed at any time
|
||||
# (Optional, experimental, default false) Enables portable links and link checks in markdown pages.
|
||||
# Portable links meant to work with text editors and let you write markdown without {{< relref >}} shortcode
|
||||
# Theme will print warning if page referenced in markdown does not exists.
|
||||
# BookPortableLinks = true
|
||||
|
||||
# /!\ This is an experimental feature, might be removed or changed at any time
|
||||
# (Optional, experimental, default false) Enables service worker that caches visited pages and resources for offline use.
|
||||
# BookServiceWorker = true
|
||||
|
||||
# /!\ This is an experimental feature, might be removed or changed at any time
|
||||
# (Optional, experimental, default false) Enables a drop-down menu for translations only if a translation is present.
|
||||
# BookTranslatedOnly = false
|
|
@ -1 +0,0 @@
|
|||
Subproject commit f2c703e155881a017cabbee17224e2dfeee0498c
|
|
@ -5,7 +5,6 @@ pub struct User {
|
|||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
@ -72,14 +71,3 @@ pub struct Page {
|
|||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct UserFilter {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignupLink {
|
||||
pub id: i64,
|
||||
pub time_created: DateTime<Utc>,
|
||||
}
|
||||
|
|
|
@ -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 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
|
||||
/// explicitely into account
|
||||
pub struct AuthenticatedRepository<'a> {
|
||||
pub(crate) store: &'a (dyn GpodderStore + Send + Sync),
|
||||
pub(crate) user: &'a models::User,
|
||||
const MAX_SESSION_AGE: i64 = 60 * 60 * 24 * 7;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GpodderRepository {
|
||||
store: Arc<dyn GpodderStore + Send + Sync>,
|
||||
}
|
||||
|
||||
impl<'a> AuthenticatedRepository<'a> {
|
||||
/// Retrieve the given session from the database, if it exists and is visible to the user
|
||||
impl GpodderRepository {
|
||||
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> {
|
||||
let session = self
|
||||
.store
|
||||
.get_session(session_id)?
|
||||
.ok_or(AuthErr::UnknownSession)?;
|
||||
|
||||
// Users can't see sessions from other users, and expired sessions still in the database
|
||||
// are considered removed
|
||||
if session.user.id != self.user.id
|
||||
|| Utc::now() - session.last_seen > super::MAX_SESSION_AGE
|
||||
{
|
||||
// Expired sessions still in the database are considered removed
|
||||
if Utc::now() - session.last_seen > TimeDelta::new(MAX_SESSION_AGE, 0).unwrap() {
|
||||
Err(AuthErr::UnknownSession)
|
||||
} else {
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a paginated list of the user's sessions
|
||||
pub fn paginated_sessions(&self, page: models::Page) -> Result<Vec<models::Session>, AuthErr> {
|
||||
self.store.paginated_sessions(self.user, page)
|
||||
pub fn paginated_sessions(
|
||||
&self,
|
||||
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 create_session(&self, user_agent: Option<String>) -> Result<models::Session, AuthErr> {
|
||||
pub fn get_user(&self, username: &str) -> Result<models::User, AuthErr> {
|
||||
self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)
|
||||
}
|
||||
|
||||
pub fn create_user(&self, username: &str, password: &str) -> Result<models::User, AuthErr> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
self.store.insert_user(username, &password_hash)
|
||||
}
|
||||
|
||||
pub fn validate_credentials(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<models::User, AuthErr> {
|
||||
let user = self.store.get_user(username)?.ok_or(AuthErr::UnknownUser)?;
|
||||
|
||||
let password_hash = PasswordHash::new(&user.password_hash).unwrap();
|
||||
|
||||
if Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.is_ok()
|
||||
{
|
||||
Ok(user)
|
||||
} else {
|
||||
Err(AuthErr::InvalidPassword)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
&self,
|
||||
user: &models::User,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<models::Session, AuthErr> {
|
||||
let session = models::Session {
|
||||
id: rand::thread_rng().r#gen(),
|
||||
last_seen: Utc::now(),
|
||||
user: self.user.clone(),
|
||||
user: user.clone(),
|
||||
user_agent,
|
||||
};
|
||||
|
||||
|
@ -50,38 +96,38 @@ impl<'a> AuthenticatedRepository<'a> {
|
|||
Ok(session)
|
||||
}
|
||||
|
||||
/// Set the session's last seen value to the current time
|
||||
pub fn refresh_session(&self, session: &models::Session) -> Result<(), AuthErr> {
|
||||
let now = Utc::now();
|
||||
|
||||
self.store.refresh_session(session, now)
|
||||
}
|
||||
|
||||
/// Remove the given session, if it belongs to the authenticated user
|
||||
pub fn remove_session(&self, session_id: i64) -> Result<(), AuthErr> {
|
||||
// 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)
|
||||
self.store.remove_session(session_id)
|
||||
}
|
||||
|
||||
/// Return the devices for the authenticated user
|
||||
pub fn devices(&self) -> Result<Vec<models::Device>, AuthErr> {
|
||||
self.store.devices_for_user(self.user)
|
||||
pub fn remove_old_sessions(&self) -> Result<usize, AuthErr> {
|
||||
let min_last_seen = Utc::now() - TimeDelta::seconds(MAX_SESSION_AGE);
|
||||
|
||||
self.store.remove_old_sessions(min_last_seen)
|
||||
}
|
||||
|
||||
pub fn devices_for_user(&self, user: &models::User) -> Result<Vec<models::Device>, AuthErr> {
|
||||
self.store.devices_for_user(user)
|
||||
}
|
||||
|
||||
/// Update the metadata of a device
|
||||
pub fn update_device_info(
|
||||
&self,
|
||||
user: &models::User,
|
||||
device_id: &str,
|
||||
patch: models::DevicePatch,
|
||||
) -> Result<(), AuthErr> {
|
||||
self.store.update_device_info(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(
|
||||
&self,
|
||||
user: &models::User,
|
||||
sync: Vec<Vec<&str>>,
|
||||
unsync: Vec<&str>,
|
||||
) -> Result<(), AuthErr> {
|
||||
|
@ -100,72 +146,71 @@ impl<'a> AuthenticatedRepository<'a> {
|
|||
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)?;
|
||||
}
|
||||
|
||||
// Finally we unsync the remaining devices
|
||||
self.store
|
||||
.remove_from_sync_group(self.user, unsync.into_iter().collect())?;
|
||||
.remove_from_sync_group(user, unsync.into_iter().collect())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the user's devices, grouped per sync group
|
||||
pub fn devices_by_sync_group(&self) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr> {
|
||||
self.store.devices_by_sync_group(self.user)
|
||||
pub fn devices_by_sync_group(
|
||||
&self,
|
||||
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(
|
||||
&self,
|
||||
user: &models::User,
|
||||
device_id: &str,
|
||||
) -> 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(&self) -> Result<Vec<models::Subscription>, AuthErr> {
|
||||
self.store.subscriptions_for_user(self.user)
|
||||
pub fn subscriptions_for_user(
|
||||
&self,
|
||||
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(
|
||||
&self,
|
||||
user: &models::User,
|
||||
device_id: &str,
|
||||
urls: Vec<String>,
|
||||
) -> Result<DateTime<Utc>, AuthErr> {
|
||||
let time_changed = Utc::now();
|
||||
|
||||
self.store
|
||||
.set_subscriptions_for_device(self.user, device_id, urls, time_changed)?;
|
||||
.set_subscriptions_for_device(user, device_id, urls, time_changed)?;
|
||||
|
||||
Ok(time_changed + TimeDelta::seconds(1))
|
||||
}
|
||||
|
||||
/// Add and remove subscriptions to and from a given device.
|
||||
pub fn update_subscriptions_for_device(
|
||||
&self,
|
||||
user: &models::User,
|
||||
device_id: &str,
|
||||
add: Vec<String>,
|
||||
remove: Vec<String>,
|
||||
) -> Result<DateTime<Utc>, AuthErr> {
|
||||
let time_changed = Utc::now();
|
||||
|
||||
self.store.update_subscriptions_for_device(
|
||||
self.user,
|
||||
device_id,
|
||||
add,
|
||||
remove,
|
||||
time_changed,
|
||||
)?;
|
||||
self.store
|
||||
.update_subscriptions_for_device(user, device_id, add, remove, time_changed)?;
|
||||
|
||||
Ok(time_changed + TimeDelta::seconds(1))
|
||||
}
|
||||
|
||||
/// Get the changes in subscriptions for a given device after a given timestamp.
|
||||
pub fn subscription_updates_for_device(
|
||||
&self,
|
||||
user: &models::User,
|
||||
device_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<
|
||||
|
@ -180,7 +225,7 @@ impl<'a> AuthenticatedRepository<'a> {
|
|||
|
||||
let (added, removed) = self
|
||||
.store
|
||||
.subscription_updates_for_device(self.user, device_id, since)?;
|
||||
.subscription_updates_for_device(user, device_id, since)?;
|
||||
|
||||
let max_time_changed = added
|
||||
.iter()
|
||||
|
@ -191,22 +236,22 @@ impl<'a> AuthenticatedRepository<'a> {
|
|||
Ok((max_time_changed + TimeDelta::seconds(1), added, removed))
|
||||
}
|
||||
|
||||
/// Add episode actions to the database
|
||||
pub fn add_episode_actions(
|
||||
&self,
|
||||
user: &models::User,
|
||||
actions: Vec<models::EpisodeAction>,
|
||||
) -> Result<DateTime<Utc>, AuthErr> {
|
||||
let time_changed = Utc::now();
|
||||
|
||||
self.store
|
||||
.add_episode_actions(self.user, actions, time_changed)?;
|
||||
.add_episode_actions(user, actions, time_changed)?;
|
||||
|
||||
Ok(time_changed + TimeDelta::seconds(1))
|
||||
}
|
||||
|
||||
/// Get episode actions for the currently authenticated user
|
||||
pub fn episode_actions_for_user(
|
||||
&self,
|
||||
user: &models::User,
|
||||
since: Option<DateTime<Utc>>,
|
||||
podcast: Option<String>,
|
||||
device: Option<String>,
|
||||
|
@ -215,7 +260,7 @@ impl<'a> AuthenticatedRepository<'a> {
|
|||
let now = chrono::Utc::now();
|
||||
let actions = self
|
||||
.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);
|
||||
|
||||
Ok((max_time_changed + TimeDelta::seconds(1), actions))
|
|
@ -1,37 +0,0 @@
|
|||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
|
||||
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,
|
||||
filter: &models::UserFilter,
|
||||
) -> Result<Vec<models::User>, AuthErr> {
|
||||
self.store.paginated_users(page, filter)
|
||||
}
|
||||
|
||||
/// Generate a new unique signup link ID
|
||||
pub fn generate_signup_link(&self) -> Result<models::SignupLink, AuthErr> {
|
||||
let link = models::SignupLink {
|
||||
id: rand::thread_rng().r#gen(),
|
||||
time_created: Utc::now(),
|
||||
};
|
||||
|
||||
self.store.insert_signup_link(&link)?;
|
||||
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Remove the signup link with the given ID, if it exists
|
||||
pub fn remove_signup_link(&self, id: i64) -> Result<bool, AuthErr> {
|
||||
self.store.remove_signup_link(id)
|
||||
}
|
||||
}
|
|
@ -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: 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,
|
||||
UnknownUser,
|
||||
InvalidPassword,
|
||||
NotAnAdmin,
|
||||
Other(Box<dyn std::error::Error + Sync + Send>),
|
||||
}
|
||||
|
||||
|
@ -18,7 +17,6 @@ impl Display for AuthErr {
|
|||
Self::UnknownUser => write!(f, "unknown user"),
|
||||
Self::UnknownSession => write!(f, "unknown session"),
|
||||
Self::InvalidPassword => write!(f, "invalid password"),
|
||||
Self::NotAnAdmin => write!(f, "not an admin"),
|
||||
Self::Other(err) => err.fmt(f),
|
||||
}
|
||||
}
|
||||
|
@ -64,38 +62,6 @@ pub trait GpodderAuthStore {
|
|||
|
||||
/// Remove any sessions whose last_seen timestamp is before the given minimum value
|
||||
fn remove_old_sessions(&self, min_last_seen: DateTime<Utc>) -> Result<usize, AuthErr>;
|
||||
|
||||
/// Return the given page of users, ordered by username
|
||||
fn paginated_users(&self, page: Page, filter: &UserFilter) -> Result<Vec<User>, AuthErr>;
|
||||
|
||||
/// Insert the signup link into the database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If a database failure occurs
|
||||
fn insert_signup_link(&self, link: &SignupLink) -> Result<(), AuthErr>;
|
||||
|
||||
/// Get the signup link associated with the given ID
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Some(link) if the ID corresponds to an existing signup link; None otherwise
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If a database failure occurs
|
||||
fn get_signup_link(&self, id: i64) -> Result<Option<SignupLink>, AuthErr>;
|
||||
|
||||
/// Remove the signup link with the given ID.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// True if the ID existed in the database; false otherwise
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If a database failure occurs
|
||||
fn remove_signup_link(&self, id: i64) -> Result<bool, AuthErr>;
|
||||
}
|
||||
|
||||
pub trait GpodderDeviceStore {
|
||||
|
|
|
@ -15,15 +15,8 @@ tracing = { workspace = true }
|
|||
chrono = { workspace = true, features = ["serde"] }
|
||||
|
||||
libsqlite3-sys = { version = "0.31.0", features = ["bundled"] }
|
||||
diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] }
|
||||
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
|
||||
|
||||
[dependencies.diesel]
|
||||
version = "2.2.7"
|
||||
features = [
|
||||
"r2d2",
|
||||
"sqlite",
|
||||
"returning_clauses_for_sqlite_3_35",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5.1"
|
||||
|
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
drop table signup_links;
|
|
@ -1,5 +0,0 @@
|
|||
-- Your SQL goes here
|
||||
create table signup_links (
|
||||
id bigint primary key not null,
|
||||
created_at bigint not null
|
||||
);
|
|
@ -2,6 +2,5 @@ pub mod device;
|
|||
pub mod device_subscription;
|
||||
pub mod episode_action;
|
||||
pub mod session;
|
||||
pub mod signup_link;
|
||||
pub mod sync_group;
|
||||
pub mod user;
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
use diesel::prelude::*;
|
||||
|
||||
use crate::schema::*;
|
||||
|
||||
#[derive(Clone, Queryable, Selectable, Insertable)]
|
||||
#[diesel(table_name = signup_links)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct SignupLink {
|
||||
pub id: i64,
|
||||
pub created_at: i64,
|
||||
}
|
|
@ -9,7 +9,6 @@ pub struct User {
|
|||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
|
|
|
@ -7,7 +7,6 @@ use crate::{
|
|||
DbError,
|
||||
models::{
|
||||
session::Session,
|
||||
signup_link::SignupLink,
|
||||
user::{NewUser, User},
|
||||
},
|
||||
schema::*,
|
||||
|
@ -19,16 +18,6 @@ impl From<User> for gpodder::User {
|
|||
id: value.id,
|
||||
username: value.username,
|
||||
password_hash: value.password_hash,
|
||||
admin: value.admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SignupLink> for gpodder::SignupLink {
|
||||
fn from(value: SignupLink) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
time_created: DateTime::from_timestamp(value.created_at, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -152,67 +141,4 @@ impl gpodder::GpodderAuthStore for SqliteRepository {
|
|||
})()
|
||||
.map_err(AuthErr::from)
|
||||
}
|
||||
|
||||
fn paginated_users(
|
||||
&self,
|
||||
page: gpodder::Page,
|
||||
filter: &gpodder::UserFilter,
|
||||
) -> Result<Vec<gpodder::User>, AuthErr> {
|
||||
(|| {
|
||||
let mut query = users::table
|
||||
.select(User::as_select())
|
||||
.order(users::username.asc())
|
||||
.offset((page.page * page.per_page) as i64)
|
||||
.limit(page.per_page as i64)
|
||||
.into_boxed();
|
||||
|
||||
if let Some(username) = &filter.username {
|
||||
// Case insensitive by default for SQLite
|
||||
query = query.filter(users::username.like(format!("%{username}%")));
|
||||
}
|
||||
|
||||
Ok::<_, DbError>(
|
||||
query
|
||||
.load_iter(&mut self.pool.get()?)?
|
||||
.map(|res| res.map(gpodder::User::from))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
)
|
||||
})()
|
||||
.map_err(AuthErr::from)
|
||||
}
|
||||
|
||||
fn get_signup_link(&self, id: i64) -> Result<Option<gpodder::SignupLink>, AuthErr> {
|
||||
match signup_links::table
|
||||
.find(id)
|
||||
.select(SignupLink::as_select())
|
||||
.first(&mut self.pool.get().map_err(DbError::from)?)
|
||||
.optional()
|
||||
{
|
||||
Ok(Some(link)) => Ok(Some(gpodder::SignupLink::from(link))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(err) => Err(DbError::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_signup_link(&self, link: &gpodder::SignupLink) -> Result<(), AuthErr> {
|
||||
diesel::insert_into(signup_links::table)
|
||||
.values(SignupLink {
|
||||
id: link.id,
|
||||
created_at: link.time_created.timestamp(),
|
||||
})
|
||||
.execute(&mut self.pool.get().map_err(DbError::from)?)
|
||||
.map_err(DbError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_signup_link(&self, id: i64) -> Result<bool, AuthErr> {
|
||||
match diesel::delete(signup_links::table.filter(signup_links::id.eq(id)))
|
||||
.execute(&mut self.pool.get().map_err(DbError::from)?)
|
||||
{
|
||||
Ok(0) => Ok(false),
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) => Err(DbError::from(err).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,13 +47,6 @@ diesel::table! {
|
|||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
signup_links (id) {
|
||||
id -> BigInt,
|
||||
created_at -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
sync_groups (id) {
|
||||
id -> BigInt,
|
||||
|
@ -65,7 +58,6 @@ diesel::table! {
|
|||
id -> BigInt,
|
||||
username -> Text,
|
||||
password_hash -> Text,
|
||||
admin -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,7 +73,6 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||
devices,
|
||||
episode_actions,
|
||||
sessions,
|
||||
signup_links,
|
||||
sync_groups,
|
||||
users,
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
data_dir = "./data"
|
||||
log_level = "debug"
|
||||
|
||||
[net]
|
||||
type = "tcp"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use clap::{Args, Subcommand};
|
||||
use clap::Subcommand;
|
||||
|
||||
use super::CliError;
|
||||
|
||||
|
@ -11,14 +11,6 @@ pub enum Command {
|
|||
},
|
||||
/// List the devices for the given user
|
||||
Devices { username: String },
|
||||
|
||||
#[command(subcommand)]
|
||||
User(UserCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum UserCommand {
|
||||
Add { username: String, password: String },
|
||||
}
|
||||
|
||||
impl Command {
|
||||
|
@ -31,34 +23,20 @@ impl Command {
|
|||
match self {
|
||||
Self::Sync { username, devices } => {
|
||||
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::new(),
|
||||
)?;
|
||||
}
|
||||
Self::Devices { username } => {
|
||||
let user = store.get_user(username)?;
|
||||
let devices = store.user(&user).devices()?;
|
||||
let devices = store.devices_for_user(&user)?;
|
||||
|
||||
for device in devices {
|
||||
println!("{} ({} subscriptions)", device.id, device.subscriptions);
|
||||
}
|
||||
}
|
||||
Self::User(user) => {
|
||||
user.run(&store)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserCommand {
|
||||
pub fn run(&self, store: &gpodder::GpodderRepository) -> Result<(), CliError> {
|
||||
match self {
|
||||
Self::Add { username, password } => {
|
||||
store.create_user(username, password)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -66,7 +66,7 @@ async fn post_login(
|
|||
.validate_credentials(auth.username(), auth.password())?;
|
||||
|
||||
let user_agent = user_agent.map(|header| header.to_string());
|
||||
let session = ctx.store.user(&user).create_session(user_agent)?;
|
||||
let session = ctx.store.create_session(&user, user_agent)?;
|
||||
|
||||
Ok::<_, AuthErr>(session)
|
||||
})
|
||||
|
|
|
@ -39,7 +39,7 @@ async fn get_devices(
|
|||
}
|
||||
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || ctx.store.user(&user).devices())
|
||||
tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user))
|
||||
.await
|
||||
.unwrap()
|
||||
.map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?,
|
||||
|
@ -56,11 +56,9 @@ async fn post_device(
|
|||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ctx.store.user(&user).update_device_info(&id, patch.into())
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into()))
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -46,8 +46,7 @@ async fn post_episode_actions(
|
|||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.store
|
||||
.user(&user)
|
||||
.add_episode_actions(actions.into_iter().map(Into::into).collect())
|
||||
.add_episode_actions(&user, actions.into_iter().map(Into::into).collect())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
@ -91,7 +90,8 @@ async fn get_episode_actions(
|
|||
let since = filter.since.and_then(|ts| DateTime::from_timestamp(ts, 0));
|
||||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.store.user(&user).episode_actions_for_user(
|
||||
ctx.store.episode_actions_for_user(
|
||||
&user,
|
||||
since,
|
||||
filter.podcast,
|
||||
filter.device,
|
||||
|
|
|
@ -44,8 +44,7 @@ pub async fn post_subscription_changes(
|
|||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.store
|
||||
.user(&user)
|
||||
.update_subscriptions_for_device(&id, delta.add, delta.remove)
|
||||
.update_subscriptions_for_device(&user, &id, delta.add, delta.remove)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
@ -80,9 +79,7 @@ pub async fn get_subscription_changes(
|
|||
let since = chrono::DateTime::from_timestamp(query.since, 0).ok_or(AppError::BadRequest)?;
|
||||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.store
|
||||
.user(&user)
|
||||
.subscription_updates_for_device(&id, since)
|
||||
ctx.store.subscription_updates_for_device(&user, &id, since)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
|
|
@ -41,7 +41,7 @@ pub async fn get_sync_status(
|
|||
}
|
||||
|
||||
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
|
||||
.unwrap()
|
||||
.map(|(not_synchronized, synchronized)| {
|
||||
|
@ -68,7 +68,8 @@ pub async fn post_sync_status_changes(
|
|||
}
|
||||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.store.user(&user).update_device_sync_status(
|
||||
ctx.store.update_device_sync_status(
|
||||
&user,
|
||||
delta
|
||||
.synchronize
|
||||
.iter()
|
||||
|
@ -77,7 +78,7 @@ pub async fn post_sync_status_changes(
|
|||
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
|
||||
.unwrap()
|
||||
|
|
|
@ -123,8 +123,7 @@ impl From<gpodder::AuthErr> for AppError {
|
|||
match value {
|
||||
gpodder::AuthErr::UnknownUser
|
||||
| gpodder::AuthErr::UnknownSession
|
||||
| gpodder::AuthErr::InvalidPassword
|
||||
| gpodder::AuthErr::NotAnAdmin => Self::Unauthorized,
|
||||
| gpodder::AuthErr::InvalidPassword => Self::Unauthorized,
|
||||
gpodder::AuthErr::Other(err) => Self::Other(err),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ pub async fn get_device_subscriptions(
|
|||
}
|
||||
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions_for_device(&id))
|
||||
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_device(&user, &id))
|
||||
.await
|
||||
.unwrap()
|
||||
.map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?,
|
||||
|
@ -51,7 +51,7 @@ pub async fn get_user_subscriptions(
|
|||
}
|
||||
|
||||
Ok(
|
||||
tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions())
|
||||
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_user(&user))
|
||||
.await
|
||||
.unwrap()
|
||||
.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 || {
|
||||
ctx.store
|
||||
.user(&user)
|
||||
.set_subscriptions_for_device(&id, urls)
|
||||
ctx.store.set_subscriptions_for_device(&user, &id, urls)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
mod sessions;
|
||||
mod users;
|
||||
|
||||
use axum::{
|
||||
Form, RequestExt, Router,
|
||||
|
@ -77,7 +76,6 @@ pub fn router(ctx: Context) -> Router<Context> {
|
|||
.route("/login", get(get_login).post(post_login))
|
||||
.route("/logout", post(post_logout))
|
||||
.merge(sessions::router(ctx.clone()))
|
||||
.merge(users::router(ctx.clone()))
|
||||
}
|
||||
|
||||
async fn get_index(
|
||||
|
@ -127,7 +125,7 @@ async fn post_login(
|
|||
.validate_credentials(&login.username, &login.password)?;
|
||||
|
||||
let user_agent = user_agent.map(|header| header.to_string());
|
||||
let session = ctx.store.user(&user).create_session(user_agent)?;
|
||||
let session = ctx.store.create_session(&user, user_agent)?;
|
||||
|
||||
Ok::<_, AuthErr>(session)
|
||||
})
|
||||
|
|
|
@ -31,9 +31,7 @@ pub async fn get_sessions(
|
|||
) -> AppResult<TemplateResponse<Page<View>>> {
|
||||
let next_page = page.next_page();
|
||||
let sessions = tokio::task::spawn_blocking(move || {
|
||||
ctx.store
|
||||
.user(&session.user)
|
||||
.paginated_sessions(page.into())
|
||||
ctx.store.paginated_sessions(&session.user, page.into())
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
use axum::{
|
||||
Extension, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
routing::{delete, get},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct UserFilter {
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
impl From<UserFilter> for gpodder::UserFilter {
|
||||
fn from(value: UserFilter) -> Self {
|
||||
Self {
|
||||
username: value.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToQuery for UserFilter {
|
||||
fn to_query(self) -> crate::web::Query {
|
||||
crate::web::Query::default().opt_parameter("username", self.username)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_users(
|
||||
State(ctx): State<Context>,
|
||||
headers: HeaderMap,
|
||||
Extension(session): Extension<gpodder::Session>,
|
||||
Query(page): Query<super::Pagination>,
|
||||
Query(filter): Query<UserFilter>,
|
||||
) -> AppResult<TemplateResponse<Page<View>>> {
|
||||
let next_page = page.next_page();
|
||||
let filter_clone = filter.clone();
|
||||
|
||||
let user_id = session.user.id;
|
||||
let users = tokio::task::spawn_blocking(move || {
|
||||
ctx.store
|
||||
.admin(&session.user)?
|
||||
.paginated_users(page.into(), &filter.into())
|
||||
})
|
||||
.await
|
||||
.unwrap()?;
|
||||
|
||||
let next_page_query = (users.len() == next_page.per_page as usize)
|
||||
.then_some(next_page.to_query().join(filter_clone));
|
||||
|
||||
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(),
|
||||
include_str!("templates/views/sessions.html"),
|
||||
),
|
||||
(
|
||||
View::Users(Vec::new(), 0, None).template(),
|
||||
include_str!("templates/views/users.html"),
|
||||
),
|
||||
])?;
|
||||
|
||||
Ok(tera)
|
||||
|
|
|
@ -23,15 +23,6 @@ impl Query {
|
|||
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience method for adding possibly empty parameter values from options
|
||||
pub fn opt_parameter(self, key: impl ToString, value: Option<impl ToString>) -> Self {
|
||||
if let Some(value) = value {
|
||||
self.parameter(key, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows objects to be converted into queries
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
<h1>Users</h1>
|
||||
|
||||
<input
|
||||
type="text" id="username" name="username"
|
||||
hx-get="/users"
|
||||
hx-target="#users > tbody"
|
||||
hx-swap="innerHTML"
|
||||
hx-select="table > tbody > tr"
|
||||
hx-trigger="input changed delay:500ms"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<table id="users">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for user in users %}
|
||||
<tr>
|
||||
<th>{{ user.username }}</th>
|
||||
</tr>
|
||||
{% 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,
|
||||
Login,
|
||||
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
|
||||
Users(Vec<gpodder::User>, i64, Option<Query>),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
@ -17,19 +16,12 @@ struct Session {
|
|||
last_seen: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct User {
|
||||
id: i64,
|
||||
username: String,
|
||||
}
|
||||
|
||||
impl Template for View {
|
||||
fn template(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Index => "views/index.html",
|
||||
Self::Login => "views/login.html",
|
||||
Self::Sessions(..) => "views/sessions.html",
|
||||
Self::Users(..) => "views/users.html",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,18 +41,6 @@ impl Template for View {
|
|||
ctx.insert("next_page_query", &query.encode());
|
||||
}
|
||||
}
|
||||
Self::Users(users, current_user_id, query) => {
|
||||
ctx.insert(
|
||||
"users",
|
||||
&users.into_iter().map(User::from).collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
ctx.insert("current_user_id", ¤t_user_id);
|
||||
|
||||
if let Some(query) = query {
|
||||
ctx.insert("next_page_query", &query.encode());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
|
@ -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