From 244397274c548a5121f96ccb471807d4079a1fb8 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Fri, 3 Apr 2026 19:49:10 +0200 Subject: [PATCH] feat: add server-rendered web UI for domain owner management --- Cargo.toml | 1 - src/handler/mod.rs | 11 +- src/lib.rs | 1 + src/state.rs | 9 ++ src/ui/handlers.rs | 267 ++++++++++++++++++++++++++++++++ src/ui/mod.rs | 9 ++ src/ui/templates.rs | 66 ++++++++ templates/dashboard.html | 23 +++ templates/domain_detail.html | 17 ++ templates/layout.html | 38 +++++ templates/link_browser.html | 25 +++ templates/login.html | 15 ++ templates/token_management.html | 25 +++ tests/common/mod.rs | 6 +- 14 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 src/ui/handlers.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/templates.rs create mode 100644 templates/dashboard.html create mode 100644 templates/domain_detail.html create mode 100644 templates/layout.html create mode 100644 templates/link_browser.html create mode 100644 templates/login.html create mode 100644 templates/token_management.html diff --git a/Cargo.toml b/Cargo.toml index 52f0fbc..a3d4218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ sea-orm-migration = "1" dashmap = "6" governor = "0.8" askama = "0.12" -askama_axum = "0.4" argon2 = "0.5" config = "0.14" serde = { version = "1", features = ["derive"] } diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 7435a86..49780dd 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -42,10 +42,15 @@ pub fn router(state: AppState) -> Router { rate_limit::rate_limit_by_token(limiter, req, next) })); - Router::new() + let mut app = Router::new() .merge(public_routes) .merge(api_routes) .merge(health::router()) - .merge(metrics::router()) - .with_state(state) + .merge(metrics::router()); + + if state.settings.ui.enabled { + app = app.merge(crate::ui::router()); + } + + app.with_state(state) } diff --git a/src/lib.rs b/src/lib.rs index 4d52411..cdc624d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub mod handler; pub mod middleware; pub mod reaper; pub mod state; +pub mod ui; diff --git a/src/state.rs b/src/state.rs index 8e218da..d08e722 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,5 @@ +use axum::extract::FromRef; +use axum_extra::extract::cookie::Key; use metrics_exporter_prometheus::PrometheusHandle; use sea_orm::DatabaseConnection; use std::sync::Arc; @@ -13,4 +15,11 @@ pub struct AppState { pub settings: Arc, pub challenge_verifier: Arc, pub metrics_handle: PrometheusHandle, + pub cookie_key: Key, +} + +impl FromRef for Key { + fn from_ref(state: &AppState) -> Self { + state.cookie_key.clone() + } } diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs new file mode 100644 index 0000000..4ef0d18 --- /dev/null +++ b/src/ui/handlers.rs @@ -0,0 +1,267 @@ +use axum::extract::{Path, State}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::Router; +use axum_extra::extract::cookie::{Cookie, SignedCookieJar}; +use sea_orm::*; +use serde::Deserialize; + +use crate::auth; +use crate::entity::{domains, links, resources, service_tokens}; +use crate::state::AppState; +use crate::ui::templates::*; + +const SESSION_COOKIE: &str = "webfingerd_session"; + +/// Extract domain ID from signed session cookie. +fn session_domain_id(jar: &SignedCookieJar) -> Option { + jar.get(SESSION_COOKIE).map(|c| c.value().to_string()) +} + +async fn login_page(State(_state): State, jar: SignedCookieJar) -> Response { + let jar = jar.clone(); + // If already logged in, redirect to dashboard + if session_domain_id(&jar).is_some() { + return Redirect::to("/ui/dashboard").into_response(); + } + + let template = LoginTemplate { error: None }; + Html(template.to_string()).into_response() +} + +#[derive(Deserialize)] +struct LoginForm { + token: String, +} + +async fn login_submit( + State(state): State, + jar: SignedCookieJar, + axum::Form(form): axum::Form, +) -> Response { + let jar = jar.clone(); + + // Parse token to get domain ID + let Some((domain_id, _)) = auth::split_token(&form.token) else { + let template = LoginTemplate { + error: Some("Invalid token format".into()), + }; + return Html(template.to_string()).into_response(); + }; + + // Look up domain and verify token + let domain = match domains::Entity::find_by_id(domain_id) + .one(&state.db) + .await + { + Ok(Some(d)) if d.verified => d, + _ => { + let template = LoginTemplate { + error: Some("Invalid token or domain not verified".into()), + }; + return Html(template.to_string()).into_response(); + } + }; + + if !auth::verify_token(&form.token, &domain.owner_token_hash) { + let template = LoginTemplate { + error: Some("Invalid token".into()), + }; + return Html(template.to_string()).into_response(); + } + + // Set session cookie with the owner token (so we can authenticate subsequent requests) + let cookie = Cookie::build((SESSION_COOKIE, form.token)) + .path("/ui") + .http_only(true) + .build(); + + let jar = jar.add(cookie); + (jar, Redirect::to("/ui/dashboard")).into_response() +} + +async fn logout(jar: SignedCookieJar) -> Response { + let jar = jar.clone(); + let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + (jar, Redirect::to("/ui/login")).into_response() +} + +async fn dashboard(State(state): State, jar: SignedCookieJar) -> Response { + let jar = jar.clone(); + let Some(token) = jar.get(SESSION_COOKIE).map(|c| c.value().to_string()) else { + return Redirect::to("/ui/login").into_response(); + }; + + let Some((domain_id, _)) = auth::split_token(&token) else { + return Redirect::to("/ui/login").into_response(); + }; + + // Get the domain owned by this token + let domain = match domains::Entity::find_by_id(domain_id) + .one(&state.db) + .await + { + Ok(Some(d)) if d.verified && auth::verify_token(&token, &d.owner_token_hash) => d, + _ => return Redirect::to("/ui/login").into_response(), + }; + + // Count links for this domain + let link_count = links::Entity::find() + .filter(links::Column::DomainId.eq(&domain.id)) + .count(&state.db) + .await + .unwrap_or(0); + + let template = DashboardTemplate { + domains: vec![DomainSummary { + id: domain.id, + domain: domain.domain, + verified: domain.verified, + link_count, + }], + }; + + Html(template.to_string()).into_response() +} + +/// Helper to authenticate session and return (domain_model, token_string). +async fn authenticate_session( + state: &AppState, + jar: &SignedCookieJar, +) -> Option<(domains::Model, String)> { + let token = jar.get(SESSION_COOKIE).map(|c| c.value().to_string())?; + let (domain_id, _) = auth::split_token(&token)?; + + let domain = domains::Entity::find_by_id(domain_id) + .one(&state.db) + .await + .ok()??; + + if domain.verified && auth::verify_token(&token, &domain.owner_token_hash) { + Some((domain, token)) + } else { + None + } +} + +async fn domain_detail( + State(state): State, + jar: SignedCookieJar, + Path(id): Path, +) -> Response { + let jar = jar.clone(); + let Some((domain, _)) = authenticate_session(&state, &jar).await else { + return Redirect::to("/ui/login").into_response(); + }; + + if domain.id != id { + return Redirect::to("/ui/dashboard").into_response(); + } + + let template = DomainDetailTemplate { + domain: DomainInfo { + id: domain.id, + domain: domain.domain, + verified: domain.verified, + challenge_type: domain.challenge_type, + created_at: domain.created_at.to_string(), + }, + }; + + Html(template.to_string()).into_response() +} + +async fn token_management( + State(state): State, + jar: SignedCookieJar, + Path(id): Path, +) -> Response { + let jar = jar.clone(); + let Some((domain, _)) = authenticate_session(&state, &jar).await else { + return Redirect::to("/ui/login").into_response(); + }; + + if domain.id != id { + return Redirect::to("/ui/dashboard").into_response(); + } + + let tokens = service_tokens::Entity::find() + .filter(service_tokens::Column::DomainId.eq(&domain.id)) + .all(&state.db) + .await + .unwrap_or_default(); + + let template = TokenManagementTemplate { + domain_id: domain.id, + domain_name: domain.domain, + tokens: tokens + .into_iter() + .map(|t| TokenSummary { + name: t.name, + allowed_rels: t.allowed_rels, + resource_pattern: t.resource_pattern, + created_at: t.created_at.to_string(), + revoked: t.revoked_at.is_some(), + }) + .collect(), + }; + + Html(template.to_string()).into_response() +} + +async fn link_browser( + State(state): State, + jar: SignedCookieJar, + Path(id): Path, +) -> Response { + let jar = jar.clone(); + let Some((domain, _)) = authenticate_session(&state, &jar).await else { + return Redirect::to("/ui/login").into_response(); + }; + + if domain.id != id { + return Redirect::to("/ui/dashboard").into_response(); + } + + let domain_links = links::Entity::find() + .filter(links::Column::DomainId.eq(&domain.id)) + .find_also_related(resources::Entity) + .all(&state.db) + .await + .unwrap_or_default(); + + let template = LinkBrowserTemplate { + domain_id: domain.id, + domain_name: domain.domain, + links: domain_links + .into_iter() + .map(|(link, resource)| { + let resource_uri = resource + .map(|r| r.resource_uri) + .unwrap_or_else(|| "unknown".into()); + LinkSummary { + resource_uri, + rel: link.rel, + href: link.href.unwrap_or_default(), + link_type: link.link_type.unwrap_or_default(), + expires_at: link + .expires_at + .map(|e| e.to_string()) + .unwrap_or_else(|| "never".into()), + } + }) + .collect(), + }; + + Html(template.to_string()).into_response() +} + +pub fn router() -> Router { + Router::new() + .route("/ui/login", get(login_page).post(login_submit)) + .route("/ui/logout", get(logout)) + .route("/ui/dashboard", get(dashboard)) + .route("/ui/domains/{id}", get(domain_detail)) + .route("/ui/domains/{id}/tokens", get(token_management)) + .route("/ui/domains/{id}/links", get(link_browser)) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..f8216ef --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,9 @@ +pub mod handlers; +pub mod templates; + +use axum::Router; +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/src/ui/templates.rs b/src/ui/templates.rs new file mode 100644 index 0000000..b9aada7 --- /dev/null +++ b/src/ui/templates.rs @@ -0,0 +1,66 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "login.html")] +pub struct LoginTemplate { + pub error: Option, +} + +#[derive(Template)] +#[template(path = "dashboard.html")] +pub struct DashboardTemplate { + pub domains: Vec, +} + +pub struct DomainSummary { + pub id: String, + pub domain: String, + pub verified: bool, + pub link_count: u64, +} + +#[derive(Template)] +#[template(path = "domain_detail.html")] +pub struct DomainDetailTemplate { + pub domain: DomainInfo, +} + +pub struct DomainInfo { + pub id: String, + pub domain: String, + pub verified: bool, + pub challenge_type: String, + pub created_at: String, +} + +#[derive(Template)] +#[template(path = "token_management.html")] +pub struct TokenManagementTemplate { + pub domain_id: String, + pub domain_name: String, + pub tokens: Vec, +} + +pub struct TokenSummary { + pub name: String, + pub allowed_rels: String, + pub resource_pattern: String, + pub created_at: String, + pub revoked: bool, +} + +#[derive(Template)] +#[template(path = "link_browser.html")] +pub struct LinkBrowserTemplate { + pub domain_id: String, + pub domain_name: String, + pub links: Vec, +} + +pub struct LinkSummary { + pub resource_uri: String, + pub rel: String, + pub href: String, + pub link_type: String, + pub expires_at: String, +} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e355976 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,23 @@ +{% extends "layout.html" %} +{% block title %}Dashboard - webfingerd{% endblock %} +{% block nav %}Logout{% endblock %} +{% block content %} +

Your Domains

+{% if domains.is_empty() %} +

No domains found for this token.

+{% else %} + + + + {% for d in domains %} + + + + + + + {% endfor %} + +
DomainStatusLinks
{{ d.domain }}{% if d.verified %}Verified{% else %}Pending{% endif %}{{ d.link_count }}Manage
+{% endif %} +{% endblock %} diff --git a/templates/domain_detail.html b/templates/domain_detail.html new file mode 100644 index 0000000..e52210e --- /dev/null +++ b/templates/domain_detail.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} +{% block title %}{{ domain.domain }} - webfingerd{% endblock %} +{% block nav %}Dashboard | Logout{% endblock %} +{% block content %} +

{{ domain.domain }}

+
+

Status: {% if domain.verified %}Verified{% else %}Pending{% endif %}

+

Challenge Type: {{ domain.challenge_type }}

+

Created: {{ domain.created_at }}

+
+ +

Service Tokens

+

Manage Tokens

+ +

Links

+

Browse Links

+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..96d6b89 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,38 @@ + + + + + + {% block title %}webfingerd{% endblock %} + + + +
+

webfingerd

+ {% block nav %}{% endblock %} +
+ {% block content %}{% endblock %} + + diff --git a/templates/link_browser.html b/templates/link_browser.html new file mode 100644 index 0000000..0b53f23 --- /dev/null +++ b/templates/link_browser.html @@ -0,0 +1,25 @@ +{% extends "layout.html" %} +{% block title %}Links - {{ domain_name }} - webfingerd{% endblock %} +{% block nav %}Back | Logout{% endblock %} +{% block content %} +

