feat: add server-rendered web UI for domain owner management

This commit is contained in:
Till Wegmueller 2026-04-03 19:49:10 +02:00
parent 820a6410c4
commit 244397274c
No known key found for this signature in database
14 changed files with 508 additions and 5 deletions

View file

@ -14,7 +14,6 @@ sea-orm-migration = "1"
dashmap = "6" dashmap = "6"
governor = "0.8" governor = "0.8"
askama = "0.12" askama = "0.12"
askama_axum = "0.4"
argon2 = "0.5" argon2 = "0.5"
config = "0.14" config = "0.14"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View file

@ -42,10 +42,15 @@ pub fn router(state: AppState) -> Router {
rate_limit::rate_limit_by_token(limiter, req, next) rate_limit::rate_limit_by_token(limiter, req, next)
})); }));
Router::new() let mut app = Router::new()
.merge(public_routes) .merge(public_routes)
.merge(api_routes) .merge(api_routes)
.merge(health::router()) .merge(health::router())
.merge(metrics::router()) .merge(metrics::router());
.with_state(state)
if state.settings.ui.enabled {
app = app.merge(crate::ui::router());
}
app.with_state(state)
} }

View file

@ -8,3 +8,4 @@ pub mod handler;
pub mod middleware; pub mod middleware;
pub mod reaper; pub mod reaper;
pub mod state; pub mod state;
pub mod ui;

View file

@ -1,3 +1,5 @@
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
use metrics_exporter_prometheus::PrometheusHandle; use metrics_exporter_prometheus::PrometheusHandle;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use std::sync::Arc; use std::sync::Arc;
@ -13,4 +15,11 @@ pub struct AppState {
pub settings: Arc<Settings>, pub settings: Arc<Settings>,
pub challenge_verifier: Arc<dyn ChallengeVerifier>, pub challenge_verifier: Arc<dyn ChallengeVerifier>,
pub metrics_handle: PrometheusHandle, pub metrics_handle: PrometheusHandle,
pub cookie_key: Key,
}
impl FromRef<AppState> for Key {
fn from_ref(state: &AppState) -> Self {
state.cookie_key.clone()
}
} }

267
src/ui/handlers.rs Normal file
View file

@ -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<String> {
jar.get(SESSION_COOKIE).map(|c| c.value().to_string())
}
async fn login_page(State(_state): State<AppState>, 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<AppState>,
jar: SignedCookieJar,
axum::Form(form): axum::Form<LoginForm>,
) -> 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<AppState>, 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<AppState>,
jar: SignedCookieJar,
Path(id): Path<String>,
) -> 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<AppState>,
jar: SignedCookieJar,
Path(id): Path<String>,
) -> 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<AppState>,
jar: SignedCookieJar,
Path(id): Path<String>,
) -> 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<AppState> {
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))
}

9
src/ui/mod.rs Normal file
View file

@ -0,0 +1,9 @@
pub mod handlers;
pub mod templates;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}

66
src/ui/templates.rs Normal file
View file

@ -0,0 +1,66 @@
use askama::Template;
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate {
pub error: Option<String>,
}
#[derive(Template)]
#[template(path = "dashboard.html")]
pub struct DashboardTemplate {
pub domains: Vec<DomainSummary>,
}
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<TokenSummary>,
}
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<LinkSummary>,
}
pub struct LinkSummary {
pub resource_uri: String,
pub rel: String,
pub href: String,
pub link_type: String,
pub expires_at: String,
}

23
templates/dashboard.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "layout.html" %}
{% block title %}Dashboard - webfingerd{% endblock %}
{% block nav %}<a href="/ui/logout">Logout</a>{% endblock %}
{% block content %}
<h2>Your Domains</h2>
{% if domains.is_empty() %}
<p>No domains found for this token.</p>
{% else %}
<table>
<thead><tr><th>Domain</th><th>Status</th><th>Links</th><th></th></tr></thead>
<tbody>
{% for d in domains %}
<tr>
<td><a href="/ui/domains/{{ d.id }}">{{ d.domain }}</a></td>
<td>{% if d.verified %}<span class="badge badge-green">Verified</span>{% else %}<span class="badge badge-yellow">Pending</span>{% endif %}</td>
<td>{{ d.link_count }}</td>
<td><a href="/ui/domains/{{ d.id }}">Manage</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "layout.html" %}
{% block title %}{{ domain.domain }} - webfingerd{% endblock %}
{% block nav %}<a href="/ui/dashboard">Dashboard</a> | <a href="/ui/logout">Logout</a>{% endblock %}
{% block content %}
<h2>{{ domain.domain }}</h2>
<div class="card">
<p><strong>Status:</strong> {% if domain.verified %}<span class="badge badge-green">Verified</span>{% else %}<span class="badge badge-yellow">Pending</span>{% endif %}</p>
<p><strong>Challenge Type:</strong> {{ domain.challenge_type }}</p>
<p><strong>Created:</strong> {{ domain.created_at }}</p>
</div>
<h3>Service Tokens</h3>
<p><a href="/ui/domains/{{ domain.id }}/tokens">Manage Tokens</a></p>
<h3>Links</h3>
<p><a href="/ui/domains/{{ domain.id }}/links">Browse Links</a></p>
{% endblock %}

