Compare commits
3 Commits
32d70daab2
...
a57e301d16
Author | SHA1 | Date |
---|---|---|
|
a57e301d16 | |
|
68b2b1beb4 | |
|
e8e0c94937 |
|
@ -1260,6 +1260,7 @@ dependencies = [
|
|||
"http-body-util",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_urlencoded",
|
||||
"tera",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
|
|
2
Justfile
2
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 \
|
||||
|
|
10
otter.toml
10
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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<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> {
|
||||
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<Context> {
|
|||
// 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<Context>,
|
||||
headers: HeaderMap,
|
||||
) -> TemplateResponse<Page<View>> {
|
||||
View::Sessions(Vec::new())
|
||||
.page(&headers)
|
||||
.headers(&headers)
|
||||
.authenticated(true)
|
||||
.response(&ctx.tera)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
|
@ -80,7 +82,7 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
|
|||
include_str!("templates/views/login.html"),
|
||||
),
|
||||
(
|
||||
View::Sessions(Vec::new()).template(),
|
||||
View::Sessions(Vec::new(), None).template(),
|
||||
include_str!("templates/views/sessions.html"),
|
||||
),
|
||||
])?;
|
||||
|
|
|
@ -18,7 +18,7 @@ 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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -9,10 +9,20 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in sessions %}
|
||||
<tr>
|
||||
<th>Firefox</th>
|
||||
<th>yesterday</th>
|
||||
<th>{{ session.user_agent }}</th>
|
||||
<th>{{ session.last_seen }}</th>
|
||||
<th>Remove</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>
|
||||
|
|
|
@ -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<gpodder::Session>),
|
||||
Sessions(Vec<gpodder::Session>, Option<Query>),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Session {
|
||||
user_agent: Option<String>,
|
||||
last_seen: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
fn render(self, tera: &tera::Tera) -> tera::Result<String> {
|
||||
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::<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue