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:
+179
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user