38
templates/layout.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}webfingerd{% endblock %}</title>
<style>
:root { --bg: #fafafa; --fg: #222; --accent: #2563eb; --border: #ddd; --muted: #666; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); max-width: 960px; margin: 0 auto; padding: 1rem; }
header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin-bottom: 1.5rem; }
header h1 { font-size: 1.25rem; }
header a { color: var(--accent); text-decoration: none; }
a { color: var(--accent); }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
th { font-weight: 600; color: var(--muted); font-size: 0.875rem; }
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 0.875rem; }
.btn-danger { background: #dc2626; }
input, textarea { padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px; width: 100%; margin-bottom: 0.5rem; }
label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.875rem; }
.card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 1rem; margin-bottom: 1rem; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.flash { padding: 0.75rem; margin-bottom: 1rem; border-radius: 4px; }
.flash-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
.flash-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
</style>
</head>
<body>
<header>
<h1>webfingerd</h1>
{% block nav %}{% endblock %}
</header>
{% block content %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,25 @@
{% extends "layout.html" %}
{% block title %}Links - {{ domain_name }} - webfingerd{% endblock %}
{% block nav %}<a href="/ui/domains/{{ domain_id }}">Back</a> | <a href="/ui/logout">Logout</a>{% endblock %}
{% block content %}
<h2>Links for {{ domain_name }}</h2>
{% if links.is_empty() %}
<p>No links registered yet.</p>
{% else %}
<table>
<thead><tr><th>Resource</th><th>Rel</th><th>Href</th><th>Type</th><th>Expires</th></tr></thead>
<tbody>
{% for l in links %}
<tr>
<td>{{ l.resource_uri }}</td>
<td>{{ l.rel }}</td>
<td>{{ l.href }}</td>
<td>{{ l.link_type }}</td>
<td>{{ l.expires_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

15
templates/login.html Normal file
View file

@ -0,0 +1,15 @@
{% extends "layout.html" %}
{% block title %}Login - webfingerd{% endblock %}
{% block content %}
<div class="card" style="max-width: 400px; margin: 2rem auto;">
<h2 style="margin-bottom: 1rem;">Domain Owner Login</h2>
{% if let Some(error) = error %}
<div class="flash flash-error">{{ error }}</div>
{% endif %}
<form method="post" action="/ui/login">
<label for="token">Owner Token</label>
<input type="password" name="token" id="token" required placeholder="Paste your owner token">
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Login</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "layout.html" %}
{% block title %}Tokens - {{ domain_name }} - webfingerd{% endblock %}
{% block nav %}<a href="/ui/domains/{{ domain_id }}">Back</a> | <a href="/ui/logout">Logout</a>{% endblock %}
{% block content %}
<h2>Service Tokens for {{ domain_name }}</h2>
{% if tokens.is_empty() %}
<p>No service tokens yet.</p>
{% else %}
<table>
<thead><tr><th>Name</th><th>Allowed Rels</th><th>Resource Pattern</th><th>Created</th><th>Status</th></tr></thead>
<tbody>
{% for t in tokens %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.allowed_rels }}</td>
<td>{{ t.resource_pattern }}</td>
<td>{{ t.created_at }}</td>
<td>{% if t.revoked %}<span class="badge badge-yellow">Revoked</span>{% else %}<span class="badge badge-green">Active</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -1,3 +1,4 @@
use axum_extra::extract::cookie::Key;
use metrics_exporter_prometheus::PrometheusBuilder; use metrics_exporter_prometheus::PrometheusBuilder;
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement};
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
@ -45,7 +46,7 @@ pub fn test_settings() -> Settings {
}, },
ui: UiConfig { ui: UiConfig {
enabled: false, 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() recorder.handle()
}); });
let cookie_key = Key::from(settings.ui.session_secret.as_bytes());
AppState { AppState {
db, db,
cache, cache,
settings: Arc::new(settings), settings: Arc::new(settings),
challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier), challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier),
metrics_handle, metrics_handle,
cookie_key,
} }
} }