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.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/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/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; diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index b8ec379..dac3229 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -1,17 +1,19 @@ +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}; +use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; use super::{ Context, @@ -20,13 +22,50 @@ use super::{ const SESSION_ID_COOKIE: &str = "sessionid"; +#[derive(Deserialize, Clone)] +#[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, + } + } +} + +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("/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 +75,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 +206,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..611372a --- /dev/null +++ b/otter/src/server/web/sessions.rs @@ -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 { + 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 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)) +} diff --git a/otter/src/web/mod.rs b/otter/src/web/mod.rs index 0035779..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"; @@ -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; + fn render(self, tera: &tera::Tera) -> tera::Result; } /// Useful additional functions on sized Template implementors @@ -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/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/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 679a565..1a59e34 100644 --- a/otter/src/web/templates/views/sessions.html +++ b/otter/src/web/templates/views/sessions.html @@ -9,10 +9,20 @@ + {% for session in sessions %} - Firefox - yesterday + {{ session.user_agent }} + {{ session.last_seen }} Remove + {% endfor %} + {% if next_page_query %} + + {% endif %} diff --git a/otter/src/web/view.rs b/otter/src/web/view.rs index 4f15834..5636a16 100644 --- a/otter/src/web/view.rs +++ b/otter/src/web/view.rs @@ -1,9 +1,18 @@ -use super::Template; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use super::{Query, Template}; pub enum View { Index, Login, - Sessions(Vec), + Sessions(Vec, Option), +} + +#[derive(Serialize)] +struct Session { + user_agent: Option, + last_seen: DateTime, } impl Template for View { @@ -11,19 +20,37 @@ 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", } } - 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, query) => { + ctx.insert( + "sessions", + &sessions.into_iter().map(Session::from).collect::>(), + ); - tera.render(self.template(), &ctx) + if let Some(query) = query { + ctx.insert("next_page_query", &query.encode()); + } + } + _ => {} + }; + + 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, + } } }