Compare commits

...

40 Commits
0.1.0 ... main

Author SHA1 Message Date
Jef Roosens 09d782b6a5
feat(otter): add link to users page in navbar for admins 2025-08-29 15:03:05 +02:00
Jef Roosens 332c05491a
feat(otter): show admin status in users view 2025-08-29 14:38:59 +02:00
Jef Roosens 5017bd1c5f
feat(otter): add working user removal route 2025-08-29 14:18:33 +02:00
Jef Roosens b946e1ce98
feat(otter): cli command to toggle admin status 2025-08-29 14:02:26 +02:00
Jef Roosens ee9db5ae36
chore: bump versions to 0.3.0 2025-08-28 14:48:22 +02:00
Jef Roosens a296d0fafe
docs: add `allow_public_signups` variable 2025-08-28 14:41:32 +02:00
Jef Roosens 4d44216e17
feat(cli): add command to generate signup links 2025-08-28 14:29:00 +02:00
Jef Roosens 722317603d
feat(server): add routes for private sign-up links 2025-08-28 14:28:36 +02:00
Jef Roosens 69e84b4266
feat(server): add config for enabling public signup view 2025-08-28 13:46:43 +02:00
Jef Roosens 5017bfb710
feat(server): add error feedback to signup form 2025-08-28 13:27:09 +02:00
Jef Roosens 4902f4d1fe
feat(server): implement signup POST request and automatic sign-in 2025-08-28 13:09:24 +02:00
Jef Roosens 89f8b08b5e
feat: add signup GET route 2025-08-28 12:58:52 +02:00
Jef Roosens 97b30b1840
refactor: split web auth routes 2025-07-17 10:55:47 +02:00
Jef Roosens 5cd1f4f736
feat(gpodder): add signup link admin methods 2025-07-02 10:58:02 +02:00
Jef Roosens c48d2a78ca
feat(gpodder_sqlite): add signup links table 2025-07-02 10:23:22 +02:00
Jef Roosens 2514aa8413
feat(docs): start configuration page 2025-06-29 14:25:36 +02:00
Jef Roosens 6c8183c1e3
feat(web): add users active search 2025-06-29 11:09:54 +02:00
Jef Roosens fce301080c
feat(gpodder): add user filter for paginated users method 2025-06-29 11:07:45 +02:00
Jef Roosens c7c5cf889c
feat(otter): add command to create new users 2025-06-24 14:16:58 +02:00
Jef Roosens 30609b1cef
feat(web): add users page 2025-06-24 13:49:37 +02:00
Jef Roosens 4854c84601
feat(gpodder): add admin paginated users method 2025-06-24 13:38:12 +02:00
Jef Roosens 2524eb5807
refactor(gpodder): split repository for admin view 2025-06-24 13:30:17 +02:00
Jef Roosens 669aa475ca
feat(gpodder_sqlite): add user admin field 2025-06-24 13:08:44 +02:00
Jef Roosens 346c27fc3f
refactor(gpodder): add authenticated view of repository 2025-06-20 10:43:46 +02:00
Jef Roosens 0e91eef0e8
docs: write small homepage 2025-06-20 10:03:49 +02:00
Jef Roosens 4735bc3f13
chore: add docs publish command 2025-06-19 14:53:40 +02:00
Jef Roosens 5e653407f2
chore(docs): set up initial Hugo scaffolding 2025-06-18 12:17:49 +02:00
Jef Roosens dd418c872a
fix(server): serve Content-Type headers with static files 2025-06-17 15:02:46 +02:00
Jef Roosens 32a4a88548
chore: bump versions 2025-06-17 14:07:24 +02:00
Jef Roosens b16c9a0404
fix(gpodder_sqlite): correct imports in tests 2025-06-17 14:01:11 +02:00
Jef Roosens 7887477ed1
feat(web): don't show remove button for current session 2025-06-17 13:51:13 +02:00
Jef Roosens 21b3450aeb
feat(server): add working remove buttons to session page 2025-06-17 13:33:16 +02:00
Jef Roosens a57e301d16
feat(server): implement infinite scroll table for sessions page
A query type is introduced along with the ToQuery trait to convert types
into queries. A query can then be properly formatted as a URL query
parameter string, allowing us to pass arbitrary safely typed query
parameters to the Tera templates. This is then used by HTMX to request
the next page of content once the last row of a table is visible.
2025-06-17 11:09:18 +02:00
Jef Roosens 68b2b1beb4
chore: format code 2025-06-17 09:53:50 +02:00
Jef Roosens e8e0c94937
feat(server): partial implementation of session page pagination 2025-06-17 09:52:47 +02:00
Jef Roosens 32d70daab2
feat(otter): added sessions page template 2025-06-15 15:30:02 +02:00
Jef Roosens 7de4897364
refactor(gpodder): rename store trait 2025-06-15 14:31:36 +02:00
Jef Roosens fc46c4874a
fix(web): refresh navbar on login and logout 2025-06-08 12:50:23 +02:00
Jef Roosens 957387bed7
feat(web): add logout button 2025-06-07 10:20:49 +02:00
Jef Roosens b04955d70e
chore: update changelog 2025-06-06 13:53:18 +02:00
64 changed files with 1694 additions and 325 deletions

3
.gitmodules vendored 100644
View File

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

View File

@ -9,6 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* Ability for an account to be an admin
* CLI command to toggle admin status of users
* Admin user management page
## [0.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0)
### Added
* Public sign-up page (disabled by default)
* Private sign-up links
* New CLI commands
* Add users
* Generate signup links
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)
### Fixed
* Serve Content-Type headers for static embedded files
## [0.2.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.0)
### Added
* Web UI
* Started development based on HTMX and PicoCSS
* Very simple homepage
* Login/logout button
* Page for managing logged-in sessions
## [0.1.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.1.0)
### Added
* Implemented bare API features
* auth
* subscriptions

7
Cargo.lock generated
View File

