From e8e0c9493705312d27dfa200aa675fd751e2ecdb Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 17 Jun 2025 09:52:47 +0200 Subject: [PATCH 1/3] feat(server): partial implementation of session page pagination --- otter.toml | 10 ++--- otter/src/server/web/mod.rs | 50 +++++++++++++-------- otter/src/server/web/sessions.rs | 38 ++++++++++++++++ otter/src/web/mod.rs | 2 +- otter/src/web/page.rs | 2 +- otter/src/web/templates/views/sessions.html | 6 ++- otter/src/web/view.rs | 37 ++++++++++++--- 7 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 otter/src/server/web/sessions.rs diff --git a/otter.toml b/otter.toml index 89481b4..185fe94 100644 --- a/otter.toml +++ b/otter.toml @@ -1,9 +1,9 @@ data_dir = "./data" [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" diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index b8ec379..f924f9b 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -1,15 +1,17 @@ +mod sessions; + use axum::{ Form, RequestExt, Router, extract::{Request, State}, - http::{HeaderMap, HeaderName, HeaderValue, header}, - middleware::{self, Next}, + http::HeaderMap, + middleware::Next, response::{IntoResponse, Redirect, Response}, routing::{get, post}, }; use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent}; use cookie::{Cookie, time::Duration}; use gpodder::{AuthErr, Session}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::web::{Page, TemplateExt, TemplateResponse, View}; @@ -20,13 +22,33 @@ use super::{ const SESSION_ID_COOKIE: &str = "sessionid"; +#[derive(Deserialize, Serialize)] +#[serde(default)] +pub struct Pagination { + page: u32, + per_page: u32, +} + +impl From 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: 1, + } + } +} + pub fn router(ctx: Context) -> Router { Router::new() - .route("/sessions", get(get_sessions)) - .route_layer(axum::middleware::from_fn_with_state( - ctx.clone(), - auth_web_middleware, - )) .route("/", get(get_index)) // .layer(middleware::from_fn_with_state( // ctx.clone(), @@ -36,6 +58,7 @@ pub fn router(ctx: Context) -> Router { // loop .route("/login", get(get_login).post(post_login)) .route("/logout", post(post_logout)) + .merge(sessions::router(ctx.clone())) } async fn get_index( @@ -166,14 +189,3 @@ pub async fn auth_web_middleware( Err(err) => err.into_response(), } } - -pub async fn get_sessions( - State(ctx): State, - headers: HeaderMap, -) -> TemplateResponse> { - View::Sessions(Vec::new()) - .page(&headers) - .headers(&headers) - .authenticated(true) - .response(&ctx.tera) -} diff --git a/otter/src/server/web/sessions.rs b/otter/src/server/web/sessions.rs new file mode 100644 index 0000000..9020bab --- /dev/null +++ b/otter/src/server/web/sessions.rs @@ -0,0 +1,38 @@ +use axum::{ + Extension, Router, + extract::{Query, State}, + http::HeaderMap, + routing::get, +}; + +use crate::{ + server::{Context, error::AppResult}, + web::{Page, TemplateExt, TemplateResponse, View}, +}; + +pub fn router(ctx: Context) -> Router { + Router::new() + .route("/sessions", get(get_sessions)) + .route_layer(axum::middleware::from_fn_with_state( + ctx.clone(), + super::auth_web_middleware, + )) +} + +pub async fn get_sessions( + State(ctx): State, + headers: HeaderMap, + Extension(user): Extension, + Query(page): Query, +) -> AppResult>> { + let sessions = + tokio::task::spawn_blocking(move || ctx.store.paginated_sessions(&user, page.into())) + .await + .unwrap()?; + + Ok(View::Sessions(sessions) + .page(&headers) + .headers(&headers) + .authenticated(true) + .response(&ctx.tera)) +} diff --git a/otter/src/web/mod.rs b/otter/src/web/mod.rs index 0035779..05d66e2 100644 --- a/otter/src/web/mod.rs +++ b/otter/src/web/mod.rs @@ -22,7 +22,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; + fn render(self, tera: &tera::Tera) -> tera::Result; } /// Useful additional functions on sized Template implementors diff --git a/otter/src/web/page.rs b/otter/src/web/page.rs index d7c0b4c..e7f7f6b 100644 --- a/otter/src/web/page.rs +++ b/otter/src/web/page.rs @@ -18,7 +18,7 @@ impl Template for Page { self.template.template() } - fn render(&self, tera: &tera::Tera) -> tera::Result { + fn render(self, tera: &tera::Tera) -> tera::Result { let inner = self.template.render(tera)?; if self.wrap_with_base { diff --git a/otter/src/web/templates/views/sessions.html b/otter/src/web/templates/views/sessions.html index 679a565..f160d41 100644 --- a/otter/src/web/templates/views/sessions.html +++ b/otter/src/web/templates/views/sessions.html @@ -9,10 +9,12 @@ + {% for session in sessions %} - Firefox - yesterday + {{ session.user_agent }} + {{ session.last_seen }} Remove + {% endfor %} diff --git a/otter/src/web/view.rs b/otter/src/web/view.rs index 4f15834..e1a0f59 100644 --- a/otter/src/web/view.rs +++ b/otter/src/web/view.rs @@ -1,3 +1,6 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; + use super::Template; pub enum View { @@ -6,6 +9,12 @@ pub enum View { Sessions(Vec), } +#[derive(Serialize)] +struct Session { + user_agent: Option, + last_seen: DateTime, +} + impl Template for View { fn template(&self) -> &'static str { match self { @@ -15,15 +24,29 @@ impl Template for View { } } - fn render(&self, tera: &tera::Tera) -> tera::Result { + fn render(self, tera: &tera::Tera) -> tera::Result { let mut ctx = tera::Context::new(); + let template = self.template(); - // match self { - // Self::Sessions(sessions) => { - // ctx.insert("sessions", sessions); - // } - // }; + match self { + Self::Sessions(sessions) => { + ctx.insert( + "sessions", + &sessions.into_iter().map(Session::from).collect::>(), + ); + } + _ => {} + }; - tera.render(self.template(), &ctx) + tera.render(template, &ctx) + } +} + +impl From for Session { + fn from(value: gpodder::Session) -> Self { + Self { + user_agent: value.user_agent, + last_seen: value.last_seen, + } } } From 68b2b1beb4ac5a15050c5adafb9dbb285436e035 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 17 Jun 2025 09:53:50 +0200 Subject: [PATCH 2/3] chore: format code --- otter/src/cli/mod.rs | 2 +- otter/src/server/gpodder/advanced/auth.rs | 8 ++++---- otter/src/server/gpodder/advanced/devices.rs | 4 ++-- otter/src/server/gpodder/advanced/episodes.rs | 4 ++-- otter/src/server/gpodder/advanced/subscriptions.rs | 4 ++-- otter/src/server/gpodder/advanced/sync.rs | 4 ++-- otter/src/server/gpodder/format.rs | 2 +- otter/src/server/gpodder/mod.rs | 8 ++++---- otter/src/server/gpodder/simple/subscriptions.rs | 4 ++-- otter/src/server/mod.rs | 2 +- otter/src/server/static/mod.rs | 4 ++-- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/otter/src/cli/mod.rs b/otter/src/cli/mod.rs index 8493451..6dc1dab 100644 --- a/otter/src/cli/mod.rs +++ b/otter/src/cli/mod.rs @@ -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; diff --git a/otter/src/server/gpodder/advanced/auth.rs b/otter/src/server/gpodder/advanced/auth.rs index b7d98dd..9c920dc 100644 --- a/otter/src/server/gpodder/advanced/auth.rs +++ b/otter/src/server/gpodder/advanced/auth.rs @@ -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 { diff --git a/otter/src/server/gpodder/advanced/devices.rs b/otter/src/server/gpodder/advanced/devices.rs index ed85bf2..30bec37 100644 --- a/otter/src/server/gpodder/advanced/devices.rs +++ b/otter/src/server/gpodder/advanced/devices.rs @@ -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 { diff --git a/otter/src/server/gpodder/advanced/episodes.rs b/otter/src/server/gpodder/advanced/episodes.rs index 3f61477..2d62333 100644 --- a/otter/src/server/gpodder/advanced/episodes.rs +++ b/otter/src/server/gpodder/advanced/episodes.rs @@ -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 { diff --git a/otter/src/server/gpodder/advanced/subscriptions.rs b/otter/src/server/gpodder/advanced/subscriptions.rs index 0395874..b430b3e 100644 --- a/otter/src/server/gpodder/advanced/subscriptions.rs +++ b/otter/src/server/gpodder/advanced/subscriptions.rs @@ -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 { diff --git a/otter/src/server/gpodder/advanced/sync.rs b/otter/src/server/gpodder/advanced/sync.rs index 63f8c7c..1ac2c33 100644 --- a/otter/src/server/gpodder/advanced/sync.rs +++ b/otter/src/server/gpodder/advanced/sync.rs @@ -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 { diff --git a/otter/src/server/gpodder/format.rs b/otter/src/server/gpodder/format.rs index 8839219..d2ff395 100644 --- a/otter/src/server/gpodder/format.rs +++ b/otter/src/server/gpodder/format.rs @@ -1,8 +1,8 @@ use std::ops::Deref; use serde::{ - de::{value::StrDeserializer, Visitor}, Deserialize, + de::{Visitor, value::StrDeserializer}, }; #[derive(Deserialize, Debug, PartialEq, Eq)] diff --git a/otter/src/server/gpodder/mod.rs b/otter/src/server/gpodder/mod.rs index 1bf8061..e8f77e3 100644 --- a/otter/src/server/gpodder/mod.rs +++ b/otter/src/server/gpodder/mod.rs @@ -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; diff --git a/otter/src/server/gpodder/simple/subscriptions.rs b/otter/src/server/gpodder/simple/subscriptions.rs index 027e575..cd960f1 100644 --- a/otter/src/server/gpodder/simple/subscriptions.rs +++ b/otter/src/server/gpodder/simple/subscriptions.rs @@ -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 { diff --git a/otter/src/server/mod.rs b/otter/src/server/mod.rs index 7ceb245..20886c6 100644 --- a/otter/src/server/mod.rs +++ b/otter/src/server/mod.rs @@ -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; diff --git a/otter/src/server/static/mod.rs b/otter/src/server/static/mod.rs index 568061c..a97f5de 100644 --- a/otter/src/server/static/mod.rs +++ b/otter/src/server/static/mod.rs @@ -1,7 +1,7 @@ use std::io::Cursor; -use axum::{routing::get, Router}; -use axum_extra::{headers::Range, TypedHeader}; +use axum::{Router, routing::get}; +use axum_extra::{TypedHeader, headers::Range}; use axum_range::{KnownSize, Ranged}; use super::Context; From a57e301d1683962a23f9b88283d3029465d2ce36 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 17 Jun 2025 11:09:18 +0200 Subject: [PATCH 3/3] 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. --- Cargo.lock | 1 + Justfile | 2 +- otter/Cargo.toml | 1 + otter/src/server/web/mod.rs | 21 ++++++++++-- otter/src/server/web/sessions.rs | 8 +++-- otter/src/web/mod.rs | 4 ++- otter/src/web/query.rs | 37 +++++++++++++++++++++ otter/src/web/templates/views/sessions.html | 8 +++++ otter/src/web/view.rs | 12 ++++--- 9 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 otter/src/web/query.rs diff --git a/Cargo.lock b/Cargo.lock index 630c663..bc750cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1260,6 +1260,7 @@ dependencies = [ "http-body-util", "rand", "serde", + "serde_urlencoded", "tera", "tokio", "tower-http", diff --git a/Justfile b/Justfile index c19ef20..1280487 100644 --- a/Justfile +++ b/Justfile @@ -46,7 +46,7 @@ run: --log debug doc: - cargo doc --workspace --frozen + cargo doc --workspace --frozen --open publish-release-binaries tag: build-release-static curl \ diff --git a/otter/Cargo.toml b/otter/Cargo.toml index 5b98adc..0c3bf8f 100644 --- a/otter/Cargo.toml +++ b/otter/Cargo.toml @@ -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" diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index f924f9b..dac3229 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -13,7 +13,7 @@ use cookie::{Cookie, time::Duration}; use gpodder::{AuthErr, Session}; use serde::{Deserialize, Serialize}; -use crate::web::{Page, TemplateExt, TemplateResponse, View}; +use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; use super::{ Context, @@ -22,7 +22,7 @@ use super::{ const SESSION_ID_COOKIE: &str = "sessionid"; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Clone)] #[serde(default)] pub struct Pagination { page: u32, @@ -47,6 +47,23 @@ impl Default for Pagination { } } +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 { Router::new() .route("/", get(get_index)) diff --git a/otter/src/server/web/sessions.rs b/otter/src/server/web/sessions.rs index 9020bab..611372a 100644 --- a/otter/src/server/web/sessions.rs +++ b/otter/src/server/web/sessions.rs @@ -7,7 +7,7 @@ use axum::{ use crate::{ server::{Context, error::AppResult}, - web::{Page, TemplateExt, TemplateResponse, View}, + web::{Page, TemplateExt, TemplateResponse, ToQuery, View}, }; pub fn router(ctx: Context) -> Router { @@ -25,12 +25,16 @@ pub async fn get_sessions( Extension(user): Extension, Query(page): Query, ) -> AppResult>> { + let next_page = page.next_page(); let sessions = tokio::task::spawn_blocking(move || ctx.store.paginated_sessions(&user, page.into())) .await .unwrap()?; - Ok(View::Sessions(sessions) + let next_page_query = + (sessions.len() == next_page.per_page as usize).then_some(next_page.to_query()); + + Ok(View::Sessions(sessions, next_page_query) .page(&headers) .headers(&headers) .authenticated(true) diff --git a/otter/src/web/mod.rs b/otter/src/web/mod.rs index 05d66e2..f99ff53 100644 --- a/otter/src/web/mod.rs +++ b/otter/src/web/mod.rs @@ -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"; @@ -80,7 +82,7 @@ pub fn initialize_tera() -> tera::Result { include_str!("templates/views/login.html"), ), ( - View::Sessions(Vec::new()).template(), + View::Sessions(Vec::new(), None).template(), include_str!("templates/views/sessions.html"), ), ])?; diff --git a/otter/src/web/query.rs b/otter/src/web/query.rs new file mode 100644 index 0000000..409afd5 --- /dev/null +++ b/otter/src/web/query.rs @@ -0,0 +1,37 @@ +/// 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 + } +} + +/// 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 + } +} diff --git a/otter/src/web/templates/views/sessions.html b/otter/src/web/templates/views/sessions.html index f160d41..1a59e34 100644 --- a/otter/src/web/templates/views/sessions.html +++ b/otter/src/web/templates/views/sessions.html @@ -16,5 +16,13 @@ Remove {% endfor %} + {% if next_page_query %} + + {% endif %} diff --git a/otter/src/web/view.rs b/otter/src/web/view.rs index e1a0f59..5636a16 100644 --- a/otter/src/web/view.rs +++ b/otter/src/web/view.rs @@ -1,12 +1,12 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -use super::Template; +use super::{Query, Template}; pub enum View { Index, Login, - Sessions(Vec), + Sessions(Vec, Option), } #[derive(Serialize)] @@ -20,7 +20,7 @@ impl Template for View { match self { Self::Index => "views/index.html", Self::Login => "views/login.html", - Self::Sessions(_) => "views/sessions.html", + Self::Sessions(..) => "views/sessions.html", } } @@ -29,11 +29,15 @@ impl Template for View { let template = self.template(); match self { - Self::Sessions(sessions) => { + Self::Sessions(sessions, query) => { ctx.insert( "sessions", &sessions.into_iter().map(Session::from).collect::>(), ); + + if let Some(query) = query { + ctx.insert("next_page_query", &query.encode()); + } } _ => {} };