Compare commits

...

2 Commits

6 changed files with 57 additions and 15 deletions

View File

@ -10,7 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
* Web UI * Web UI
* Started development based on HTMX and PicoCSS
* Very simple homepage
* Login/logout button * Login/logout button
* Page for managing logged-in sessions
## [0.1.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.1.0) ## [0.1.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.1.0)

View File

@ -42,7 +42,7 @@ impl Default for Pagination {
fn default() -> Self { fn default() -> Self {
Self { Self {
page: 0, page: 0,
per_page: 1, per_page: 25,
} }
} }
} }
@ -198,7 +198,7 @@ pub async fn auth_web_middleware(
match extract_session(ctx, &jar).await { match extract_session(ctx, &jar).await {
Ok(Some(session)) => { Ok(Some(session)) => {
req.extensions_mut().insert(session.user); req.extensions_mut().insert(session);
next.run(req).await next.run(req).await
} }

View File

@ -1,18 +1,22 @@
use axum::{ use axum::{
Extension, Router, Extension, Router,
extract::{Query, State}, extract::{Path, Query, State},
http::HeaderMap, http::HeaderMap,
routing::get, routing::{delete, get},
}; };
use crate::{ use crate::{
server::{Context, error::AppResult}, server::{
Context,
error::{AppError, AppResult},
},
web::{Page, TemplateExt, TemplateResponse, ToQuery, View}, web::{Page, TemplateExt, TemplateResponse, ToQuery, View},
}; };
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {
Router::new() Router::new()
.route("/sessions", get(get_sessions)) .route("/sessions", get(get_sessions))
.route("/sessions/{id}", delete(delete_session))
.route_layer(axum::middleware::from_fn_with_state( .route_layer(axum::middleware::from_fn_with_state(
ctx.clone(), ctx.clone(),
super::auth_web_middleware, super::auth_web_middleware,
@ -22,21 +26,45 @@ pub fn router(ctx: Context) -> Router<Context> {
pub async fn get_sessions( pub async fn get_sessions(
State(ctx): State<Context>, State(ctx): State<Context>,
headers: HeaderMap, headers: HeaderMap,
Extension(user): Extension<gpodder::User>, Extension(session): Extension<gpodder::Session>,
Query(page): Query<super::Pagination>, Query(page): Query<super::Pagination>,
) -> AppResult<TemplateResponse<Page<View>>> { ) -> AppResult<TemplateResponse<Page<View>>> {
let next_page = page.next_page(); let next_page = page.next_page();
let sessions = let sessions = tokio::task::spawn_blocking(move || {
tokio::task::spawn_blocking(move || ctx.store.paginated_sessions(&user, page.into())) ctx.store.paginated_sessions(&session.user, page.into())
.await })
.unwrap()?; .await
.unwrap()?;
let next_page_query = let next_page_query =
(sessions.len() == next_page.per_page as usize).then_some(next_page.to_query()); (sessions.len() == next_page.per_page as usize).then_some(next_page.to_query());
Ok(View::Sessions(sessions, next_page_query) Ok(View::Sessions(sessions, session.id, next_page_query)
.page(&headers) .page(&headers)
.headers(&headers) .headers(&headers)
.authenticated(true) .authenticated(true)
.response(&ctx.tera)) .response(&ctx.tera))
} }
pub async fn delete_session(
State(ctx): State<Context>,
Extension(session): Extension<gpodder::Session>,
Path(id): Path<i64>,
) -> AppResult<()> {
tokio::task::spawn_blocking(move || {
let other_session = ctx.store.get_session(id)?;
// Check to ensure a user can't remove a session that's not theirs
if session.user.id != other_session.user.id {
return Err(AppError::Unauthorized);
}
ctx.store.remove_session(session.id)?;
Ok(())
})
.await
.unwrap()?;
Ok(())
}

View File

@ -82,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(), None).template(), View::Sessions(Vec::new(), 0, None).template(),
include_str!("templates/views/sessions.html"), include_str!("templates/views/sessions.html"),
), ),
])?; ])?;

View File

@ -13,7 +13,15 @@
<tr> <tr>
<th>{{ session.user_agent }}</th> <th>{{ session.user_agent }}</th>
<th>{{ session.last_seen }}</th> <th>{{ session.last_seen }}</th>
<th>Remove</th> <th>
{%- if session.id != current_session_id -%}
<a hx-delete="/sessions/{{ session.id }}"
hx-target="closest tr"
>Remove</a>
{%- else -%}
Current session
{%- endif -%}
</th>
</tr> </tr>
{% endfor %} {% endfor %}
{% if next_page_query %} {% if next_page_query %}

View File

@ -6,11 +6,12 @@ use super::{Query, Template};
pub enum View { pub enum View {
Index, Index,
Login, Login,
Sessions(Vec<gpodder::Session>, Option<Query>), Sessions(Vec<gpodder::Session>, i64, Option<Query>),
} }
#[derive(Serialize)] #[derive(Serialize)]
struct Session { struct Session {
id: i64,
user_agent: Option<String>, user_agent: Option<String>,
last_seen: DateTime<Utc>, last_seen: DateTime<Utc>,
} }
@ -29,11 +30,12 @@ impl Template for View {
let template = self.template(); let template = self.template();
match self { match self {
Self::Sessions(sessions, query) => { Self::Sessions(sessions, current_session_id, query) => {
ctx.insert( ctx.insert(
"sessions", "sessions",
&sessions.into_iter().map(Session::from).collect::<Vec<_>>(), &sessions.into_iter().map(Session::from).collect::<Vec<_>>(),
); );
ctx.insert("current_session_id", &current_session_id);
if let Some(query) = query { if let Some(query) = query {
ctx.insert("next_page_query", &query.encode()); ctx.insert("next_page_query", &query.encode());
@ -49,6 +51,7 @@ impl Template for View {
impl From<gpodder::Session> for Session { impl From<gpodder::Session> for Session {
fn from(value: gpodder::Session) -> Self { fn from(value: gpodder::Session) -> Self {
Self { Self {
id: value.id,
user_agent: value.user_agent, user_agent: value.user_agent,
last_seen: value.last_seen, last_seen: value.last_seen,
} }