@ -821,7 +821,7 @@ dependencies = [
[[package]]
name = "gpodder"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"argon2",
"chrono",
@ -830,7 +830,7 @@ dependencies = [
[[package]]
name = "gpodder_sqlite"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"chrono",
"criterion",
@ -1246,7 +1246,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "otter"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"axum",
"axum-extra",
@ -1260,6 +1260,7 @@ dependencies = [
"http-body-util",
"rand",
"serde",
"serde_urlencoded",
"tera",
"tokio",
"tower-http",

View File

@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.3.0"
edition = "2024"
[workspace.dependencies]

View File

@ -42,11 +42,10 @@ run:
cargo run \
--bin otter \
-- serve \
-c ./otter.toml \
--log debug
-c ./otter.toml
doc:
cargo doc --workspace --frozen
cargo doc --workspace --frozen --open
publish-release-binaries tag: build-release-static
curl \
@ -59,3 +58,4 @@ 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 100644
View File

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

20
docs/Justfile 100644
View File

@ -0,0 +1,20 @@
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

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

View File

@ -0,0 +1,32 @@
# 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

@ -0,0 +1,40 @@
# 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`
* `allow_public_signups` (`OTTER_ALLOW_PUBLIC_SIGNUPS`): whether public signups
are allowed. This enables the `/signup` route and adds a notice to sign up at
the bottom of the login page.
* Default: `false`
## 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`.

94
docs/hugo.toml 100644
View File

@ -0,0 +1,94 @@
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
docs/themes/hugo-book vendored 160000

@ -0,0 +1 @@
Subproject commit f2c703e155881a017cabbee17224e2dfeee0498c

View File

@ -5,5 +5,6 @@ mod store;
pub use models::*;
pub use repository::GpodderRepository;
pub use store::{
AuthErr, AuthStore, DeviceRepository, EpisodeActionRepository, Store, SubscriptionRepository,
AuthErr, GpodderAuthStore, GpodderDeviceStore, GpodderEpisodeActionStore, GpodderStore,
GpodderSubscriptionStore,
};

View File

@ -5,6 +5,7 @@ pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub admin: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -71,3 +72,14 @@ 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

@ -0,0 +1,21 @@
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)
}
pub fn remove_user(&self, id: i64) -> Result<bool, AuthErr> {
self.store.remove_user(id)
}
}

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 rand::{Rng, rngs::OsRng};
use rand::Rng;
use crate::{
models,
store::{AuthErr, Store},
};
use crate::{AuthErr, GpodderStore, models};
const MAX_SESSION_AGE: i64 = 60 * 60 * 24 * 7;
#[derive(Clone)]
pub struct GpodderRepository {
store: Arc<dyn Store + Send + Sync>,
/// 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,
}
impl GpodderRepository {
pub fn new(store: impl Store + 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> {
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 > TimeDelta::new(MAX_SESSION_AGE, 0).unwrap() {
// 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
{
Err(AuthErr::UnknownSession)
} else {
Ok(session)
}
}
pub fn paginated_sessions(
&self,
user: &models::User,
page: models::Page,
) -> Result<Vec<models::Session>, AuthErr> {
self.store.paginated_sessions(user, page)
/// 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 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> {
/// Create a new session for the authenticated user
pub fn create_session(&self, user_agent: Option<String>) -> Result<models::Session, AuthErr> {
let session = models::Session {
id: rand::thread_rng().r#gen(),
last_seen: Utc::now(),
user: user.clone(),
user: self.user.clone(),
user_agent,
};
@ -96,38 +50,38 @@ impl GpodderRepository {
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> {
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> {
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)
/// Return the devices for the authenticated user
pub fn devices(&self) -> Result<Vec<models::Device>, AuthErr> {
self.store.devices_for_user(self.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(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(
&self,
user: &models::User,
sync: Vec<Vec<&str>>,
unsync: Vec<&str>,
) -> Result<(), AuthErr> {
@ -146,71 +100,72 @@ impl GpodderRepository {
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)?;
}
// Finally we unsync the remaining devices
self.store
.remove_from_sync_group(user, unsync.into_iter().collect())?;
.remove_from_sync_group(self.user, unsync.into_iter().collect())?;
Ok(())
}
pub fn devices_by_sync_group(
&self,
user: &models::User,
) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr> {
self.store.devices_by_sync_group(user)
/// 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)
}
/// 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(user, device_id)
self.store.subscriptions_for_device(self.user, device_id)
}
pub fn subscriptions_for_user(
&self,
user: &models::User,
) -> Result<Vec<models::Subscription>, AuthErr> {
self.store.subscriptions_for_user(user)
/// Retrieve the user's subscriptions
pub fn subscriptions(&self) -> Result<Vec<models::Subscription>, AuthErr> {
self.store.subscriptions_for_user(self.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(user, device_id, urls, time_changed)?;
.set_subscriptions_for_device(self.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(user, device_id, add, remove, time_changed)?;
self.store.update_subscriptions_for_device(
self.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<
@ -225,7 +180,7 @@ impl GpodderRepository {
let (added, removed) = self
.store
.subscription_updates_for_device(user, device_id, since)?;
.subscription_updates_for_device(self.user, device_id, since)?;
let max_time_changed = added
.iter()
@ -236,22 +191,22 @@ impl GpodderRepository {
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(user, actions, time_changed)?;
.add_episode_actions(self.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>,
@ -260,7 +215,7 @@ impl GpodderRepository {
let now = chrono::Utc::now();
let actions = self
.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);
Ok((max_time_changed + TimeDelta::seconds(1), actions))

View File

@ -0,0 +1,145 @@
mod admin;
mod authenticated;
use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use chrono::{TimeDelta, Utc};
use rand::{Rng, rngs::OsRng};
use crate::{
SignupLink, 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 update_user(&self, user: models::User) -> Result<models::User, AuthErr> {
self.store.update_user(user)
}
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)
}
pub fn get_signup_link(&self, id: i64) -> Result<Option<SignupLink>, AuthErr> {
self.store.get_signup_link(id)
}
/// 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)
}
/// 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)
}
}

View File

@ -8,6 +8,7 @@ pub enum AuthErr {
UnknownSession,
UnknownUser,
InvalidPassword,
NotAnAdmin,
Other(Box<dyn std::error::Error + Sync + Send>),
}
@ -17,6 +18,7 @@ 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),
}
}
@ -24,17 +26,18 @@ impl Display for AuthErr {
impl std::error::Error for AuthErr {}
pub trait Store:
AuthStore + DeviceRepository + SubscriptionRepository + EpisodeActionRepository
/// API abstraction providing methods for serving the Gpodder API
pub trait GpodderStore:
GpodderAuthStore + GpodderDeviceStore + GpodderSubscriptionStore + GpodderEpisodeActionStore
{
}
impl<T> Store for T where
T: AuthStore + DeviceRepository + SubscriptionRepository + EpisodeActionRepository
impl<T> GpodderStore for T where
T: GpodderAuthStore + GpodderDeviceStore + GpodderSubscriptionStore + GpodderEpisodeActionStore
{
}
pub trait AuthStore {
pub trait GpodderAuthStore {
/// Retrieve the session with the given session ID
fn get_session(&self, session_id: i64) -> Result<Option<Session>, AuthErr>;
@ -48,6 +51,12 @@ pub trait AuthStore {
/// Insert a new user into the data store
fn insert_user(&self, username: &str, password_hash: &str) -> Result<User, AuthErr>;
/// Update the user with the included ID with the new values
fn update_user(&self, user: User) -> Result<User, AuthErr>;
/// Remove the user with the given ID
fn remove_user(&self, id: i64) -> Result<bool, AuthErr>;
/// Create a new session for a user with the given session ID
///
/// The `last_seen` timestamp's precision should be at least accurate to the second
@ -61,9 +70,41 @@ pub trait AuthStore {
/// 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 DeviceRepository {
pub trait GpodderDeviceStore {
/// Return all devices associated with the user
fn devices_for_user(&self, user: &User) -> Result<Vec<Device>, AuthErr>;
@ -108,7 +149,7 @@ pub trait DeviceRepository {
) -> Result<(Vec<String>, Vec<Vec<String>>), AuthErr>;
}
pub trait SubscriptionRepository {
pub trait GpodderSubscriptionStore {
/// Return the subscriptions for the given device
fn subscriptions_for_device(
&self,
@ -149,7 +190,7 @@ pub trait SubscriptionRepository {
) -> Result<(Vec<Subscription>, Vec<Subscription>), AuthErr>;
}
pub trait EpisodeActionRepository {
pub trait GpodderEpisodeActionStore {
/// Insert the given episode actions into the datastore.
fn add_episode_actions(
&self,

View File

@ -15,8 +15,15 @@ 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

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

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

View File

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

View File

@ -2,5 +2,6 @@ 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

@ -0,0 +1,11 @@
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

@ -2,13 +2,14 @@ use diesel::prelude::*;
use crate::schema::*;
#[derive(Clone, Queryable, Selectable)]
#[derive(Clone, Queryable, Selectable, AsChangeset)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct User {
pub id: i64,
pub username: String,
pub password_hash: String,
pub admin: bool,
}
#[derive(Insertable)]

View File

@ -7,6 +7,7 @@ use crate::{
DbError,
models::{
session::Session,
signup_link::SignupLink,
user::{NewUser, User},
},
schema::*,
@ -18,11 +19,32 @@ impl From<User> for gpodder::User {
id: value.id,
username: value.username,
password_hash: value.password_hash,
admin: value.admin,
}
}
}
impl gpodder::AuthStore for SqliteRepository {
impl From<gpodder::User> for User {
fn from(value: gpodder::User) -> Self {
Self {
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(),
}
}
}
impl gpodder::GpodderAuthStore for SqliteRepository {
fn get_user(&self, username: &str) -> Result<Option<gpodder::models::User>, AuthErr> {
Ok(users::table
.select(User::as_select())
@ -47,6 +69,28 @@ impl gpodder::AuthStore for SqliteRepository {
.map_err(DbError::from)?)
}
fn update_user(&self, user: gpodder::User) -> Result<gpodder::User, AuthErr> {
let conn = &mut self.pool.get().map_err(DbError::from)?;
let user: User = user.into();
Ok(diesel::update(users::table.filter(users::id.eq(user.id)))
.set(&user)
.returning(User::as_returning())
.get_result(conn)
.map(gpodder::User::from)
.map_err(DbError::from)?)
}
fn remove_user(&self, id: i64) -> Result<bool, AuthErr> {
let conn = &mut self.pool.get().map_err(DbError::from)?;
match diesel::delete(users::table.filter(users::id.eq(id))).execute(conn) {
Ok(0) => Ok(false),
Ok(_) => Ok(true),
Err(err) => Err(DbError::from(err).into()),
}
}
fn get_session(&self, session_id: i64) -> Result<Option<gpodder::models::Session>, AuthErr> {
match sessions::table
.inner_join(users::table)
@ -141,4 +185,67 @@ impl gpodder::AuthStore 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

@ -38,7 +38,7 @@ impl From<gpodder::DeviceType> for DeviceType {
}
}
impl gpodder::DeviceRepository for SqliteRepository {
impl gpodder::GpodderDeviceStore for SqliteRepository {
fn devices_for_user(
&self,
user: &gpodder::User,

View File

@ -71,7 +71,7 @@ fn to_gpodder_action(
}
}
impl gpodder::EpisodeActionRepository for SqliteRepository {
impl gpodder::GpodderEpisodeActionStore for SqliteRepository {
fn add_episode_actions(
&self,
user: &gpodder::User,

View File

@ -172,7 +172,7 @@ pub fn update_subscriptions_for_single_device(
Ok(())
}
impl gpodder::SubscriptionRepository for SqliteRepository {
impl gpodder::GpodderSubscriptionStore for SqliteRepository {
fn subscriptions_for_user(
&self,
user: &gpodder::User,

View File

@ -47,6 +47,13 @@ diesel::table! {
}
}
diesel::table! {
signup_links (id) {
id -> BigInt,
created_at -> BigInt,
}
}
diesel::table! {
sync_groups (id) {
id -> BigInt,
@ -58,6 +65,7 @@ diesel::table! {
id -> BigInt,
username -> Text,
password_hash -> Text,
admin -> Bool,
}
}
@ -73,6 +81,7 @@ diesel::allow_tables_to_appear_in_same_query!(
devices,
episode_actions,
sessions,
signup_links,
sync_groups,
users,
);

View File

@ -1,7 +1,7 @@
mod common;
use chrono::{SubsecRound, TimeDelta};
use gpodder::{AuthStore, Session};
use gpodder::{GpodderAuthStore, Session};
use gpodder_sqlite::SqliteRepository;
#[test]

View File

@ -1,4 +1,4 @@
use gpodder::{AuthStore, User};
use gpodder::{GpodderAuthStore, User};
use gpodder_sqlite::SqliteRepository;
use rand::{Rng, distributions::Alphanumeric};

View File

@ -1,6 +1,6 @@
mod common;
use gpodder::{DevicePatch, DeviceRepository, DeviceType};
use gpodder::{DevicePatch, DeviceType, GpodderDeviceStore};
#[test]
fn test_insert_devices() {

View File

@ -1,9 +1,12 @@
data_dir = "./data"
log_level = "debug"
allow_public_signup = true
[net]
# type = "tcp"
# domain = "127.0.0.1"
# port = 8080
type = "tcp"
domain = "127.0.0.1"
port = 8080
type = "unix"
path = "./otter.socket"
# type = "unix"
# path = "./otter.socket"

View File

@ -25,3 +25,4 @@ http-body-util = "0.1.3"
tokio = { version = "1.43.0", features = ["full"] }
tracing-subscriber = "0.3.19"
tera = "1.20.0"
serde_urlencoded = "0.7.1"

View File

@ -1,4 +1,4 @@
use clap::Subcommand;
use clap::{ArgAction, Args, Subcommand};
use super::CliError;
@ -11,6 +11,23 @@ pub enum Command {
},
/// List the devices for the given user
Devices { username: String },
#[command(subcommand)]
User(UserCommand),
}
#[derive(Subcommand)]
pub enum UserCommand {
/// Add a new user
Add { username: String, password: String },
/// Generate a signup link ID
GenerateSignupLink,
/// Give or remove admin privileges to a user
SetAdmin {
username: String,
#[clap(action=ArgAction::Set)]
is_admin: bool,
},
}
impl Command {
@ -23,20 +40,44 @@ impl Command {
match self {
Self::Sync { username, devices } => {
let user = store.get_user(username)?;
store.update_device_sync_status(
&user,
store.user(&user).update_device_sync_status(
vec![devices.iter().map(|s| s.as_ref()).collect()],
Vec::new(),
)?;
}
Self::Devices { username } => {
let user = store.get_user(username)?;
let devices = store.devices_for_user(&user)?;
let devices = store.user(&user).devices()?;
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)?;
}
Self::GenerateSignupLink => {
let link = store.generate_signup_link()?;
println!("/signup/{}", link.id);
}
Self::SetAdmin { username, is_admin } => {
let mut user = store.get_user(username)?;
user.admin = *is_admin;
store.update_user(user)?;
}
}
Ok(())

View File

@ -6,8 +6,8 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
providers::{Env, Format, Serialized, Toml},
};
use serde::Serialize;

View File

@ -23,6 +23,7 @@ pub fn serve(config: &crate::config::Config) -> Result<(), CliError> {
let ctx = server::Context {
store,
tera: Arc::new(tera),
config: config.clone(),
};
let app = server::app(ctx.clone());

View File

@ -23,7 +23,7 @@ impl From<LogLevel> for tracing::Level {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
#[serde(tag = "type")]
pub enum NetConfig {
@ -31,12 +31,13 @@ pub enum NetConfig {
Unix { path: PathBuf },
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
pub net: NetConfig,
pub data_dir: PathBuf,
pub session_cleanup_interval: u64,
pub log_level: LogLevel,
pub allow_public_signup: bool,
}
impl Default for Config {
@ -50,6 +51,7 @@ impl Default for Config {
// Once per day
session_cleanup_interval: 60 * 60 * 24,
log_level: LogLevel::Warn,
allow_public_signup: false,
}
}
}

View File

@ -1,20 +1,20 @@
use axum::{
Router,
extract::{Path, State},
routing::post,
Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization, UserAgent},
TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, UserAgent, authorization::Basic},
};
use cookie::time::Duration;
use gpodder::AuthErr;
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::SESSION_ID_COOKIE,
Context,
};
pub fn router() -> Router<Context> {
@ -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.create_session(&user, user_agent)?;
let session = ctx.store.user(&user).create_session(user_agent)?;
Ok::<_, AuthErr>(session)
})

View File

@ -1,18 +1,18 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::{get, post},
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
@ -39,7 +39,7 @@ async fn get_devices(
}
Ok(
tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user))
tokio::task::spawn_blocking(move || ctx.store.user(&user).devices())
.await
.unwrap()
.map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?,
@ -56,9 +56,11 @@ async fn post_device(
return Err(AppError::NotFound);
}
tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into()))
.await
.unwrap()?;
tokio::task::spawn_blocking(move || {
ctx.store.user(&user).update_device_info(&id, patch.into())
})
.await
.unwrap()?;
Ok(())
}

View File

@ -1,13 +1,14 @@
use axum::{
Extension, Json, Router,
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
@ -15,7 +16,6 @@ use crate::server::{
models,
models::UpdatedUrlsResponse,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
@ -46,7 +46,8 @@ async fn post_episode_actions(
Ok(tokio::task::spawn_blocking(move || {
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
.unwrap()
@ -90,8 +91,7 @@ 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.episode_actions_for_user(
&user,
ctx.store.user(&user).episode_actions_for_user(
since,
filter.podcast,
filter.device,

View File

@ -1,19 +1,19 @@
use axum::{
Extension, Json, Router,
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use serde::Deserialize;
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
@ -44,7 +44,8 @@ pub async fn post_subscription_changes(
Ok(tokio::task::spawn_blocking(move || {
ctx.store
.update_subscriptions_for_device(&user, &id, delta.add, delta.remove)
.user(&user)
.update_subscriptions_for_device(&id, delta.add, delta.remove)
})
.await
.unwrap()
@ -79,7 +80,9 @@ 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.subscription_updates_for_device(&user, &id, since)
ctx.store
.user(&user)
.subscription_updates_for_device(&id, since)
})
.await
.unwrap()

View File

@ -1,18 +1,18 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SyncStatus, SyncStatusDelta},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
@ -41,7 +41,7 @@ pub async fn get_sync_status(
}
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
.unwrap()
.map(|(not_synchronized, synchronized)| {
@ -68,8 +68,7 @@ pub async fn post_sync_status_changes(
}
Ok(tokio::task::spawn_blocking(move || {
ctx.store.update_device_sync_status(
&user,
ctx.store.user(&user).update_device_sync_status(
delta
.synchronize
.iter()
@ -78,7 +77,7 @@ pub async fn post_sync_status_changes(
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
.unwrap()

View File

@ -1,8 +1,8 @@
use std::ops::Deref;
use serde::{
de::{value::StrDeserializer, Visitor},
Deserialize,
de::{Visitor, value::StrDeserializer},
};
#[derive(Deserialize, Debug, PartialEq, Eq)]

View File

@ -4,16 +4,16 @@ mod models;
mod simple;
use axum::{
RequestExt, Router,
extract::{Request, State},
http::{header::WWW_AUTHENTICATE, HeaderName, HeaderValue, StatusCode},
http::{HeaderName, HeaderValue, StatusCode, header::WWW_AUTHENTICATE},
middleware::Next,
response::{IntoResponse, Response},
RequestExt, Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, authorization::Basic},
};
use tower_http::set_header::SetResponseHeaderLayer;
@ -123,7 +123,8 @@ impl From<gpodder::AuthErr> for AppError {
match value {
gpodder::AuthErr::UnknownUser
| gpodder::AuthErr::UnknownSession
| gpodder::AuthErr::InvalidPassword => Self::Unauthorized,
| gpodder::AuthErr::InvalidPassword
| gpodder::AuthErr::NotAnAdmin => Self::Unauthorized,
gpodder::AuthErr::Other(err) => Self::Other(err),
}
}

View File

@ -1,14 +1,14 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{auth_api_middleware, format::StringWithFormat},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
@ -34,7 +34,7 @@ pub async fn get_device_subscriptions(
}
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
.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.subscriptions_for_user(&user))
tokio::task::spawn_blocking(move || ctx.store.user(&user).subscriptions())
.await
.unwrap()
.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 || {
ctx.store.set_subscriptions_for_device(&user, &id, urls)
ctx.store
.user(&user)
.set_subscriptions_for_device(&id, urls)
})
.await
.unwrap()

View File

@ -6,12 +6,12 @@ mod web;
use std::sync::Arc;
use axum::{
Router,
body::Body,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Router,
};
use http_body_util::BodyExt;
use tower_http::trace::TraceLayer;
@ -20,6 +20,7 @@ use tower_http::trace::TraceLayer;
pub struct Context {
pub store: ::gpodder::GpodderRepository,
pub tera: Arc<tera::Tera>,
pub config: crate::config::Config,
}
pub fn app(ctx: Context) -> Router {

View File

@ -1,7 +1,7 @@
use std::io::Cursor;
use axum::{routing::get, Router};
use axum_extra::{headers::Range, TypedHeader};
use axum::{Router, http::header, response::IntoResponse, routing::get};
use axum_extra::{TypedHeader, headers::Range};
use axum_range::{KnownSize, Ranged};
use super::Context;
@ -9,8 +9,6 @@ use super::Context;
const HTMX: &str = include_str!("./htmx_2.0.4.min.js");
const PICOCSS: &str = include_str!("./pico_2.1.1.classless.jade.min.css");
type RangedResponse = Ranged<KnownSize<Cursor<&'static str>>>;
pub fn router() -> Router<Context> {
Router::new()
.route("/htmx_2.0.4.min.js", get(get_htmx))
@ -18,17 +16,24 @@ pub fn router() -> Router<Context> {
}
#[inline(always)]
fn serve_static(data: &'static str, range: Option<Range>) -> RangedResponse {
fn serve_static(data: &'static str, range: Option<Range>, content_type: &str) -> impl IntoResponse {
let cursor = Cursor::new(data);
let body = KnownSize::sized(cursor, data.len() as u64);
Ranged::new(range, body)
(
[(header::CONTENT_TYPE, content_type)],
Ranged::new(range, body),
)
}
async fn get_htmx(range: Option<TypedHeader<Range>>) -> RangedResponse {
serve_static(HTMX, range.map(|TypedHeader(range)| range))
async fn get_htmx(range: Option<TypedHeader<Range>>) -> impl IntoResponse {
serve_static(
HTMX,
range.map(|TypedHeader(range)| range),
"text/javascript",
)
}
async fn get_picocss(range: Option<TypedHeader<Range>>) -> RangedResponse {
serve_static(PICOCSS, range.map(|TypedHeader(range)| range))
async fn get_picocss(range: Option<TypedHeader<Range>>) -> impl IntoResponse {
serve_static(PICOCSS, range.map(|TypedHeader(range)| range), "text/css")
}

View File

@ -0,0 +1,325 @@
use axum::{
Form, RequestExt, Router,
extract::{Path, Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
use cookie::{Cookie, time::Duration};
use serde::Deserialize;
use gpodder::{AuthErr, Session};
use crate::{
server::{
Context,
error::{AppError, AppResult},
},
web::{TemplateExt, View},
};
pub fn router(ctx: Context) -> Router<Context> {
let mut router = Router::new()
// .layer(middleware::from_fn_with_state(
// ctx.clone(),
// auth_web_middleware,
// ))
// Login route needs to be handled differently, as the middleware turns it into a redirect
// loop
.route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout));
// If public signups aren't allowed, we don't even register the route to prevent any dumb
// security mistakes
if ctx.config.allow_public_signup {
router = router.route("/signup", get(get_signup).post(post_signup))
} else {
router = router.route("/signup/{id}", get(get_signup_link).post(post_signup_link));
}
router
}
/// Middleware that authenticates the current user via the session token. If the credentials are
/// invalid, the user is redirected to the login page.
pub async fn auth_web_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/login");
match extract_session(ctx, &jar).await {
Ok(Some(session)) => {
req.extensions_mut().insert(session);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
}
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(super::SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
} else {
Ok(None)
}
}
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
if extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Redirect::to("/").into_response()
} else {
View::Login {
signup_note: ctx.config.allow_public_signup,
}
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn post_login(
State(ctx): State<Context>,
user_agent: Option<TypedHeader<UserAgent>>,
jar: CookieJar,
Form(login): Form<LoginForm>,
) -> AppResult<Response> {
match tokio::task::spawn_blocking(move || {
let user = ctx
.store
.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)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok((
// Redirect forces htmx to reload the full page, refreshing the navbar
[("HX-Redirect", "/")],
(jar.add(
Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string()))
.secure(true)
.same_site(cookie::SameSite::Lax)
.http_only(true)
.path("/")
.max_age(Duration::days(365)),
)),
)
.into_response()),
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
todo!("serve login form with error messages")
}
Err(err) => Err(AppError::from(err)),
}
}
/// Log out the user by simply removing the session
async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
if let Some(session) = extract_session(ctx.clone(), &jar).await? {
ctx.store.remove_session(session.id)?;
}
// Redirect forces htmx to reload the full page, refreshing the navbar
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
}
#[derive(Deserialize)]
struct SignupForm {
username: String,
password: String,
password_confirm: String,
}
struct SignupValidation {
username_available: bool,
passwords_match: bool,
}
impl SignupForm {
fn validate(&self, ctx: &Context) -> AppResult<SignupValidation> {
let username_available = match ctx.store.get_user(&self.username) {
Ok(_) => false,
Err(AuthErr::UnknownUser) => true,
Err(err) => {
return Err(err.into());
}
};
let passwords_match = self.password == self.password_confirm;
Ok(SignupValidation {
username_available,
passwords_match,
})
}
}
impl SignupValidation {
pub fn valid(&self) -> bool {
self.username_available && self.passwords_match
}
}
async fn get_signup(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
if extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Redirect::to("/").into_response()
} else {
View::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
async fn post_signup(
State(ctx): State<Context>,
jar: CookieJar,
headers: HeaderMap,
user_agent: Option<TypedHeader<UserAgent>>,
Form(signup): Form<SignupForm>,
) -> AppResult<Response> {
let validation = signup.validate(&ctx)?;
if validation.valid() {
// Create the user and log them in
match tokio::task::spawn_blocking(move || {
let user = ctx.store.create_user(&signup.username, &signup.password)?;
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.user(&user).create_session(user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok((
// Redirect forces htmx to reload the full page, refreshing the navbar
[("HX-Redirect", "/")],
(jar.add(
Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string()))
.secure(true)
.same_site(cookie::SameSite::Lax)
.http_only(true)
.path("/")
.max_age(Duration::days(365)),
)),
)
.into_response()),
Err(err) => Err(AppError::from(err)),
}
} else {
Ok(View::Signup {
username: Some(signup.username),
username_available: validation.username_available,
passwords_match: validation.passwords_match,
}
.page(&headers)
.response(&ctx.tera)
.into_response())
}
}
async fn get_signup_link(
State(ctx): State<Context>,
Path(id): Path<i64>,
headers: HeaderMap,
jar: CookieJar,
) -> AppResult<Response> {
let ctx_clone = ctx.clone();
let signup_link = tokio::task::spawn_blocking(move || ctx_clone.store.get_signup_link(id))
.await
.unwrap()?;
// Just redirect to / if it's an invalid sign-up link
if signup_link.is_none()
|| extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Ok(Redirect::to("/").into_response())
} else {
Ok(View::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.page(&headers)
.response(&ctx.tera)
.into_response())
}
}
async fn post_signup_link(
State(ctx): State<Context>,
Path(id): Path<i64>,
jar: CookieJar,
headers: HeaderMap,
user_agent: Option<TypedHeader<UserAgent>>,
signup: Form<SignupForm>,
) -> AppResult<Response> {
let ctx_clone = ctx.clone();
if tokio::task::spawn_blocking(move || ctx_clone.store.get_signup_link(id))
.await
.unwrap()?
.is_some()
{
let response = post_signup(State(ctx.clone()), jar, headers, user_agent, signup).await?;
// Signup flow was successful, so remove the signup link
tokio::task::spawn_blocking(move || ctx.store.remove_signup_link(id))
.await
.unwrap()?;
Ok(response)
} else {
Ok(StatusCode::NOT_FOUND.into_response())
}
}

View File

@ -1,142 +1,84 @@
use axum::{
extract::{Request, State},
http::HeaderMap,
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
routing::get,
Form, RequestExt, Router,
};
use axum_extra::{extract::CookieJar, headers::UserAgent, TypedHeader};
use cookie::{time::Duration, Cookie};
use gpodder::{AuthErr, Session};
mod auth;
mod sessions;
mod users;
use axum::{Router, extract::State, http::HeaderMap, routing::get};
use axum_extra::extract::CookieJar;
use serde::Deserialize;
use crate::web::{Page, TemplateExt, TemplateResponse, View};
use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View};
use super::{
error::{AppError, AppResult},
Context,
};
use super::{Context, error::AppResult};
const SESSION_ID_COOKIE: &str = "sessionid";
#[derive(Deserialize, Clone)]
#[serde(default)]
pub struct Pagination {
page: u32,
per_page: u32,
}
impl From<Pagination> for gpodder::Page {
fn from(value: Pagination) -> Self {
Self {
page: value.page,
per_page: value.per_page,
}
}
}
impl Default for Pagination {
fn default() -> Self {
Self {
page: 0,
per_page: 25,
}
}
}
impl ToQuery for Pagination {
fn to_query(self) -> Query {
Query::default()
.parameter("page", self.page)
.parameter("per_page", self.per_page)
}
}
impl Pagination {
pub fn next_page(&self) -> Self {
Self {
page: self.page + 1,
per_page: self.per_page,
}
}
}
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route("/", get(get_index))
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_web_middleware,
))
// .layer(middleware::from_fn_with_state(
// ctx.clone(),
// auth_web_middleware,
// ))
// Login route needs to be handled differently, as the middleware turns it into a redirect
// loop
.route("/login", get(get_login).post(post_login))
.merge(auth::router(ctx.clone()))
.merge(sessions::router(ctx.clone()))
.merge(users::router(ctx.clone()))
}
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> {
View::Index.page(&headers).response(&ctx.tera)
}
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
if extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Redirect::to("/").into_response()
} else {
View::Login
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn post_login(
async fn get_index(
State(ctx): State<Context>,
user_agent: Option<TypedHeader<UserAgent>>,
headers: HeaderMap,
jar: CookieJar,
Form(login): Form<LoginForm>,
) -> AppResult<Response> {
match tokio::task::spawn_blocking(move || {
let user = ctx
.store
.validate_credentials(&login.username, &login.password)?;
) -> AppResult<TemplateResponse<Page<View>>> {
let user = auth::extract_session(ctx.clone(), &jar)
.await?
.map(|session| session.user);
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.create_session(&user, user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok((
jar.add(
Cookie::build((SESSION_ID_COOKIE, session.id.to_string()))
.secure(true)
.same_site(cookie::SameSite::Lax)
.http_only(true)
.path("/")
.max_age(Duration::days(365)),
),
Redirect::to("/"),
)
.into_response()),
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
todo!("serve login form with error messages")
}
Err(err) => Err(AppError::from(err)),
}
}
async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
} else {
Ok(None)
}
}
/// Middleware that authenticates the current user via the session token. If the credentials are
/// invalid, the user is redirected to the login page.
pub async fn auth_web_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/login");
match extract_session(ctx, &jar).await {
Ok(Some(session)) => {
req.extensions_mut().insert(session.user);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
Ok(View::Index
.page(&headers)
.user(user.as_ref())
.response(&ctx.tera))
}

View File

@ -0,0 +1,74 @@
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("/sessions", get(get_sessions))
.route("/sessions/{id}", delete(delete_session))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
super::auth::auth_web_middleware,
))
}
pub async fn get_sessions(
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 admin = session.user.admin;
let sessions = tokio::task::spawn_blocking(move || {
ctx.store
.user(&session.user)
.paginated_sessions(page.into())
})
.await
.unwrap()?;
let next_page_query =
(sessions.len() == next_page.per_page as usize).then_some(next_page.to_query());
Ok(View::Sessions(sessions, session.id, next_page_query)
.page(&headers)
.headers(&headers)
.authenticated(true, admin)
.response(&ctx.tera))
}
pub async fn delete_session(
State(ctx): State<Context>,
Extension(session): Extension<gpodder::Session>,
Path(id): Path<i64>,
) -> AppResult<()> {
tokio::task::spawn_blocking(move || {
let other_session = ctx.store.get_session(id)?;
// Check to ensure a user can't remove a session that's not theirs
if session.user.id != other_session.user.id {
return Err(AppError::Unauthorized);
}
ctx.store.remove_session(session.id)?;
Ok(())
})
.await
.unwrap()?;
Ok(())
}

View File

@ -0,0 +1,90 @@
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("/users/{id}", delete(delete_user))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
super::auth::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, true)
.response(&ctx.tera))
}
async fn delete_user(
State(ctx): State<Context>,
Extension(session): Extension<gpodder::Session>,
Path(id): Path<i64>,
) -> AppResult<()> {
let deleted =
tokio::task::spawn_blocking(move || ctx.store.admin(&session.user)?.remove_user(id))
.await
.unwrap()?;
if deleted {
Ok(())
} else {
Err(AppError::NotFound)
}
}

View File

@ -1,4 +1,5 @@
mod page;
mod query;
mod view;
use std::sync::Arc;
@ -10,6 +11,7 @@ use axum::{
};
pub use page::Page;
pub use query::{Query, ToQuery};
pub use view::View;
const BASE_TEMPLATE: &str = "base.html";
@ -22,7 +24,7 @@ pub trait Template {
/// Render the template using the given Tera instance.
///
/// Templates are expected to manage their own context requirements if needed.
fn render(&self, tera: &tera::Tera) -> tera::Result<String>;
fn render(self, tera: &tera::Tera) -> tera::Result<String>;
}
/// Useful additional functions on sized Template implementors
@ -76,9 +78,26 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
include_str!("templates/views/index.html"),
),
(
View::Login.template(),
View::Login { signup_note: false }.template(),
include_str!("templates/views/login.html"),
),
(
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"),
),
(
View::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.template(),
include_str!("templates/views/signup.html"),
),
])?;
Ok(tera)

View File

@ -10,6 +10,8 @@ const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
pub struct Page<T> {
template: T,
wrap_with_base: bool,
authenticated: bool,
admin: bool,
}
impl<T: Template> Template for Page<T> {
@ -17,12 +19,14 @@ impl<T: Template> Template for Page<T> {
self.template.template()
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
fn render(self, tera: &tera::Tera) -> tera::Result<String> {
let inner = self.template.render(tera)?;
if self.wrap_with_base {
let mut ctx = tera::Context::new();
ctx.insert("inner", &inner);
ctx.insert("authenticated", &self.authenticated);
ctx.insert("admin", &self.admin);
tera.render(super::BASE_TEMPLATE, &ctx)
} else {
@ -36,6 +40,8 @@ impl<T> Page<T> {
Self {
template,
wrap_with_base: false,
authenticated: false,
admin: false,
}
}
@ -50,4 +56,25 @@ impl<T> Page<T> {
self
}
/// Set the view's authentication level
pub fn authenticated(mut self, authenticated: bool, admin: bool) -> Self {
self.authenticated = authenticated;
self.admin = admin;
self
}
/// Utility function to derive authentication level from a given user
pub fn user(mut self, user: Option<&gpodder::User>) -> Self {
if let Some(user) = user {
self.authenticated = true;
self.admin = user.admin;
} else {
self.authenticated = false;
self.admin = false;
}
self
}
}

View File

@ -0,0 +1,46 @@
/// Represents a list of query parameters
#[derive(Default)]
pub struct Query(Vec<(String, String)>);
impl Query {
/// Combine two queries into one
pub fn join(mut self, other: impl ToQuery) -> Self {
let mut other = other.to_query();
self.0.append(&mut other.0);
self
}
/// Convert the query into a url-encoded query parameter string
pub fn encode(self) -> String {
// TODO is this unwrap safe?
serde_urlencoded::to_string(&self.0).unwrap()
}
/// Builder-style method that appends a parameter to the query
pub fn parameter(mut self, key: impl ToString, value: impl ToString) -> Self {
self.0.push((key.to_string(), value.to_string()));
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
pub trait ToQuery {
fn to_query(self) -> Query;
}
impl ToQuery for Query {
fn to_query(self) -> Query {
self
}
}

View File

@ -15,6 +15,23 @@ a:hover {
<body>
<main>
<nav>
<ul>
<li>
<a hx-get="/" hx-target="#inner" hx-push-url="true"><strong>Otter</strong></a>
</li>
</ul>
<ul>
{% if authenticated %}
<li><a hx-get="/sessions" hx-target="#inner" hx-push-url="true">Sessions</a></li>
{% if admin %}
<li><a hx-get="/users" hx-target="#inner" hx-push-url="true">Users</a></li>
{% endif %}
<li><a hx-post="/logout" hx-target="#inner">Logout</a></li>
{% else %}
<li><a hx-get="/login" hx-target="#inner" hx-push-url="true">Login</a></li>
{% endif %}
</li>
</ul>
</nav>
<article id="inner">
{{ inner | safe }}

View File

@ -1,5 +1,3 @@
<h1>Otter</h1>
Otter is a self-hostable Gpodder implementation.
If you're seeing this, you're logged in.

View File

@ -1,9 +1,13 @@
<article>
<form hx-post="/login" hx-target="#inner">
<form hx-post="/login" hx-target="#inner" hx-push-url="/">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<input type="submit" value="Login">
</form>
{% if signup_note %}
<p>Don't have an account yet? <a hx-get="/signup" hx-target="#inner" hx-push-url="/signup">Create one here</a>!</p>
{% endif %}
</article>

View File

@ -0,0 +1,36 @@
<h1>Sessions</h1>
<table>
<thead>
<tr>
<th>User Agent</th>
<th>Last seen</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<th>{{ session.user_agent }}</th>
<th>{{ session.last_seen }}</th>
<th>
{%- if session.id != current_session_id -%}
<a hx-delete="/sessions/{{ session.id }}"
hx-target="closest tr"
>Remove</a>
{%- else -%}
Current session
{%- endif -%}
</th>
</tr>
{% endfor %}
{% if next_page_query %}
<tr
hx-get="/sessions?{{ next_page_query }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-select="table > tbody > tr"
></tr>
{% endif %}
</tbody>
</table>

View File

@ -0,0 +1,34 @@
<article>
<form hx-post hx-target="#inner">
<label for="username">Username:</label>
<input
type="text"
id="username"
name="username"
value="{{ username }}"
{% if not username_available %}
aria-invalid="true"
aria-describedby="username-helper"
{% endif %}
>
{% if not username_available %}
<small id="username-helper">Username not available</small>
{% endif %}
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<label for="password_confirm">Confirm password:</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
{% if not passwords_match %}
aria-invalid="true"
aria-describedby="password-helper"
{% endif %}
>
{% if not passwords_match %}
<small id="password-helper">Passwords don't match</small>
{% endif %}
<input type="submit" value="Sign Up">
</form>
</article>

View File

@ -0,0 +1,48 @@
<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>
<th>Privileges</th>
<th>Action</th>
</thead>
<tbody>
{%- for user in users %}
<tr>
<th>{{ user.username }}</th>
<th>{%- if user.admin -%}
Admin
{%- else -%}
User
{%- endif -%}
<th>
{%- if user.id != current_user_id -%}
<a hx-delete="/users/{{ user.id }}"
hx-target="closest tr"
>Remove</a>
{%- else -%}
Current user
{%- endif -%}
</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

@ -1,19 +1,110 @@
use super::Template;
use chrono::{DateTime, Utc};
use serde::Serialize;
use super::{Query, Template};
pub enum View {
Index,
Login,
Login {
signup_note: bool,
},
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
Users(Vec<gpodder::User>, i64, Option<Query>),
Signup {
username: Option<String>,
username_available: bool,
passwords_match: bool,
},
}
#[derive(Serialize)]
struct Session {
id: i64,
user_agent: Option<String>,
last_seen: DateTime<Utc>,
}
#[derive(Serialize)]
struct User {
id: i64,
username: String,
admin: bool,
}
impl Template for View {
fn template(&self) -> &'static str {
match self {
Self::Index => "views/index.html",
Self::Login => "views/login.html",
Self::Login { .. } => "views/login.html",
Self::Sessions(..) => "views/sessions.html",
Self::Users(..) => "views/users.html",
Self::Signup { .. } => "views/signup.html",
}
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
tera.render(self.template(), &tera::Context::new())
fn render(self, tera: &tera::Tera) -> tera::Result<String> {
let mut ctx = tera::Context::new();
let template = self.template();
match self {
Self::Sessions(sessions, current_session_id, query) => {
ctx.insert(
"sessions",
&sessions.into_iter().map(Session::from).collect::<Vec<_>>(),
);
ctx.insert("current_session_id", &current_session_id);
if let Some(query) = query {
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());
}
}
Self::Signup {
username,
username_available,
passwords_match,
} => {
ctx.insert("username", &username);
ctx.insert("username_available", &username_available);
ctx.insert("passwords_match", &passwords_match);
}
Self::Login { signup_note } => {
ctx.insert("signup_note", &signup_note);
}
_ => {}
};
tera.render(template, &ctx)
}
}
impl From<gpodder::Session> for Session {
fn from(value: gpodder::Session) -> Self {
Self {
id: value.id,
user_agent: value.user_agent,
last_seen: value.last_seen,
}
}
}
impl From<gpodder::User> for User {
fn from(value: gpodder::User) -> Self {
Self {
id: value.id,
username: value.username,
admin: value.admin,
}
}
}