Compare commits

...

3 Commits

Author SHA1 Message Date
Jef Roosens a57e301d16
feat(server): implement infinite scroll table for sessions page
A query type is introduced along with the ToQuery trait to convert types
into queries. A query can then be properly formatted as a URL query
parameter string, allowing us to pass arbitrary safely typed query
parameters to the Tera templates. This is then used by HTMX to request
the next page of content once the last row of a table is visible.
2025-06-17 11:09:18 +02:00
Jef Roosens 68b2b1beb4
chore: format code 2025-06-17 09:53:50 +02:00
Jef Roosens e8e0c94937
feat(server): partial implementation of session page pagination 2025-06-17 09:52:47 +02:00
22 changed files with 213 additions and 64 deletions

1
Cargo.lock generated
View File

@ -1260,6 +1260,7 @@ dependencies = [
"http-body-util", "http-body-util",
"rand", "rand",
"serde", "serde",
"serde_urlencoded",
"tera", "tera",
"tokio", "tokio",
"tower-http", "tower-http",

View File

@ -46,7 +46,7 @@ run:
--log debug --log debug
doc: doc:
cargo doc --workspace --frozen cargo doc --workspace --frozen --open
publish-release-binaries tag: build-release-static publish-release-binaries tag: build-release-static
curl \ curl \

View File

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

View File

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

View File

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

View File

@ -1,20 +1,20 @@
use axum::{ use axum::{
Router,
extract::{Path, State}, extract::{Path, State},
routing::post, routing::post,
Router,
}; };
use axum_extra::{ use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization, UserAgent},
TypedHeader, TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, UserAgent, authorization::Basic},
}; };
use cookie::time::Duration; use cookie::time::Duration;
use gpodder::AuthErr; use gpodder::AuthErr;
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::SESSION_ID_COOKIE, gpodder::SESSION_ID_COOKIE,
Context,
}; };
pub fn router() -> Router<Context> { pub fn router() -> Router<Context> {

View File

@ -1,18 +1,18 @@
use axum::{ use axum::{
Extension, Json, Router,
extract::{Path, State}, extract::{Path, State},
middleware, middleware,
routing::{get, post}, routing::{get, post},
Extension, Json, Router,
}; };
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::{ gpodder::{
auth_api_middleware, auth_api_middleware,
format::{Format, StringWithFormat}, format::{Format, StringWithFormat},
models, models,
}, },
Context,
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,13 +1,14 @@
use axum::{ use axum::{
Extension, Json, Router,
extract::{Path, Query, State}, extract::{Path, Query, State},
middleware, middleware,
routing::post, routing::post,
Extension, Json, Router,
}; };
use chrono::DateTime; use chrono::DateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::{ gpodder::{
auth_api_middleware, auth_api_middleware,
@ -15,7 +16,6 @@ use crate::server::{
models, models,
models::UpdatedUrlsResponse, models::UpdatedUrlsResponse,
}, },
Context,
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,19 +1,19 @@
use axum::{ use axum::{
Extension, Json, Router,
extract::{Path, Query, State}, extract::{Path, Query, State},
middleware, middleware,
routing::post, routing::post,
Extension, Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::{ gpodder::{
auth_api_middleware, auth_api_middleware,
format::{Format, StringWithFormat}, format::{Format, StringWithFormat},
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse}, models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
}, },
Context,
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,18 +1,18 @@
use axum::{ use axum::{
Extension, Json, Router,
extract::{Path, State}, extract::{Path, State},
middleware, middleware,
routing::get, routing::get,
Extension, Json, Router,
}; };
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::{ gpodder::{
auth_api_middleware, auth_api_middleware,
format::{Format, StringWithFormat}, format::{Format, StringWithFormat},
models::{SyncStatus, SyncStatusDelta}, models::{SyncStatus, SyncStatusDelta},
}, },
Context,
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {

View File

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

View File

@ -4,16 +4,16 @@ mod models;
mod simple; mod simple;
use axum::{ use axum::{
RequestExt, Router,
extract::{Request, State}, extract::{Request, State},
http::{header::WWW_AUTHENTICATE, HeaderName, HeaderValue, StatusCode}, http::{HeaderName, HeaderValue, StatusCode, header::WWW_AUTHENTICATE},
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
RequestExt, Router,
}; };
use axum_extra::{ use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
TypedHeader, TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, authorization::Basic},
}; };
use tower_http::set_header::SetResponseHeaderLayer; use tower_http::set_header::SetResponseHeaderLayer;

View File

@ -1,14 +1,14 @@
use axum::{ use axum::{
Extension, Json, Router,
extract::{Path, State}, extract::{Path, State},
middleware, middleware,
routing::get, routing::get,
Extension, Json, Router,
}; };
use crate::server::{ use crate::server::{
Context,
error::{AppError, AppResult}, error::{AppError, AppResult},
gpodder::{auth_api_middleware, format::StringWithFormat}, gpodder::{auth_api_middleware, format::StringWithFormat},
Context,
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {

View File

@ -6,12 +6,12 @@ mod web;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Router,
body::Body, body::Body,
extract::Request, extract::Request,
http::StatusCode, http::StatusCode,
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Router,
}; };
use http_body_util::BodyExt; use http_body_util::BodyExt;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;

View File

@ -1,7 +1,7 @@
use std::io::Cursor; use std::io::Cursor;
use axum::{routing::get, Router}; use axum::{Router, routing::get};
use axum_extra::{headers::Range, TypedHeader}; use axum_extra::{TypedHeader, headers::Range};
use axum_range::{KnownSize, Ranged}; use axum_range::{KnownSize, Ranged};
use super::Context; use super::Context;

View File

@ -1,17 +1,19 @@
mod sessions;
use axum::{ use axum::{
Form, RequestExt, Router, Form, RequestExt, Router,
extract::{Request, State}, extract::{Request, State},
http::{HeaderMap, HeaderName, HeaderValue, header}, http::HeaderMap,
middleware::{self, Next}, middleware::Next,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
}; };
use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent}; use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
use cookie::{Cookie, time::Duration}; use cookie::{Cookie, time::Duration};
use gpodder::{AuthErr, Session}; use gpodder::{AuthErr, Session};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::web::{Page, TemplateExt, TemplateResponse, View}; use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View};
use super::{ use super::{
Context, Context,
@ -20,13 +22,50 @@ use super::{
const SESSION_ID_COOKIE: &str = "sessionid"; 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: 1,
}
}
}
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> { pub fn router(ctx: Context) -> Router<Context> {
Router::new() Router::new()
.route("/sessions", get(get_sessions))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
auth_web_middleware,
))
.route("/", get(get_index)) .route("/", get(get_index))
// .layer(middleware::from_fn_with_state( // .layer(middleware::from_fn_with_state(
// ctx.clone(), // ctx.clone(),
@ -36,6 +75,7 @@ pub fn router(ctx: Context) -> Router<Context> {
// loop // loop
.route("/login", get(get_login).post(post_login)) .route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout)) .route("/logout", post(post_logout))
.merge(sessions::router(ctx.clone()))
} }
async fn get_index( async fn get_index(
@ -166,14 +206,3 @@ pub async fn auth_web_middleware(
Err(err) => err.into_response(), Err(err) => err.into_response(),
} }
} }
pub async fn get_sessions(
State(ctx): State<Context>,
headers: HeaderMap,
) -> TemplateResponse<Page<View>> {
View::Sessions(Vec::new())
.page(&headers)
.headers(&headers)
.authenticated(true)
.response(&ctx.tera)
}

