Compare commits

..

No commits in common. "main" and "0.2.1" have entirely different histories.
main ... 0.2.1

41 changed files with 131 additions and 781 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "docs/themes/hugo-book"]
path = docs/themes/hugo-book
url = https://github.com/alex-shpak/hugo-book

View File

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

View File

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

5
docs/.gitignore vendored
View File

@ -1,5 +0,0 @@
public/
*.lock
*.tar.gz
/resources/_gen/
auth.txt

View File

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

View File

@ -1,5 +0,0 @@
+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
-- This file should undo anything in `up.sql`
drop table signup_links;

View File

@ -1,5 +0,0 @@
-- Your SQL goes here
create table signup_links (
id bigint primary key not null,
created_at bigint not null
);

View File

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

View File

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

View File

@ -9,7 +9,6 @@ pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub admin: bool,
}
#[derive(Insertable)]

View File

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

View File

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

View File

@ -1,5 +1,4 @@
data_dir = "./data"
log_level = "debug"
[net]
type = "tcp"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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