Links for {{ domain_name }}

+ +{% if links.is_empty() %} +

No links registered yet.

+{% else %} + + + + {% for l in links %} + + + + + + + + {% endfor %} + +
ResourceRelHrefTypeExpires
{{ l.resource_uri }}{{ l.rel }}{{ l.href }}{{ l.link_type }}{{ l.expires_at }}
+{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..21053d2 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %} +{% block title %}Login - webfingerd{% endblock %} +{% block content %} +
+

Domain Owner Login

+ {% if let Some(error) = error %} +
{{ error }}
+ {% endif %} +
+ + + +
+
+{% endblock %} diff --git a/templates/token_management.html b/templates/token_management.html new file mode 100644 index 0000000..9d61864 --- /dev/null +++ b/templates/token_management.html @@ -0,0 +1,25 @@ +{% extends "layout.html" %} +{% block title %}Tokens - {{ domain_name }} - webfingerd{% endblock %} +{% block nav %}Back | Logout{% endblock %} +{% block content %} +

Service Tokens for {{ domain_name }}

+ +{% if tokens.is_empty() %} +

No service tokens yet.

+{% else %} + + + + {% for t in tokens %} + + + + + + + + {% endfor %} + +
NameAllowed RelsResource PatternCreatedStatus
{{ t.name }}{{ t.allowed_rels }}{{ t.resource_pattern }}{{ t.created_at }}{% if t.revoked %}Revoked{% else %}Active{% endif %}
+{% endif %} +{% endblock %} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8c86dde..8f5c2ee 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,4 @@ +use axum_extra::extract::cookie::Key; use metrics_exporter_prometheus::PrometheusBuilder; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use sea_orm_migration::MigratorTrait; @@ -45,7 +46,7 @@ pub fn test_settings() -> Settings { }, ui: UiConfig { enabled: false, - session_secret: "test-secret-at-least-32-bytes-long-for-signing".into(), + session_secret: "test-secret-that-must-be-at-least-sixty-four-bytes-long-for-cookie-signing-key-requirements".into(), }, } } @@ -67,11 +68,14 @@ pub async fn test_state_with_settings(settings: Settings) -> AppState { recorder.handle() }); + let cookie_key = Key::from(settings.ui.session_secret.as_bytes()); + AppState { db, cache, settings: Arc::new(settings), challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier), metrics_handle, + cookie_key, } }