View File

@ -0,0 +1,42 @@
use axum::{
Extension, Router,
extract::{Query, State},
http::HeaderMap,
routing::get,
};
use crate::{
server::{Context, error::AppResult},
web::{Page, TemplateExt, TemplateResponse, ToQuery, View},
};
pub fn router(ctx: Context) -> Router<Context> {
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<Context>,
headers: HeaderMap,
Extension(user): Extension<gpodder::User>,
Query(page): Query<super::Pagination>,
) -> AppResult<TemplateResponse<Page<View>>> {
let next_page = page.next_page();
let sessions =
tokio::task::spawn_blocking(move || ctx.store.paginated_sessions(&user, 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, next_page_query)
.page(&headers)
.headers(&headers)
.authenticated(true)
.response(&ctx.tera))
}

View File

@ -1,4 +1,5 @@
mod page; mod page;
mod query;
mod view; mod view;
use std::sync::Arc; use std::sync::Arc;
@ -10,6 +11,7 @@ use axum::{
}; };
pub use page::Page; pub use page::Page;
pub use query::{Query, ToQuery};
pub use view::View; pub use view::View;
const BASE_TEMPLATE: &str = "base.html"; const BASE_TEMPLATE: &str = "base.html";
@ -22,7 +24,7 @@ pub trait Template {
/// Render the template using the given Tera instance. /// Render the template using the given Tera instance.
/// ///
/// Templates are expected to manage their own context requirements if needed. /// 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 /// Useful additional functions on sized Template implementors
@ -80,7 +82,7 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
include_str!("templates/views/login.html"), include_str!("templates/views/login.html"),
), ),
( (
View::Sessions(Vec::new()).template(), View::Sessions(Vec::new(), None).template(),
include_str!("templates/views/sessions.html"), include_str!("templates/views/sessions.html"),
), ),
])?; ])?;

