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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+5042
File diff suppressed because it is too large
Load Diff
+111
@@ -0,0 +1,111 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.8" }
|
||||
leptos_meta = { version = "0.8" }
|
||||
leptos_router = { version = "0.8" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# --- server only ---
|
||||
leptos_axum = { version = "0.8", optional = true }
|
||||
axum = { version = "0.8", features = ["multipart"], optional = true }
|
||||
aws-sdk-s3 = { version = "1", optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
tower-http = { version = "0.6", features = ["fs"], optional = true }
|
||||
tower-sessions = { version = "0.14", optional = true }
|
||||
async-nats = { version = "0.38", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
openidconnect = { version = "4", optional = true }
|
||||
# For pulling the `groups` custom claim out of the already-verified ID
|
||||
# token's raw JWT payload - see server/oidc.rs::extract_groups_claim.
|
||||
base64 = { version = "0.22", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
uuid = { version = "1", features = ["v4"], optional = true }
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
anyhow = { version = "1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
|
||||
# --- browser only ---
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
wasm-bindgen-futures = { version = "0.4", optional = true }
|
||||
js-sys = { version = "0.3", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"File",
|
||||
"FileList",
|
||||
"FormData",
|
||||
"HtmlInputElement",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Window",
|
||||
], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hydrate = [
|
||||
"leptos/hydrate",
|
||||
"dep:wasm-bindgen",
|
||||
"dep:wasm-bindgen-futures",
|
||||
"dep:js-sys",
|
||||
"dep:console_error_panic_hook",
|
||||
"dep:web-sys",
|
||||
]
|
||||
ssr = [
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
"dep:axum",
|
||||
"dep:aws-sdk-s3",
|
||||
"dep:tokio",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tower-sessions",
|
||||
"dep:async-nats",
|
||||
"dep:url",
|
||||
"dep:serde_yaml",
|
||||
"dep:sha2",
|
||||
"dep:openidconnect",
|
||||
"dep:base64",
|
||||
"dep:chrono",
|
||||
"dep:uuid",
|
||||
"dep:dotenvy",
|
||||
"dep:anyhow",
|
||||
"dep:futures",
|
||||
"dep:tracing",
|
||||
"dep:tracing-subscriber",
|
||||
]
|
||||
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[package.metadata.leptos]
|
||||
output-name = "portal"
|
||||
site-root = "target/site"
|
||||
site-pkg-dir = "pkg"
|
||||
style-file = "style/main.css"
|
||||
assets-dir = "public"
|
||||
site-addr = "127.0.0.1:3010"
|
||||
reload-port = 3011
|
||||
bin-features = ["ssr"]
|
||||
bin-default-features = false
|
||||
lib-features = ["hydrate"]
|
||||
lib-default-features = false
|
||||
lib-profile-release = "wasm-release"
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="13" fill="#b8631f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 118 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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()))
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/* Same self-hosted Quicksand used for headings on Gitea/the homepage
|
||||
dashboard elsewhere in this stack (see
|
||||
~/repos/infrastructure/gitea-custom-header.tmpl) - reused as-is
|
||||
(same files, same unicode-range split) rather than pulled from a
|
||||
font CDN. */
|
||||
@font-face {
|
||||
font-family: "Quicksand";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/quicksand-semibold-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Quicksand";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/quicksand-semibold-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Quicksand";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/quicksand-semibold-vietnamese.woff2") format("woff2");
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* the one brand color - everything else is neutral. Tuned for
|
||||
legibility against near-black, distinct from the YES canvas's own
|
||||
cyan/magenta/yellow so UI chrome never fights with it. */
|
||||
--accent: #f0923c;
|
||||
--accent-soft: rgba(240, 146, 60, 0.16);
|
||||
|
||||
--paper: #0a0a0a;
|
||||
--paper-raised: #161616;
|
||||
--ink: #ededed;
|
||||
--ink-dim: #9a9a9a;
|
||||
--line: #2a2a2a;
|
||||
|
||||
--heading: "Quicksand", var(--sans);
|
||||
--sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif;
|
||||
|
||||
--radius: 1.1rem;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
|
||||
p { margin: 0 0 0.9em; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
::selection { background: var(--accent-soft); color: var(--ink); }
|
||||
|
||||
main.loading, main.not-found {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* hero */
|
||||
|
||||
.hero {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--heading);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hero-copy h1 { font-size: 2.1rem; }
|
||||
|
||||
.hero-copy p {
|
||||
color: var(--ink-dim);
|
||||
font-size: 1.05rem;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
/* the actual "YES - Rasterized Lines" piece live at uhhm.no (see
|
||||
public/yes.js) - landing page only. Full-viewport so it's a real
|
||||
moment, not a thumbnail; hero-copy overlays near the bottom rather
|
||||
than interrupting the canvas. */
|
||||
|
||||
.hero-yes {
|
||||
position: relative;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
padding: 0;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hero-canvas canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-canvas #rasterCanvas {
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero-yes .hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 1.5rem 3.5rem;
|
||||
gap: 0.6rem;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* alternatives */
|
||||
|
||||
.alternatives {
|
||||
max-width: 780px;
|
||||
margin: 0 auto 5rem;
|
||||
padding: 0 1.5rem;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.alt-card {
|
||||
background: var(--paper-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem 1.9rem;
|
||||
}
|
||||
|
||||
.alt-card h2 { font-size: 1.3rem; }
|
||||
|
||||
.alt-description { color: var(--ink-dim); }
|
||||
|
||||
.alt-encouragement {
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.features { display: grid; gap: 1.1rem; margin-top: 0.6rem; }
|
||||
|
||||
.feature h3 { font-size: 1rem; margin-bottom: 0.15em; }
|
||||
.feature p { color: var(--ink-dim); font-size: 0.95rem; }
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.required { color: var(--accent); margin-left: 0.2em; }
|
||||
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea { min-height: 5rem; resize: vertical; }
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.alt-submit {
|
||||
margin-top: 1.4rem;
|
||||
font-family: var(--sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #0a0a0a;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.75rem 1.4rem;
|
||||
cursor: pointer;
|
||||
transition: filter 120ms, transform 120ms;
|
||||
}
|
||||
|
||||
.alt-submit:hover { filter: brightness(1.08); }
|
||||
.alt-submit:active { transform: translateY(1px); }
|
||||
.alt-submit:disabled { filter: grayscale(0.5) brightness(0.85); cursor: wait; }
|
||||
|
||||
.gate-card { text-align: center; }
|
||||
|
||||
/* resources */
|
||||
|
||||
.resource { margin-top: 0.6rem; }
|
||||
|
||||
.resource-loading, .resource-empty {
|
||||
color: var(--ink-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.resource-error { color: #ff6b6b; font-size: 0.9rem; }
|
||||
|
||||
.resource-raw {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.answer-list { display: grid; gap: 0.8rem; }
|
||||
|
||||
.answer-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.answer-row.decided { opacity: 0.55; }
|
||||
|
||||
.answer-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.answer-field { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
|
||||
.answer-field-key {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.answer-field-value { font-size: 0.92rem; word-break: break-word; }
|
||||
|
||||
.answer-meta {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.answer-state {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.answer-actions { display: flex; gap: 0.6rem; }
|
||||
|
||||
.answer-action {
|
||||
font-family: var(--sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.answer-action:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.answer-action:disabled { opacity: 0.5; cursor: wait; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero { padding: 3rem 1.25rem 2rem; }
|
||||
.alt-card { padding: 1.4rem 1.3rem; }
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized
|
||||
// Lines"), the piece currently live at uhhm.no - kept as the actual asset,
|
||||
// not reinvented. Two changes from the original: exported as a class (no
|
||||
// auto-init on `window load`, since Leptos controls when this mounts) and
|
||||
// a `stop()` method that actually breaks the requestAnimationFrame loop -
|
||||
// the original ran forever once started, fine for a static page that's
|
||||
// the whole document, not fine in an SPA where this hero mounts/unmounts
|
||||
// as you navigate.
|
||||
|
||||
export class RasterizedYES {
|
||||
constructor() {
|
||||
this.rasterCanvas = document.getElementById('rasterCanvas');
|
||||
this.lineCanvas = document.getElementById('lineCanvas');
|
||||
this.rasterCtx = this.rasterCanvas.getContext('2d');
|
||||
this.lineCtx = this.lineCanvas.getContext('2d');
|
||||
|
||||
this.lines = [];
|
||||
this.rasterData = null;
|
||||
this.isActive = true;
|
||||
this.destroyed = false;
|
||||
this.time = 0;
|
||||
|
||||
this.mouseX = 0.5;
|
||||
this.mouseY = 0.5;
|
||||
this.containmentStrength = 0.5;
|
||||
this.wiggleAmount = 0.5;
|
||||
|
||||
this.setupCanvas();
|
||||
this.setupMouseTracking();
|
||||
this.setupClickHandler();
|
||||
this.rasterizeText();
|
||||
this.initializeLines();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.destroyed = true;
|
||||
if (this._resizeHandler) {
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
this.rasterCanvas.style.width = width + 'px';
|
||||
this.rasterCanvas.style.height = height + 'px';
|
||||
this.lineCanvas.style.width = width + 'px';
|
||||
this.lineCanvas.style.height = height + 'px';
|
||||
|
||||
this.rasterCanvas.width = width * pixelRatio;
|
||||
this.rasterCanvas.height = height * pixelRatio;
|
||||
this.lineCanvas.width = width * pixelRatio;
|
||||
this.lineCanvas.height = height * pixelRatio;
|
||||
|
||||
this.rasterCtx.scale(pixelRatio, pixelRatio);
|
||||
this.lineCtx.scale(pixelRatio, pixelRatio);
|
||||
|
||||
this.displayWidth = width;
|
||||
this.displayHeight = height;
|
||||
this.pixelRatio = pixelRatio;
|
||||
|
||||
if (!this._resizeHandler) {
|
||||
this._resizeHandler = () => {
|
||||
this.setupCanvas();
|
||||
this.rasterizeText();
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
setupMouseTracking() {
|
||||
const updatePosition = (clientX, clientY) => {
|
||||
this.mouseX = clientX / window.innerWidth;
|
||||
this.mouseY = clientY / window.innerHeight;
|
||||
this.containmentStrength = 0.1 + (this.mouseX * 0.9);
|
||||
this.wiggleAmount = 0.1 + (this.mouseY * 1.9);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
updatePosition(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// Scoped to the canvas itself, not `window` - the original
|
||||
// standalone page was the whole document, so preventDefault()ing
|
||||
// touchmove globally was harmless (nothing else to scroll to).
|
||||
// Embedded as a hero above a longer page, that same global
|
||||
// handler silently blocks scrolling everywhere, not just over
|
||||
// the canvas - this only intercepts touches that start there.
|
||||
this.lineCanvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length > 0) {
|
||||
const touch = e.touches[0];
|
||||
updatePosition(touch.clientX, touch.clientY);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
this.lineCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length > 0) {
|
||||
const touch = e.touches[0];
|
||||
updatePosition(touch.clientX, touch.clientY);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
this.mouseX = 0.5;
|
||||
this.mouseY = 0.5;
|
||||
this.containmentStrength = 0.55;
|
||||
this.wiggleAmount = 1.05;
|
||||
}
|
||||
|
||||
setupClickHandler() {
|
||||
this.lineCanvas.addEventListener('click', () => {
|
||||
this.restartAnimation();
|
||||
});
|
||||
}
|
||||
|
||||
restartAnimation() {
|
||||
this.time = 0;
|
||||
this.lineCtx.fillStyle = '#0a0a0a';
|
||||
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
|
||||
this.initializeLines();
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
rasterizeText() {
|
||||
const width = this.displayWidth;
|
||||
const height = this.displayHeight;
|
||||
|
||||
const aspectRatio = width / height;
|
||||
let fontSize;
|
||||
if (aspectRatio < 1) {
|
||||
fontSize = width * 0.4;
|
||||
} else {
|
||||
fontSize = height * 0.5;
|
||||
}
|
||||
|
||||
this.fontSize = fontSize;
|
||||
|
||||
this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
this.rasterCtx.textAlign = 'left';
|
||||
this.rasterCtx.textBaseline = 'middle';
|
||||
|
||||
const fullTextMetrics = this.rasterCtx.measureText('YES');
|
||||
const textWidth = fullTextMetrics.width;
|
||||
const textStartX = (width - textWidth) / 2;
|
||||
const textY = height / 2;
|
||||
|
||||
const letters = ['Y', 'E', 'S'];
|
||||
this.letterPositions = [];
|
||||
let currentX = textStartX;
|
||||
|
||||
for (let i = 0; i < letters.length; i++) {
|
||||
const letterMetrics = this.rasterCtx.measureText(letters[i]);
|
||||
this.letterPositions[i] = {
|
||||
x: currentX,
|
||||
y: textY,
|
||||
width: letterMetrics.width,
|
||||
centerX: currentX + letterMetrics.width / 2
|
||||
};
|
||||
currentX += letterMetrics.width;
|
||||
}
|
||||
|
||||
this.letterRasters = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
this.rasterCtx.fillStyle = '#111';
|
||||
this.rasterCtx.fillRect(0, 0, width, height);
|
||||
|
||||
this.rasterCtx.fillStyle = '#ffffff';
|
||||
this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y);
|
||||
|
||||
this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
|
||||
}
|
||||
|
||||
this.rasterCtx.fillStyle = '#111';
|
||||
this.rasterCtx.fillRect(0, 0, width, height);
|
||||
this.rasterCtx.fillStyle = '#ffffff';
|
||||
this.rasterCtx.fillText('YES', textStartX, textY);
|
||||
|
||||
this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
|
||||
}
|
||||
|
||||
isInSpecificLetter(x, y, letterIndex) {
|
||||
const canvasX = x * this.pixelRatio;
|
||||
const canvasY = y * this.pixelRatio;
|
||||
|
||||
if (!this.letterRasters || !this.letterRasters[letterIndex] ||
|
||||
canvasX < 0 || canvasY < 0 ||
|
||||
canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4;
|
||||
const red = this.letterRasters[letterIndex].data[index];
|
||||
return red > 128;
|
||||
}
|
||||
|
||||
initializeLines() {
|
||||
this.lines = [];
|
||||
const numLines = 60;
|
||||
|
||||
const letterCentroids = this.calculateLetterCentroids();
|
||||
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
const letterIndex = Math.floor(i / (numLines / 3));
|
||||
let startX, startY, centerX, centerY;
|
||||
|
||||
if (letterCentroids[letterIndex]) {
|
||||
centerX = letterCentroids[letterIndex].x;
|
||||
centerY = letterCentroids[letterIndex].y;
|
||||
|
||||
const startVariation = this.fontSize * 0.1;
|
||||
startX = centerX + (Math.random() - 0.5) * startVariation;
|
||||
startY = centerY + (Math.random() - 0.5) * startVariation;
|
||||
|
||||
if (!this.isInSpecificLetter(startX, startY, letterIndex)) {
|
||||
const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex);
|
||||
if (nearestPoint) {
|
||||
startX = nearestPoint.x;
|
||||
startY = nearestPoint.y;
|
||||
} else {
|
||||
startX = centerX;
|
||||
startY = centerY;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const letterPos = this.letterPositions[letterIndex];
|
||||
startX = letterPos.centerX;
|
||||
startY = letterPos.y;
|
||||
centerX = startX;
|
||||
centerY = startY;
|
||||
}
|
||||
|
||||
const colors = [
|
||||
'hsl(180, 90%, 70%)',
|
||||
'hsl(300, 90%, 70%)',
|
||||
'hsl(60, 90%, 70%)'
|
||||
];
|
||||
|
||||
this.lines.push({
|
||||
relativeX: (startX - centerX) / this.fontSize,
|
||||
relativeY: (startY - centerY) / this.fontSize,
|
||||
prevRelativeX: (startX - centerX) / this.fontSize,
|
||||
prevRelativeY: (startY - centerY) / this.fontSize,
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
color: colors[letterIndex],
|
||||
letterIndex: letterIndex,
|
||||
lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
|
||||
outsideDuration: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
calculateLetterCentroids() {
|
||||
const centroids = [];
|
||||
|
||||
for (let letterIndex = 0; letterIndex < 3; letterIndex++) {
|
||||
let sumX = 0, sumY = 0, count = 0;
|
||||
|
||||
const letterPos = this.letterPositions[letterIndex];
|
||||
const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1);
|
||||
const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1);
|
||||
const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6);
|
||||
const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6);
|
||||
|
||||
const step = Math.max(1, Math.floor(this.fontSize * 0.02));
|
||||
for (let y = searchStartY; y <= searchEndY; y += step) {
|
||||
for (let x = searchStartX; x <= searchEndX; x += step) {
|
||||
if (this.isInSpecificLetter(x, y, letterIndex)) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
centroids[letterIndex] = {
|
||||
x: sumX / count,
|
||||
y: sumY / count
|
||||
};
|
||||
} else {
|
||||
centroids[letterIndex] = {
|
||||
x: letterPos.centerX,
|
||||
y: letterPos.y
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return centroids;
|
||||
}
|
||||
|
||||
updateLines() {
|
||||
this.time += 0.016;
|
||||
if (!this.isActive) return;
|
||||
|
||||
const letterCentroids = this.calculateLetterCentroids();
|
||||
|
||||
this.lines.forEach(line => {
|
||||
line.prevRelativeX = line.relativeX;
|
||||
line.prevRelativeY = line.relativeY;
|
||||
|
||||
const centroid = letterCentroids[line.letterIndex];
|
||||
if (!centroid) return;
|
||||
|
||||
const currentX = centroid.x + line.relativeX * this.fontSize;
|
||||
const currentY = centroid.y + line.relativeY * this.fontSize;
|
||||
|
||||
const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex);
|
||||
|
||||
if (currentlyInside) {
|
||||
line.outsideDuration = 0;
|
||||
line.lastSeenInside = { x: line.relativeX, y: line.relativeY };
|
||||
} else {
|
||||
line.outsideDuration++;
|
||||
}
|
||||
|
||||
const visionDistance = 0.08 * this.fontSize;
|
||||
const centerX = currentX + Math.cos(line.angle) * visionDistance;
|
||||
const centerY = currentY + Math.sin(line.angle) * visionDistance;
|
||||
const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance;
|
||||
const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance;
|
||||
const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance;
|
||||
const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance;
|
||||
|
||||
const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex);
|
||||
const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex);
|
||||
const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex);
|
||||
|
||||
let speed = 0.02;
|
||||
|
||||
const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45);
|
||||
|
||||
if (line.outsideDuration > attractionThreshold) {
|
||||
const targetX = line.lastSeenInside.x;
|
||||
const targetY = line.lastSeenInside.y;
|
||||
const deltaX = targetX - line.relativeX;
|
||||
const deltaY = targetY - line.relativeY;
|
||||
const angleToTarget = Math.atan2(deltaY, deltaX);
|
||||
|
||||
let angleDiff = angleToTarget - line.angle;
|
||||
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
|
||||
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
|
||||
|
||||
const baseAttraction = Math.min(0.4, line.outsideDuration / 80);
|
||||
const attractionStrength = baseAttraction * this.containmentStrength;
|
||||
line.angle += angleDiff * attractionStrength;
|
||||
}
|
||||
|
||||
if (centerSees) {
|
||||
const baseWiggle = 0.15;
|
||||
line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount;
|
||||
} else {
|
||||
speed *= (0.3 + this.containmentStrength * 0.4);
|
||||
|
||||
const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4);
|
||||
const randomTurnAmount = 0.2 * this.wiggleAmount;
|
||||
|
||||
if (leftSees && !rightSees) {
|
||||
line.angle -= baseTurnStrength + Math.random() * randomTurnAmount;
|
||||
} else if (rightSees && !leftSees) {
|
||||
line.angle += baseTurnStrength + Math.random() * randomTurnAmount;
|
||||
} else {
|
||||
const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7);
|
||||
line.angle += randomTurn;
|
||||
}
|
||||
}
|
||||
|
||||
line.relativeX += Math.cos(line.angle) * speed;
|
||||
line.relativeY += Math.sin(line.angle) * speed;
|
||||
|
||||
line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX));
|
||||
line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY));
|
||||
});
|
||||
}
|
||||
|
||||
findNearestSpecificLetterPixel(x, y, letterIndex) {
|
||||
const searchRadius = this.fontSize * 0.08;
|
||||
const step = Math.max(1, Math.floor(this.fontSize * 0.01));
|
||||
let nearestPoint = null;
|
||||
let nearestDistance = Infinity;
|
||||
|
||||
for (let dy = -searchRadius; dy <= searchRadius; dy += step) {
|
||||
for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
|
||||
const testX = x + dx;
|
||||
const testY = y + dy;
|
||||
|
||||
if (this.isInSpecificLetter(testX, testY, letterIndex)) {
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestPoint = { x: testX, y: testY };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nearestPoint;
|
||||
}
|
||||
|
||||
draw() {
|
||||
this.lineCtx.fillStyle = 'rgba(10, 10, 10, 0.03)';
|
||||
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
|
||||
|
||||
const letterCentroids = this.calculateLetterCentroids();
|
||||
|
||||
this.lines.forEach(line => {
|
||||
const centroid = letterCentroids[line.letterIndex];
|
||||
if (!centroid) return;
|
||||
|
||||
const currentX = centroid.x + line.relativeX * this.fontSize;
|
||||
const currentY = centroid.y + line.relativeY * this.fontSize;
|
||||
const prevX = centroid.x + line.prevRelativeX * this.fontSize;
|
||||
const prevY = centroid.y + line.prevRelativeY * this.fontSize;
|
||||
|
||||
if (prevX === currentX && prevY === currentY) return;
|
||||
|
||||
this.lineCtx.strokeStyle = line.color.replace('70%)', '80%, 0.8)');
|
||||
this.lineCtx.lineWidth = this.fontSize * 0.003;
|
||||
this.lineCtx.lineCap = 'round';
|
||||
|
||||
this.lineCtx.beginPath();
|
||||
this.lineCtx.moveTo(prevX, prevY);
|
||||
this.lineCtx.lineTo(currentX, currentY);
|
||||
this.lineCtx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
animate() {
|
||||
if (this.destroyed) return;
|
||||
this.updateLines();
|
||||
this.draw();
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user