Compare commits
40 Commits
Author | SHA1 | Date |
---|---|---|
|
09d782b6a5 | |
|
332c05491a | |
|
5017bd1c5f | |
|
b946e1ce98 | |
|
ee9db5ae36 | |
|
a296d0fafe | |
|
4d44216e17 | |
|
722317603d | |
|
69e84b4266 | |
|
5017bfb710 | |
|
4902f4d1fe | |
|
89f8b08b5e | |
|
97b30b1840 | |
|
5cd1f4f736 | |
|
c48d2a78ca | |
|
2514aa8413 | |
|
6c8183c1e3 | |
|
fce301080c | |
|
c7c5cf889c | |
|
30609b1cef | |
|
4854c84601 | |
|
2524eb5807 | |
|
669aa475ca | |
|
346c27fc3f | |
|
0e91eef0e8 | |
|
4735bc3f13 | |
|
5e653407f2 | |
|
dd418c872a | |
|
32a4a88548 | |
|
b16c9a0404 | |
|
7887477ed1 | |
|
21b3450aeb | |
|
a57e301d16 | |
|
68b2b1beb4 | |
|
e8e0c94937 | |
|
32d70daab2 | |
|
7de4897364 | |
|
fc46c4874a | |
|
957387bed7 | |
|
b04955d70e |
|
@ -0,0 +1,3 @@
|
|||
[submodule "docs/themes/hugo-book"]
|
||||
path = docs/themes/hugo-book
|
||||
url = https://github.com/alex-shpak/hugo-book
|
34
CHANGELOG.md
34
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -7,7 +7,7 @@ members = [
|
|||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
6
Justfile
6
Justfile
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
public/
|
||||
*.lock
|
||||
*.tar.gz
|
||||
/resources/_gen/
|
||||
auth.txt
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
|
@ -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 %}}
|
|
@ -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`.
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
Subproject commit f2c703e155881a017cabbee17224e2dfeee0498c
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
alter table users
|
||||
drop column admin;
|
|
@ -0,0 +1,2 @@
|
|||
alter table users
|
||||
add column admin boolean not null default false;
|
|
@ -0,0 +1,2 @@
|
|||
-- This file should undo anything in `up.sql`
|
||||
drop table signup_links;
|
|
@ -0,0 +1,5 @@
|
|||
-- Your SQL goes here
|
||||
create table signup_links (
|
||||
id bigint primary key not null,
|
||||
created_at bigint not null
|
||||
);
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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)]
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
mod common;
|
||||
|
||||
use chrono::{SubsecRound, TimeDelta};
|
||||
use gpodder::{AuthStore, Session};
|
||||
use gpodder::{GpodderAuthStore, Session};
|
||||
use gpodder_sqlite::SqliteRepository;
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use gpodder::{AuthStore, User};
|
||||
use gpodder::{GpodderAuthStore, User};
|
||||
use gpodder_sqlite::SqliteRepository;
|
||||
|
||||
use rand::{Rng, distributions::Alphanumeric};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
mod common;
|
||||
|
||||
use gpodder::{DevicePatch, DeviceRepository, DeviceType};
|
||||
use gpodder::{DevicePatch, DeviceType, GpodderDeviceStore};
|
||||
|
||||
#[test]
|
||||
fn test_insert_devices() {
|
||||
|
|
13
otter.toml
13
otter.toml
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use std::ops::Deref;
|
||||
|
||||
use serde::{
|
||||
de::{value::StrDeserializer, Visitor},
|
||||
Deserialize,
|
||||
de::{Visitor, value::StrDeserializer},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq, Eq)]
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 }}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
<h1>Otter</h1>
|
||||
|
||||
Otter is a self-hostable Gpodder implementation.
|
||||
|
||||
If you're seeing this, you're logged in.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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", ¤t_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", ¤t_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue