Compare commits

..

No commits in common. "a57e301d1683962a23f9b88283d3029465d2ce36" and "32d70daab2c6227d2d2d17f4b57764992e3af39f" have entirely different histories.

22 changed files with 64 additions and 213 deletions

1
Cargo.lock generated
View File

@ -1260,7 +1260,6 @@ 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 --open cargo doc --workspace --frozen
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,4 +25,3 @@ 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::{
Figment,
providers::{Env, Format, Serialized, Toml}, providers::{Env, Format, Serialized, Toml},
Figment,
}; };
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,14 +1,13 @@
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,
@ -16,6 +15,7 @@ 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::{HeaderName, HeaderValue, StatusCode, header::WWW_AUTHENTICATE}, http::{header::WWW_AUTHENTICATE, HeaderName, HeaderValue, StatusCode},
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::{Router, routing::get}; use axum::{routing::get, Router};
use axum_extra::{TypedHeader, headers::Range}; use axum_extra::{headers::Range, TypedHeader};
use axum_range::{KnownSize, Ranged}; use axum_range::{KnownSize, Ranged};
use super::Context; use super::Context;

View File

@ -1,19 +1,17 @@
mod sessions;
use axum::{ use axum::{
Form, RequestExt, Router, Form, RequestExt, Router,
extract::{Request, State}, extract::{Request, State},
http::HeaderMap, http::{HeaderMap, HeaderName, HeaderValue, header},
middleware::Next, middleware::{self, 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, Serialize}; use serde::Deserialize;
use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; use crate::web::{Page, TemplateExt, TemplateResponse, View};
use super::{ use super::{
Context, Context,
@ -22,50 +20,13 @@ 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(),
@ -75,7 +36,6 @@ 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(
@ -206,3 +166,14 @@ 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

@ -1,42 +0,0 @@
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,5 +1,4 @@
mod page; mod page;
mod query;
mod view; mod view;
use std::sync::Arc; use std::sync::Arc;
@ -11,7 +10,6 @@ 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";
@ -24,7 +22,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
@ -82,7 +80,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(), None).template(), View::Sessions(Vec::new()).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

@ -1,37 +0,0 @@
/// 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,20 +9,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in sessions %}
<tr> <tr>
<th>{{ session.user_agent }}</th> <th>Firefox</th>
<th>{{ session.last_seen }}</th> <th>yesterday</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,18 +1,9 @@
use chrono::{DateTime, Utc}; use super::Template;
use serde::Serialize;
use super::{Query, Template};
pub enum View { pub enum View {
Index, Index,
Login, Login,
Sessions(Vec<gpodder::Session>, Option<Query>), Sessions(Vec<gpodder::Session>),
}
#[derive(Serialize)]
struct Session {
user_agent: Option<String>,
last_seen: DateTime<Utc>,
} }
impl Template for View { impl Template for View {
@ -20,37 +11,19 @@ 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, query) => { // Self::Sessions(sessions) => {
ctx.insert( // ctx.insert("sessions", sessions);
"sessions", // }
&sessions.into_iter().map(Session::from).collect::<Vec<_>>(), // };
);
if let Some(query) = query { tera.render(self.template(), &ctx)
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,
}
} }
} }