Initial commit: content-driven onboarding portal

Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-29 19:38:40 +02:00
commit aa1a7fa572
21 changed files with 7873 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
//! Durable, queryable storage for submitted answers - the KV-backed
//! counterpart to the ephemeral `events::AnswerSubmitted` every
//! submission already publishes. The runtime here has no notion of
//! "applicant" or any other specific workflow: an `Alternative.record_as`
//! names a NATS KV bucket (nothing more); on submit, the response gets
//! wrapped in the same envelope (`Answer`) regardless of what content
//! produced it. `resource::get_resource` reads it back generically, and
//! `transition_answer` below moves an answer between states named in
//! content (`ResourceSpec.transitions`), never states hardcoded here.
//! Whatever workflow this ends up powering - applicant review, order
//! triage, comment moderation - is entirely a property of some
//! `portal-content` YAML, not of this file.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// An answer's starting state - content decides what it may transition
/// to (`ResourceSpec.transitions`), never this file.
pub const OPEN_STATE: &str = "open";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Answer {
/// The submission's chain hash - stable, unique, and already what
/// ties this answer back into the DAG a transition later extends.
pub id: String,
pub question_id: String,
pub alternative: String,
/// The submitted form fields, verbatim - the runtime never inspects
/// these keys, only content (via its own copy/labels) gives them
/// meaning.
pub responses: serde_json::Value,
pub state: String,
pub submitted_ms: i64,
#[serde(default)]
pub decided_ms: Option<i64>,
#[serde(default)]
pub decided_by: Option<String>,
}
/// Writes a submitted answer into `bucket`, creating it on first use -
/// there's no fixed, pre-declared list of buckets, since content can
/// name any.
#[cfg(feature = "ssr")]
pub async fn store_answer(
js: &async_nats::jetstream::Context,
bucket: &str,
id: String,
question_id: &str,
alternative: &str,
responses: &serde_json::Value,
submitted_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(bucket).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: bucket.to_string(),
..Default::default()
})
.await?
}
};
let answer = Answer {
id: id.clone(),
question_id: question_id.to_string(),
alternative: alternative.to_string(),
responses: responses.clone(),
state: OPEN_STATE.to_string(),
submitted_ms,
decided_ms: None,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
store.put(id, data.into()).await?;
Ok(())
}
/// Moves a stored answer from `open` to one of the target states its
/// own resource spec declares (`ResourceSpec.transitions`) - `to` is
/// checked against that content-declared allow-list, never accepted as
/// an arbitrary string. Requires a signed-in member of the resource's
/// `requires_group`, regardless of whether the resource is otherwise
/// `public` - reads can be public, mutations always need identity.
#[server]
pub async fn transition_answer(
question_id: String,
feature_name: String,
item_id: String,
to: String,
) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.get(&question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question
.alternatives
.iter()
.flat_map(|a| &a.features)
.find(|f| f.name == feature_name)
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let transition = resource
.transitions
.iter()
.find(|t| t.to == to)
.ok_or_else(|| ServerFnError::new("no such transition"))?
.clone();
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
let store = state
.jetstream
.get_key_value(&resource.bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
let bytes = store
.get(&item_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
if answer.state != OPEN_STATE {
return Err(ServerFnError::new("already decided"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
answer.state = to.clone();
answer.decided_ms = Some(decided_ms);
answer.decided_by = Some(user.username.clone());
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
store
.put(&item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Extend the DAG: the decision is a child node of the answer's own
// submission hash, published the same way any other answer is -
// question_id/alternative come from this call's own arguments and
// the content-declared transition label, never a hardcoded value.
let decision_payload = serde_json::json!({ "to": to, "item": item_id, "by": user.username });
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id,
alternative: transition.label,
responses: decision_payload,
timestamp_ms: decided_ms,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}
+862
View File
@@ -0,0 +1,862 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
path, NavigateOptions,
};
use serde::{Deserialize, Serialize};
use crate::answers::{Answer, TransitionAnswer};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Transition};
use crate::resource::get_resource;
/// The visible site name/wordmark - "portal" is just this codebase's
/// working title, not necessarily what any given deployment is called.
/// Baked in at *build* time (`option_env!`, not `std::env::var`) so the
/// ssr and hydrate targets - compiled separately - end up with the
/// identical value without a runtime round-trip or context-passing
/// between them; set `SITE_NAME` for both `cargo leptos` invocations
/// (ssr and hydrate) to change it, e.g. `SITE_NAME=uhhm`. CSS uppercases
/// the wordmark for display regardless of the source casing here.
const SITE_NAME: &str = match option_env!("SITE_NAME") {
Some(name) => name,
None => "portal",
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Title text=SITE_NAME/>
<Router>
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
</Router>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let location = use_location();
let query = use_query_map();
let path = Memo::new(move |_| {
let p = location.pathname.get();
if p.is_empty() {
"/".to_string()
} else {
p
}
});
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let question = Resource::new(move || path.get(), get_question);
let user = Resource::new(|| (), |_| current_user());
view! {
<Suspense fallback=|| {
view! {
<main class="loading">
<p>"loading…"</p>
</main>
}
}>
{move || {
// Both read unconditionally (not nested inside the match
// below) so Suspense tracks both from the first pass,
// rather than only discovering `user` once `question`
// has already resolved.
let user_res = user.get();
let question_res = question.get();
question_res
.map(|res| match res {
Ok(Some(q)) => {
let current = user_res.and_then(|r| r.ok()).flatten();
view! {
<QuestionView
question=q
parent_hash=parent_hash.get()
user=current
/>
}
.into_any()
}
_ => view! { <NotFound/> }.into_any(),
})
}}
</Suspense>
}
}
#[component]
fn QuestionView(
question: Question,
parent_hash: Option<String>,
user: Option<User>,
) -> impl IntoView {
let question_id = question.id.clone();
// Generic: any question with `qualifies` set renders this same gate
// instead of its alternatives - "/review" isn't a special case, it's
// just a question that happens to have `qualifies` set.
if !is_qualified(user.as_ref(), &question) {
let signed_in = user.is_some();
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<div class="alternatives">
<section class="alt-card gate-card">
{if signed_in {
view! { <p>"This part of the site is for organizational owners — sign in with that account to take a look."</p> }
.into_any()
} else {
view! {
<a class="alt-submit" href="/auth/login" rel="external">
"Sign in"
</a>
}
.into_any()
}}
</section>
</div>
}
.into_any();
}
view! {
<Hero
title=question.name.clone()
description=question.description.clone()
show_yes=question_id == "/"
/>
<div class="alternatives">
<For
each=move || question.alternatives.clone()
key=|a| a.name.clone()
children=move |alt: Alternative| {
view! {
<AlternativeCard
question_id=question_id.clone()
alternative=alt
parent_hash=parent_hash.clone()
/>
}
}
/>
</div>
}
.into_any()
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
#[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you
// pages, /review) get the plain dark header below. Starts the
// animation once the canvas elements exist (post-mount, via
// Effect), and explicitly stops the requestAnimationFrame loop on
// unmount - the original page-owning script never needed this since
// navigating away meant a full document unload, which doesn't
// happen in an SPA.
#[cfg(feature = "hydrate")]
if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm),
// which a JS-backed value never is - `StoredValue`'s `LocalStorage`
// variant is the established way around this (same pattern
// cnats' WebRTC call state uses for its own non-Send peer map).
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
instance.set_value(Some(yes::RasterizedYes::new()));
});
on_cleanup(move || {
instance.update_value(|opt| {
if let Some(inst) = opt.take() {
inst.stop();
}
});
});
}
view! {
<header class="hero" class:hero-yes=show_yes>
{show_yes
.then(|| {
view! {
<div class="hero-canvas" aria-hidden="true">
<canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas"></canvas>
</div>
}
})}
<div class="hero-copy">
<a class="wordmark" href="/">
{SITE_NAME}
</a>
<h1>{title}</h1>
<p>{description}</p>
</div>
</header>
}
}
#[component]
fn AlternativeCard(
question_id: String,
alternative: Alternative,
parent_hash: Option<String>,
) -> impl IntoView {
let has_action = alternative.action.is_some();
// File fields don't fit a live-typed RwSignal<String> - they get
// their own map of element refs, read (and uploaded) only at submit
// time, and are excluded from `field_map` below.
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
std::collections::HashMap::new();
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
std::collections::HashMap::new();
for feature in &alternative.features {
for req in &feature.requirements {
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
} else {
field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new()));
}
}
}
// Prefixes field DOM ids with the alternative too, not just the
// question - two alternatives on the same page (e.g. both asking for
// "email") would otherwise mint the same id twice, which is invalid
// HTML and makes <label for=…> pick the wrong input.
let field_prefix = format!(
"{question_id}-{}",
alternative.name.to_lowercase().replace(' ', "-")
);
let submit = ServerAction::<SubmitAnswer>::new();
let navigate = use_navigate();
let question_id_for_submit = question_id.clone();
let alt_name_for_submit = alternative.name.clone();
let parent_hash_for_submit = parent_hash.clone();
let field_map_for_submit = field_map.clone();
let file_refs_for_submit = file_refs.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let field_map_for_submit = field_map_for_submit.clone();
let file_refs_for_submit = file_refs_for_submit.clone();
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
map.insert(name.clone(), serde_json::Value::String(sig.get()));
}
// Only used under hydrate (file uploads are a browser-only
// concern); referenced unconditionally so an `ssr` build
// doesn't warn about it going unused.
let _ = &file_refs_for_submit;
#[cfg(feature = "hydrate")]
for (name, file_ref) in file_refs_for_submit.iter() {
let Some(input) = file_ref.get_untracked() else {
continue;
};
let Some(files) = input.files() else { continue };
let Some(file) = files.get(0) else { continue };
match upload_file(&file).await {
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
}
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
}
}
}
let responses_json =
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default();
submit.dispatch(SubmitAnswer {
question_id: question_id_for_submit,
alternative: alt_name_for_submit,
parent_hash: parent_hash_for_submit,
responses_json,
});
});
};
Effect::new(move |_| {
if let Some(Ok(result)) = submit.value().get() {
if let Some(next) = result.next {
navigate(
&format!("{next}?chain={}", result.chain_hash),
NavigateOptions::default(),
);
}
}
});
let button_label = alternative
.consequence
.first()
.cloned()
.unwrap_or_else(|| "Send".to_string());
view! {
<section class="alt-card">
<h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p>
<For
each=move || alternative.encouragements.clone()
key=|e| e.clone()
children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/>
<div class="features">
<For
each={
let features = alternative.features.clone();
move || features.clone()
}
key=|f| f.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
move |feature| {
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let resource = feature.resource.clone();
let feature_name = feature.name.clone();
view! {
<div class="feature">
<Show when={
let name = feature.name.clone();
move || !name.is_empty()
}>
<h3>{feature.name.clone()}</h3>
</Show>
<Show when={
let description = feature.description.clone();
move || !description.is_empty()
}>
<p>{feature.description.clone()}</p>
</Show>
{resource.map(|spec| {
view! {
<ResourceFeature
question_id=question_id.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
/>
}
})}
<For
each={
let reqs = feature.requirements.clone();
move || reqs.clone()
}
key=|r| r.name.clone()
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let field_prefix = field_prefix.clone();
move |req| {
let field_id = format!("{}-{}", field_prefix, req.name);
let label_for = field_id.clone();
let label_text = view! {
<span class="field-label">
{req.display_label()}
{(!req.optional).then(|| view! { <span class="required">"*"</span> })}
</span>
};
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
return view! {
<label class="field" for=label_for>
{label_text}
<input
id=field_id
type="file"
accept=req.accept.clone().unwrap_or_default()
multiple=req.multiple
required=!req.optional
node_ref=file_ref
/>
</label>
}
.into_any();
}
let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
view! {
<label class="field" for=label_for>
{label_text}
<Show
when={
let kind = req.kind.clone();
move || kind == "textarea"
}
fallback={
let kind = req.kind.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let field_id = field_id.clone();
let required = !req.optional;
move || {
view! {
<input
id=field_id.clone()
type=kind.clone()
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
}
}
}
>
{
let field_id = field_id.clone();
let placeholder = req.placeholder.clone().unwrap_or_default();
let required = !req.optional;
view! {
<textarea
id=field_id.clone()
placeholder=placeholder.clone()
required=required
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
></textarea>
}
}
</Show>
</label>
}
.into_any()
}
}
/>
</div>
}
}
}
/>
</div>
{has_action
.then(|| {
view! {
<form on:submit=on_submit>
<button
type="submit"
class="alt-submit"
disabled=move || submit.pending().get()
>
{button_label.clone()}
</button>
</form>
}
})}
</section>
}
}
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
/// Fully generic: it has no idea what "/review" or "applicants" are - it
/// just fetches `question_id`/`feature_name`'s data and renders it
/// according to its own shape (see `ResourceValue`), with one button
/// per `transitions` entry when the data looks like a list of answers.
#[component]
fn ResourceFeature(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
) -> impl IntoView {
let data = Resource::new(
{
let question_id = question_id.clone();
let feature_name = feature_name.clone();
move || (question_id.clone(), feature_name.clone())
},
|(q, f)| get_resource(q, f),
);
view! {
<div class="resource">
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
{move || {
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
data.get()
.map(|res| match res {
Ok(value) => {
view! {
<ResourceValue
question_id=question_id
feature_name=feature_name
transitions=transitions
value=value
/>
}
.into_any()
}
Err(e) => {
view! { <p class="resource-error">{e.to_string()}</p> }.into_any()
}
})
}}
</Suspense>
</div>
}
}
/// Renders whatever `get_resource` returned, purely from its shape: an
/// array that parses as `answers::Answer` renders as a list (with
/// transition buttons, if any were declared); anything else renders as
/// a plain, read-only dump. No `kind`/render-mode tag anywhere - the
/// data decides.
#[component]
fn ResourceValue(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
value: serde_json::Value,
) -> impl IntoView {
if let serde_json::Value::Array(items) = &value {
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
answers.sort_by(|a, b| {
let open = |s: &str| s != crate::answers::OPEN_STATE;
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
});
return view! {
<div class="answer-list">
<For
each=move || answers.clone()
key=|a| a.id.clone()
children={
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let transitions = transitions.clone();
move |answer: Answer| {
view! {
<AnswerRow
question_id=question_id.clone()
feature_name=feature_name.clone()
transitions=transitions.clone()
answer=answer
/>
}
}
}
/>
</div>
}
.into_any();
}
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
}
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
}
#[component]
fn AnswerRow(
question_id: String,
feature_name: String,
transitions: Vec<Transition>,
answer: Answer,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
let responses = answer
.responses
.as_object()
.cloned()
.unwrap_or_default();
let submitted_ms = answer.submitted_ms;
let state = answer.state.clone();
let item_id = answer.id.clone();
let transition = ServerAction::<TransitionAnswer>::new();
view! {
<article class="answer-row" class:decided=move || !is_open>
<div class="answer-fields">
<For
each={
let responses = responses.clone();
move || responses.clone().into_iter().collect::<Vec<_>>()
}
key=|(k, _)| k.clone()
children=move |(key, value)| {
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
view! {
<div class="answer-field">
<span class="answer-field-key">{key}</span>
<span class="answer-field-value">{text}</span>
</div>
}
}
/>
</div>
<div class="answer-meta">
<span class="answer-state">{state}</span>
<span class="answer-time">{format_ms(submitted_ms)}</span>
</div>
<Show when={
let transitions = transitions.clone();
move || is_open && !transitions.is_empty()
}>
<div class="answer-actions">
<For
each={
let transitions = transitions.clone();
move || transitions.clone()
}
key=|t| t.to.clone()
children={
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
move |t: Transition| {
let question_id = question_id.clone();
let feature_name = feature_name.clone();
let item_id = item_id.clone();
let to = t.to.clone();
view! {
<button
class="answer-action"
on:click=move |_| {
transition.dispatch(TransitionAnswer {
question_id: question_id.clone(),
feature_name: feature_name.clone(),
item_id: item_id.clone(),
to: to.clone(),
});
}
disabled=move || transition.pending().get()
>
{t.label.clone()}
</button>
}
}
}
/>
</div>
</Show>
</article>
}
}
fn format_ms(ms: i64) -> String {
#[cfg(feature = "ssr")]
{
use chrono::TimeZone;
chrono::Utc
.timestamp_millis_opt(ms)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| ms.to_string())
}
#[cfg(not(feature = "ssr"))]
{
ms.to_string()
}
}
#[component]
fn NotFound() -> impl IntoView {
view! {
<main class="not-found">
<h1>"Nothing here"</h1>
<a href="/">"back to the start"</a>
</main>
}
}
#[server]
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
Ok(state.questions.get(&path).cloned())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubmitResult {
pub next: Option<String>,
pub chain_hash: String,
}
#[server]
pub async fn submit_answer(
question_id: String,
alternative: String,
parent_hash: Option<String>,
responses_json: String,
) -> Result<SubmitResult, ServerFnError> {
use crate::answers::store_answer;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::content::is_qualified;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.get(&question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !is_qualified(user.as_ref(), question) {
return Err(ServerFnError::new("not authorized for this question"));
}
let alt = question
.alternatives
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?;
let next = alt.action.clone();
let record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
let parent_hashes: Vec<String> = parent_hash.into_iter().collect();
let timestamp_ms = chrono::Utc::now().timestamp_millis();
let chain_hash = hash_node(&question_id, &parent_hashes, &responses, timestamp_ms);
emit_answer_submitted(
&state.nats,
&AnswerSubmitted {
chain_hash: chain_hash.clone(),
parent_hashes,
question_id: question_id.clone(),
alternative: alternative.clone(),
responses: responses.clone(),
timestamp_ms,
},
)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
// Best-effort: a bucket-write hiccup shouldn't fail a submission the
// NATS event has already recorded.
if let Some(bucket) = &record_as {
if let Err(e) = store_answer(
&state.jetstream,
bucket,
chain_hash.clone(),
&question_id,
&alternative,
&responses,
timestamp_ms,
)
.await
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
}
Ok(SubmitResult { next, chain_hash })
}
/// Uploads one file to `POST /upload` and returns its stored object key
/// - the value a `type: file` requirement contributes to
/// `responses_json`, same as any other field. A plain `fetch`, not a
/// Leptos server fn: those aren't built for binary bodies, and the
/// server side is a raw Axum multipart handler (`src/upload.rs`) for
/// the same reason.
#[cfg(feature = "hydrate")]
async fn upload_file(file: &web_sys::File) -> Result<String, wasm_bindgen::JsValue> {
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{FormData, Request, RequestInit, Response};
let form = FormData::new()?;
form.append_with_blob("file", file)?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_body_opt_form_data(Some(&form));
let request = Request::new_with_str_and_init("/upload", &opts)?;
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let resp: Response = JsFuture::from(window.fetch_with_request(&request))
.await?
.dyn_into()?;
if !resp.ok() {
return Err(JsValue::from_str(&format!(
"upload failed with status {}",
resp.status()
)));
}
let json = JsFuture::from(resp.json()?).await?;
js_sys::Reflect::get(&json, &JsValue::from_str("key"))?
.as_string()
.ok_or_else(|| JsValue::from_str("upload response had no key"))
}
+30
View File
@@ -0,0 +1,30 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// The authenticated user, as established by the Kanidm OIDC flow and
/// stored in the server-side session. Ported from cnats' `auth.rs` -
/// same shape, same provider.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct User {
pub sub: String,
pub username: String,
pub display_name: String,
/// Kanidm group membership, from the `groups` OIDC claim (see
/// `oauth2 update-claim-map`). Fixed at login time - not re-checked
/// live, so a group change only takes effect on the next login.
#[serde(default)]
pub groups: Vec<String>,
}
pub const SESSION_USER_KEY: &str = "user";
/// Returns the currently signed-in user, if any.
#[server]
pub async fn current_user() -> Result<Option<User>, ServerFnError> {
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(user)
}
+35
View File
@@ -0,0 +1,35 @@
//! SHA-256 hashed DAG chain, ported from dodrenett's lib/challenge.ts.
//! Kept deliberately plain: every submission hashes in its parent(s), but
//! nothing here produces more than one parent yet - that only starts
//! mattering once something (an LLM, or a human) posts follow-up
//! questions back into a visitor's chain instead of the fixed content in
//! ../portal-content. The DAG-shaped `parent_hashes` field exists so that
//! can slot in later without a schema change; the hashing itself doesn't
//! need to.
#![cfg(feature = "ssr")]
use sha2::{Digest, Sha256};
/// Hashes one chain node from its question, its parent hash(es), the
/// submitted responses, and a timestamp. Parents are sorted first so the
/// hash doesn't depend on the order multiple parents happened to arrive
/// in.
pub fn hash_node(
question_id: &str,
parent_hashes: &[String],
responses: &serde_json::Value,
timestamp_ms: i64,
) -> String {
let mut parents = parent_hashes.to_vec();
parents.sort();
let payload = serde_json::json!({
"questionId": question_id,
"parentHashes": parents,
"responses": responses,
"timestamp": timestamp_ms,
});
let data = serde_json::to_vec(&payload).expect("json values always serialize");
let mut hasher = Sha256::new();
hasher.update(&data);
format!("{:x}", hasher.finalize())
}
+168
View File
@@ -0,0 +1,168 @@
use serde::{Deserialize, Serialize};
/// One page: a prompt plus the paths on from it. `id` doubles as the URL
/// path it's served at ("/" is the landing page). Loaded from a plain
/// YAML file per question in a content directory kept in its own git
/// repo (see ../portal-content) - editing content is a content-repo
/// commit, not a Rust rebuild.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Question {
pub id: String,
#[serde(default)]
pub route: Option<String>,
pub name: String,
#[serde(default)]
pub description: String,
/// Kanidm group required to view/submit this question - `None` means
/// open to anyone, matching every question today. Content-driven
/// on purpose: a gated page like "/review" is just a Question with
/// this set, not a bespoke Rust route.
#[serde(default)]
pub qualifies: Option<String>,
#[serde(default)]
pub alternatives: Vec<Alternative>,
}
/// Whether `user` may view/submit `question`. `true` when the question
/// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room`
/// in cnats - same synchronous, I/O-free shape, same staleness tradeoff
/// (group membership is fixed at login, not re-checked live).
pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool {
match &question.qualifies {
None => true,
Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)),
}
}
/// One path through a question: a short pitch, an optional next question
/// to advance to on submit, and the form (via `features`) that collects
/// what's needed to get there.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Alternative {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
pub encouragements: Vec<String>,
#[serde(default)]
pub features: Vec<Feature>,
/// Names a NATS KV bucket to also durably store this submission
/// into, beyond the ephemeral NATS event every submission already
/// gets published as - just a bucket name, not a keyword the
/// runtime special-cases (see `src/answers.rs`). Content-driven
/// rather than matching on the alternative's name/copy, which would
/// silently break if the wording changes later.
#[serde(default)]
pub record_as: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Feature {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub requirements: Vec<Requirement>,
/// Live data this feature pulls in. Read-only unless `transitions`
/// is non-empty, in which case listed answers get one action button
/// per transition (see `src/resource.rs`, `src/answers.rs`).
#[serde(default)]
pub resource: Option<ResourceSpec>,
}
/// Declares a NATS KV-backed live-data read. The bucket/key here are
/// only ever read server-side, from content the server itself loaded at
/// startup - `get_resource` (`src/resource.rs`) takes a question id +
/// feature name from the client, never a bucket/key directly, so a
/// visitor can never probe an arbitrary bucket by naming it. There is
/// no render-mode/"kind" tag here on purpose - what a resource displays
/// as follows from its data's own shape (a plain value, or a list of
/// `answers::Answer`s), not from a Rust-side special case per content
/// item.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResourceSpec {
pub bucket: String,
/// A single item; omit to list the whole bucket.
#[serde(default)]
pub key: Option<String>,
/// Kanidm group required to read this resource.
#[serde(default)]
pub requires_group: Option<String>,
/// Must be explicitly set for an anonymous-readable resource - a
/// spec with neither this nor `requires_group` is unreachable by
/// design (fail closed, not fail open). Reads only - mutations
/// (`transitions` below) always require `requires_group` regardless
/// of this flag.
#[serde(default)]
pub public: bool,
/// States a listed answer may move to, and the button label for
/// each - empty means the resource is read-only. The allow-list a
/// `transition_answer` call is checked against, so a client can
/// never move an answer to a state content didn't declare.
#[serde(default)]
pub transitions: Vec<Transition>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
pub to: String,
pub label: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Requirement {
pub name: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default = "default_requirement_type", rename = "type")]
pub kind: String,
#[serde(default)]
pub optional: bool,
/// `type: file` only - accept multiple files.
#[serde(default)]
pub multiple: bool,
/// `type: file` only - HTML `accept` hint (UX only, not a security
/// boundary - the upload handler re-checks content-type itself).
#[serde(default)]
pub accept: Option<String>,
}
fn default_requirement_type() -> String {
"text".to_string()
}
impl Requirement {
pub fn display_label(&self) -> String {
self.label.clone().unwrap_or_else(|| self.name.clone())
}
}
/// Reads every `*.yaml` file in `dir` as a `Question`, keyed by its own
/// `id`. Runs once at startup; no hot-reload yet - restart the process
/// (or add a watcher later) to pick up content changes.
#[cfg(feature = "ssr")]
pub fn load_questions(
dir: &std::path::Path,
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
let mut out = std::collections::HashMap::new();
for entry in std::fs::read_dir(dir)
.map_err(|e| anyhow::anyhow!("reading content dir {}: {e}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let raw = std::fs::read_to_string(&path)?;
let question: Question = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
out.insert(question.id.clone(), question);
}
Ok(out)
}
+30
View File
@@ -0,0 +1,30 @@
//! Plain NATS event publishing - ported from dodrenett's lib/nats.ts
//! (AnswerSubmitted). Deliberately just a publish, no stream/consumer
//! bookkeeping here: whoever reads `portal.answers.submitted` today is a
//! person watching the subject; a future LLM-driven follow-up poster
//! reads the exact same subject and shape, so this doesn't need to
//! change shape when that shows up, only gain a second subscriber.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnswerSubmitted {
pub chain_hash: String,
pub parent_hashes: Vec<String>,
pub question_id: String,
pub alternative: String,
pub responses: serde_json::Value,
pub timestamp_ms: i64,
}
pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
pub async fn emit_answer_submitted(
nats: &async_nats::Client,
payload: &AnswerSubmitted,
) -> anyhow::Result<()> {
let data = serde_json::to_vec(payload)?;
nats.publish(ANSWERS_SUBJECT, data.into()).await?;
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
pub mod answers;
pub mod app;
pub mod auth;
pub mod chain;
pub mod content;
pub mod events;
pub mod resource;
#[cfg(feature = "ssr")]
pub mod server;
#[cfg(feature = "ssr")]
pub mod upload;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(app::App);
}
+144
View File
@@ -0,0 +1,144 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use portal::app::{shell, App};
use portal::content;
use portal::server::{oidc, AppState};
use portal::upload::{self, Garage};
use std::sync::Arc;
use tower_http::services::{ServeDir, ServeFile};
use tower_sessions::{MemoryStore, SessionManagerLayer};
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,portal=debug".into()),
)
.init();
let conf = get_configuration(None)?;
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
let content_dir = std::env::var("CONTENT_DIR")
.unwrap_or_else(|_| "../portal-content/questions".to_string());
let questions = content::load_questions(std::path::Path::new(&content_dir))?;
tracing::info!(count = questions.len(), dir = %content_dir, "loaded content");
let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
tracing::info!(%nats_url, "connecting to NATS");
let parsed = url::Url::parse(&nats_url)?;
let mut nats_opts = async_nats::ConnectOptions::new();
if !parsed.username().is_empty() {
nats_opts = nats_opts.user_and_password(
parsed.username().to_string(),
parsed.password().unwrap_or_default().to_string(),
);
}
let nats = nats_opts.connect(&nats_url).await?;
// No fixed, pre-declared list of buckets to initialize here - content
// names whatever bucket an alternative's `record_as` should write
// into, and `answers::store_answer` creates it on first use.
let jetstream = async_nats::jetstream::new(nats.clone());
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
let garage = Garage::from_env();
if garage.is_none() {
tracing::warn!("GARAGE_* env vars not set - file upload fields will fail closed");
}
let state = AppState {
leptos_options: leptos_options.clone(),
nats,
jetstream,
questions: std::sync::Arc::new(questions),
oidc: oidc_state,
garage,
};
// Dev-friendly defaults: in-memory sessions, secure cookies only when
// COOKIE_SECURE=true (set it behind TLS in production) - same
// defaults cnats uses. SameSite=Lax (tower-sessions defaults to
// Strict) because the OIDC callback is, by definition, a top-level
// GET arriving via a redirect *from* the IdP's origin - Strict
// withholds the cookie on exactly that request, breaking login
// whenever the app and Kanidm aren't under the same registrable
// domain (e.g. this app on 127.0.0.1 during local dev vs. Kanidm's
// real domain). Lax is the standard, correct setting for this
// pattern, not a workaround.
let cookie_secure = std::env::var("COOKIE_SECURE")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let session_layer = SessionManagerLayer::new(MemoryStore::default())
.with_secure(cookie_secure)
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_name("portal_session");
async fn server_fn_handler(
State(state): State<AppState>,
request: Request<Body>,
) -> impl IntoResponse {
leptos_axum::handle_server_fns_with_context(
move || provide_context(state.clone()),
request,
)
.await
}
// The app's own Leptos route list includes a root-level wildcard
// (`/*any`, since a question's `id` doubles as its URL path), which
// would otherwise swallow every request - including these static
// assets - before `.fallback()` ever got a chance to serve them.
// Registering them explicitly here lets axum's route matching pick
// the more specific match over the wildcard.
let pkg_dir = format!(
"{}/{}",
leptos_options.site_root, leptos_options.site_pkg_dir
);
let favicon_path = format!("{}/favicon.svg", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload))
.nest_service("/pkg", ServeDir::new(pkg_dir))
.nest_service("/fonts", ServeDir::new(fonts_dir))
.route_service("/favicon.svg", ServeFile::new(favicon_path))
.leptos_routes_with_context(
&state,
routes,
{
let state = state.clone();
move || provide_context(state.clone())
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
},
)
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
.layer(session_layer)
.with_state(state);
tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
#[cfg(not(feature = "ssr"))]
fn main() {
// The browser build is a cdylib; this stub only exists so `cargo check`
// without features still succeeds.
}
+89
View File
@@ -0,0 +1,89 @@
//! Generic, authorized reads of NATS KV data declared in content
//! (`content::ResourceSpec`, on a `Feature`). The one property that
//! matters: the bucket/key a resource reads is never a client-supplied
//! parameter, only something the server resolves from its own
//! trusted, YAML-loaded content - the client only ever names a
//! question id + feature name, so it can't probe an arbitrary bucket
//! by just naming it.
use leptos::prelude::*;
/// Fetches the live data for `question_id`'s `feature_name` feature.
/// Fails closed: a resource with neither `public: true` nor
/// `requires_group` set is unreachable, not "open" by omission.
#[server]
pub async fn get_resource(
question_id: String,
feature_name: String,
) -> Result<serde_json::Value, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.get(&question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question
.alternatives
.iter()
.flat_map(|a| &a.features)
.find(|f| f.name == feature_name)
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
if !resource.public {
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
}
let store = state
.jetstream
.get_key_value(&resource.bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
match &resource.key {
Some(key) => {
let bytes = store
.get(key)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))
}
None => {
use futures::TryStreamExt;
let keys: Vec<String> = store
.keys()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.try_collect()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let mut items = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
items.push(value);
}
}
}
Ok(serde_json::Value::Array(items))
}
}
}
+30
View File
@@ -0,0 +1,30 @@
#![cfg(feature = "ssr")]
pub mod oidc;
use axum::extract::FromRef;
use leptos::prelude::LeptosOptions;
use std::collections::HashMap;
use std::sync::Arc;
use crate::content::Question;
#[derive(Clone)]
pub struct AppState {
pub leptos_options: LeptosOptions,
pub nats: async_nats::Client,
/// JetStream context - source of every NATS KV bucket this app
/// reads/writes (applicants, and whatever a `ResourceSpec` names).
pub jetstream: async_nats::jetstream::Context,
pub questions: Arc<HashMap<String, Question>>,
pub oidc: Arc<oidc::Oidc>,
/// `None` when `GARAGE_*` env vars aren't set - uploads are the one
/// optional feature, everything else works without Garage.
pub garage: Option<crate::upload::Garage>,
}
impl FromRef<AppState> for LeptosOptions {
fn from_ref(state: &AppState) -> Self {
state.leptos_options.clone()
}
}
+251
View File
@@ -0,0 +1,251 @@
//! OIDC authorization-code + PKCE flow against Kanidm. Ported from
//! cnats' `server/oidc.rs` - same provider, same flow, unchanged.
//!
//! Kanidm serves per-client OIDC discovery documents at
//! `<KANIDM_URL>/oauth2/openid/<client_id>/.well-known/openid-configuration`,
//! and enforces PKCE, so this module always sends a S256 challenge.
use axum::{
extract::{Query, State},
http::StatusCode,
response::Redirect,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse,
};
use serde::Deserialize;
use tower_sessions::Session;
use crate::auth::{User, SESSION_USER_KEY};
use super::AppState;
type OidcClient = CoreClient<
EndpointSet, // auth endpoint
EndpointNotSet, // device auth
EndpointNotSet, // introspection
EndpointNotSet, // revocation
EndpointMaybeSet, // token endpoint (from discovery)
EndpointMaybeSet, // userinfo endpoint (from discovery)
>;
pub struct Oidc {
client: OidcClient,
http: openidconnect::reqwest::Client,
}
const PKCE_KEY: &str = "oidc_pkce_verifier";
const CSRF_KEY: &str = "oidc_csrf_state";
const NONCE_KEY: &str = "oidc_nonce";
impl Oidc {
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
pub async fn from_env() -> anyhow::Result<Self> {
let kanidm_url = require_env("KANIDM_URL")?;
let client_id = require_env("OAUTH2_CLIENT_ID")?;
let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
let public_url = require_env("PUBLIC_URL")?;
let issuer = IssuerUrl::new(format!(
"{}/oauth2/openid/{}",
kanidm_url.trim_end_matches('/'),
client_id
))?;
let redirect = RedirectUrl::new(format!(
"{}/auth/callback",
public_url.trim_end_matches('/')
))?;
// Never follow redirects when talking to the IdP (SSRF hygiene).
let http = openidconnect::reqwest::ClientBuilder::new()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()?;
tracing::info!(issuer = %issuer.as_str(), "discovering OIDC provider");
let metadata = CoreProviderMetadata::discover_async(issuer, &http).await?;
let client = CoreClient::from_provider_metadata(
metadata,
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect);
Ok(Self { client, http })
}
}
fn require_env(name: &str) -> anyhow::Result<String> {
std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var {name}"))
}
type HandlerError = (StatusCode, String);
fn internal(err: impl std::fmt::Display) -> HandlerError {
tracing::error!("oidc error: {err}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"authentication failed; see server logs".to_string(),
)
}
/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
pub async fn login(State(state): State<AppState>, session: Session) -> Result<Redirect, HandlerError> {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = state
.oidc
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
session
.insert(PKCE_KEY, pkce_verifier.secret())
.await
.map_err(internal)?;
session
.insert(CSRF_KEY, csrf_state.secret())
.await
.map_err(internal)?;
session
.insert(NONCE_KEY, nonce.secret())
.await
.map_err(internal)?;
Ok(Redirect::to(auth_url.as_str()))
}
#[derive(Deserialize)]
pub struct CallbackParams {
code: String,
state: String,
}
/// GET /auth/callback — verify state, exchange the code, verify the ID token,
/// and store the user in the session.
pub async fn callback(
State(state): State<AppState>,
session: Session,
Query(params): Query<CallbackParams>,
) -> Result<Redirect, HandlerError> {
let stored_csrf: Option<String> = session.remove(CSRF_KEY).await.map_err(internal)?;
let pkce_verifier: Option<String> = session.remove(PKCE_KEY).await.map_err(internal)?;
let nonce: Option<String> = session.remove(NONCE_KEY).await.map_err(internal)?;
let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
(stored_csrf, pkce_verifier, nonce)
else {
return Err((
StatusCode::BAD_REQUEST,
"no login in progress; start again at /auth/login".to_string(),
));
};
if params.state != stored_csrf {
return Err((
StatusCode::BAD_REQUEST,
"state mismatch; start again at /auth/login".to_string(),
));
}
let oidc = &state.oidc;
let token_response = oidc
.client
.exchange_code(AuthorizationCode::new(params.code))
.map_err(internal)?
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
.request_async(&oidc.http)
.await
.map_err(internal)?;
let id_token = token_response
.id_token()
.ok_or_else(|| internal("provider returned no ID token"))?;
let claims = id_token
.claims(&oidc.client.id_token_verifier(), &Nonce::new(nonce))
.map_err(internal)?;
let username = claims
.preferred_username()
.map(|u| u.as_str().to_string())
.or_else(|| claims.email().map(|e| e.as_str().to_string()))
.unwrap_or_else(|| claims.subject().as_str().to_string());
let display_name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.as_str().to_string())
.unwrap_or_else(|| username.clone());
// `groups` is a custom claim (Kanidm `oauth2 update-claim-map`), not
// something the Core* typed claims struct above knows about. The
// signature is already verified by `id_token.claims(...)` above, so
// re-reading the same payload's raw JSON for one more field is safe -
// just a plain field extraction, not a second verification step.
// IdToken's Serialize impl (not Display - it has none) produces the
// raw compact JWT string "header.payload.signature".
let groups = extract_groups_claim(&id_token);
let user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
groups,
};
// Rotate the session id on privilege change, then store the user.
session.cycle_id().await.map_err(internal)?;
session
.insert(SESSION_USER_KEY, &user)
.await
.map_err(internal)?;
tracing::info!(user = %user.username, "signed in");
Ok(Redirect::to("/"))
}
/// GET /auth/logout — drop the session.
pub async fn logout(session: Session) -> Result<Redirect, HandlerError> {
session.flush().await.map_err(internal)?;
Ok(Redirect::to("/"))
}
/// Pulls the `groups` custom claim (Kanidm `oauth2 update-claim-map`) out
/// of an ID token's raw JWT payload. `IdToken`'s `Serialize` impl (it has
/// no `Display`) produces the compact "header.payload.signature" string,
/// which is where this reads from - the signature itself is never
/// re-checked here, that already happened via `id_token.claims(...)`
/// before this is called. Defensive by design: any parse failure (no
/// claim, wrong shape) just yields no groups rather than failing login.
fn extract_groups_claim<T: serde::Serialize>(id_token: &T) -> Vec<String> {
use base64::Engine;
let Ok(serde_json::Value::String(compact)) = serde_json::to_value(id_token) else {
return Vec::new();
};
let Some(payload_b64) = compact.split('.').nth(1) else {
return Vec::new();
};
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)
else {
return Vec::new();
};
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
return Vec::new();
};
payload
.get("groups")
.and_then(|g| g.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default()
}
+113
View File
@@ -0,0 +1,113 @@
//! Plain multipart upload → Garage (S3-compatible), for `type: file`
//! requirement fields. A raw Axum handler, not a Leptos server fn -
//! those aren't built for binary bodies. The stored object key becomes
//! that field's value in `responses_json` (`app.rs`'s file-input
//! widget), so nothing about `submit_answer`'s generic
//! `Map<String, String>` flow needs to know a file was involved.
#![cfg(feature = "ssr")]
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use axum::extract::{Multipart, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
use crate::server::AppState;
const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
#[derive(Clone)]
pub struct Garage {
client: aws_sdk_s3::Client,
bucket: String,
}
impl Garage {
/// Builds a client from `GARAGE_S3_ENDPOINT`/`GARAGE_ACCESS_KEY`/
/// `GARAGE_SECRET_KEY`/`GARAGE_UPLOADS_BUCKET` - same secret names
/// dodrenett's own Woodpecker pipeline already uses. `None` (not an
/// error) when unset, since uploads are optional: everything else
/// works without Garage configured.
pub fn from_env() -> Option<Self> {
let endpoint = std::env::var("GARAGE_S3_ENDPOINT")
.unwrap_or_else(|_| "http://garage:3900".to_string());
let access_key = std::env::var("GARAGE_ACCESS_KEY").ok()?;
let secret_key = std::env::var("GARAGE_SECRET_KEY").ok()?;
let bucket = std::env::var("GARAGE_UPLOADS_BUCKET")
.unwrap_or_else(|_| "portal-attachments".to_string());
let credentials = Credentials::new(access_key, secret_key, None, None, "portal");
let config = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.region(Region::new("garage"))
.endpoint_url(endpoint)
.credentials_provider(credentials)
// Garage expects path-style bucket addressing, not the
// virtual-hosted `bucket.host` style AWS defaults to.
.force_path_style(true)
.build();
Some(Self {
client: aws_sdk_s3::Client::from_conf(config),
bucket,
})
}
}
#[derive(Serialize)]
pub struct UploadResponse {
key: String,
}
/// POST /upload - streams the first `file` field straight through to
/// Garage and hands back its object key. Server-side hygiene beyond the
/// HTML `accept` hint (a UX-only signal, not a boundary): a hard size
/// cap, and the upload is rejected outright if Garage isn't configured
/// rather than silently succeeding nowhere.
pub async fn upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
let garage = state.garage.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"uploads are not configured".to_string(),
))?;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
if field.name() != Some("file") {
continue;
}
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let filename = field.file_name().unwrap_or("upload").to_string();
let bytes = field
.bytes()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if bytes.len() > MAX_UPLOAD_BYTES {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
}
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
garage
.client
.put_object()
.bucket(&garage.bucket)
.key(&key)
.body(ByteStream::from(bytes))
.content_type(content_type)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
return Ok(Json(UploadResponse { key }));
}
Err((StatusCode::BAD_REQUEST, "no file field in request".to_string()))
}