View File

@ -18,7 +18,7 @@ impl<T: Template> Template for Page<T> {
self.template.template() 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)?; let inner = self.template.render(tera)?;
if self.wrap_with_base { if self.wrap_with_base {

View File

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

View File

@ -9,10 +9,20 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in sessions %}
<tr> <tr>
<th>Firefox</th> <th>{{ session.user_agent }}</th>
<th>yesterday</th> <th>{{ session.last_seen }}</th>
<th>Remove</th> <th>Remove</th>
</tr> </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> </tbody>
</table> </table>

View File

@ -1,9 +1,18 @@
use super::Template; use chrono::{DateTime, Utc};
use serde::Serialize;
use super::{Query, Template};
pub enum View { pub enum View {
Index, Index,
Login, Login,
Sessions(Vec<gpodder::Session>), Sessions(Vec<gpodder::Session>, Option<Query>),
}
#[derive(Serialize)]
struct Session {
user_agent: Option<String>,
last_seen: DateTime<Utc>,
} }
impl Template for View { impl Template for View {
@ -11,19 +20,37 @@ impl Template for View {
match self { match self {
Self::Index => "views/index.html", Self::Index => "views/index.html",
Self::Login => "views/login.html", Self::Login => "views/login.html",
Self::Sessions(_) => "views/sessions.html", Self::Sessions(..) => "views/sessions.html",
} }
} }
fn render(&self, tera: &tera::Tera) -> tera::Result<String> { fn render(self, tera: &tera::Tera) -> tera::Result<String> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
let template = self.template();
// match self { match self {
// Self::Sessions(sessions) => { Self::Sessions(sessions, query) => {
// ctx.insert("sessions", sessions); ctx.insert(
// } "sessions",
// }; &sessions.into_iter().map(Session::from).collect::<Vec<_>>(),
);
tera.render(self.template(), &ctx) if let Some(query) = query {
ctx.insert("next_page_query", &query.encode());
}
}
_ => {}
};
tera.render(template, &ctx)
}
}
impl From<gpodder::Session> for Session {
fn from(value: gpodder::Session) -> Self {
Self {
user_agent: value.user_agent,
last_seen: value.last_seen,
}
} }
} }