Files
portal/src/resource.rs
T
Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf
Test / test (push) Successful in 23s
Filesystem routes, sections, dynamic segments; instant YES hero
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00

583 lines
24 KiB
Rust

//! Generic, authorized reads of live data declared in content
//! (`content::ResourceSpec`, on a `Feature`). The one property that
//! matters: what 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
//! (plus, now, whatever form-field values it already has, forwarded as
//! query params), never a bucket/URL directly.
use leptos::prelude::*;
/// Fetches the live data for `question_id`'s `alternative`'s
/// `feature_name` feature. Fails closed: a resource with neither
/// `public: true` nor `requires_group` set is unreachable, not "open"
/// by omission. `params` are the requesting form's current field
/// values (if any) - forwarded as query parameters to a live external
/// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind
/// "a resource parameterized by other form fields"; a `Kv` resource
/// ignores them entirely, same as today.
#[server(endpoint = "get_resource")]
pub async fn get_resource(
question_id: String,
alternative: String,
feature_name: String,
// An empty map serializes as no `params` key at all on a real
// client-side call (unlike the server-only initial SSR call, which
// never round-trips through serialization) - without this, every
// `Resource::refetch()` after a fully-empty-params call failed with
// "missing field `params`", even though the very first load (always
// server-resolved) never did.
#[server(default)]
params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(resource).await?;
fetch_resource_value(&state, resource, &params).await
}
/// The `Requirement.resource`-backed counterpart to `get_resource` -
/// same lookup/auth/fetch machinery, just resolving through a named
/// requirement's own resource (the option source for a `type: select`
/// field) instead of a feature's. A resource is a resource regardless
/// of whether it's displayed read-only or offered as choices to pick
/// from, so this deliberately doesn't duplicate the source-dispatch or
/// jq-shaping logic - see `fetch_resource_value`.
#[server(endpoint = "get_requirement_options")]
pub async fn get_requirement_options(
question_id: String,
alternative: String,
feature_name: String,
requirement_name: String,
// See get_resource's `params` for why this is needed.
#[server(default)]
params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let requirement = feature
.requirements
.iter()
.find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
let resource = requirement
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(resource).await?;
fetch_resource_value(&state, resource, &params).await
}
/// The `Requirement.bind`-backed counterpart to
/// `get_requirement_options`: fetches the resource a bound field
/// loads its value from, parameterized by the watched sibling's value
/// (already inside `params`, keyed by the bind's param name).
#[server(endpoint = "get_requirement_binding")]
pub async fn get_requirement_binding(
question_id: String,
alternative: String,
feature_name: String,
requirement_name: String,
// See get_resource's `params` for why this is needed.
#[server(default)]
params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let feature = find_feature(&state, &question_id, &alternative, &feature_name)?;
let requirement = feature
.requirements
.iter()
.find(|r| r.name == requirement_name)
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
let bind = requirement
.bind
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&bind.resource).await?;
fetch_resource_value(&state, &bind.resource, &params).await
}
/// Scoped to the named alternative first, not flattened across all of
/// them - a feature name (often just "") is only unique within its own
/// alternative, not across a whole question. Flattening silently
/// resolved every same-named feature to whichever alternative happened
/// to be first, so "Subscribers" (and any other later resource-listing
/// alternative sharing an unnamed feature with an earlier one on the
/// same question) always read the first alternative's bucket instead
/// of its own.
#[cfg(feature = "ssr")]
fn find_feature(
state: &crate::server::AppState,
question_id: &str,
alternative: &str,
feature_name: &str,
) -> Result<crate::content::Feature, ServerFnError> {
// resolve_question, not a plain map get: a dynamic page's client
// holds its concrete path as the question id, and resolution is
// also what substitutes the URL segment into the page's resource
// keys - so this lookup is where a `/review/<record>` page's
// feature acquires its record-specific key.
let question = crate::content::resolve_question(&state.questions.load(), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
question
.alternatives
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
.features
.iter()
.find(|f| f.name == feature_name)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown feature"))
}
#[cfg(feature = "ssr")]
async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
if resource.public {
return Ok(());
}
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"));
}
Ok(())
}
#[cfg(feature = "ssr")]
async fn fetch_resource_value(
state: &crate::server::AppState,
resource: &crate::content::ResourceSpec,
params: &std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::content::ResourceSource;
let value = match &resource.source {
ResourceSource::Kv { bucket } => {
// A bucket only ever gets created on first write
// (answers::store_answer's own create-on-first-use, or the
// event-sourcing projection upsert) - a bucket declared in
// content but never yet written to is a completely normal
// "nothing submitted here yet" state, not an error. Listing
// it reads the same as an empty bucket would; asking for
// one specific key that can't possibly exist yet still
// surfaces as an error, same as key-not-found.
let store = state.jetstream.get_key_value(bucket).await.ok();
match (&resource.key, store) {
(Some(_), None) => return Err(ServerFnError::new("resource key not found")),
(None, None) => serde_json::Value::Array(Vec::new()),
(Some(key), Some(store)) => {
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, Some(store)) => {
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);
}
}
}
serde_json::Value::Array(items)
}
}
}
ResourceSource::GiteaStarred { username } => {
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
}
ResourceSource::GiteaOrgRepos { org } => {
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
}
ResourceSource::GiteaReleases { owner, repo } => {
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
.await?
}
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
};
match &resource.jq {
Some(filter) => apply_jq(filter, &value),
None => Ok(value),
}
}
/// Fetches JSON from a path on the same Gitea instance content is
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
/// set - confirmed live against `project.uhhm.no` that at least the
/// starred-repos endpoint 401s unauthenticated, unlike the repo
/// -contents/repo-info endpoints `content.rs` already calls anonymously.
#[cfg(feature = "ssr")]
async fn fetch_gitea_json(
state: &crate::server::AppState,
path: &str,
params: &std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
let mut url = url::Url::parse(&format!("{}{}", state.gitea_base, path))
.map_err(|e| ServerFnError::new(e.to_string()))?;
for (k, v) in params {
url.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let mut req = client.get(url.clone());
if let Ok(token) = std::env::var("GITEA_API_TOKEN") {
if !token.is_empty() {
req = req.header("Authorization", format!("token {token}"));
}
}
let text = req
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?
.text()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
/// Fetches JSON from an arbitrary content-declared URL
/// (`content::ResourceSource::Url`). Content is trusted today (same
/// org owns both this app and its content repo), but a server-side
/// fetch of a content-supplied URL is real SSRF surface the moment
/// that stops being true - `assert_public_https_url` fails closed on
/// anything that isn't `https://` and actually resolves to a public
/// address, not just a syntactically-plausible one.
/// Substitutes `{name}` placeholders in `url` with percent-encoded
/// param values (keeping `/`, since file paths legitimately contain
/// it) and returns the substituted url plus the params that were NOT
/// consumed by a placeholder. Runs before the SSRF check, so a value
/// can't steer the fetch anywhere the check wouldn't allow.
#[cfg(feature = "ssr")]
fn template_url(
url: &str,
params: &std::collections::HashMap<String, String>,
) -> (String, std::collections::HashMap<String, String>) {
let mut out = url.to_string();
let mut leftover = std::collections::HashMap::new();
for (k, v) in params {
let placeholder = format!("{{{k}}}");
if out.contains(&placeholder) {
let encoded: String = v
.split('/')
.map(|seg| urlencoding_encode(seg))
.collect::<Vec<_>>()
.join("/");
out = out.replace(&placeholder, &encoded);
} else {
leftover.insert(k.clone(), v.clone());
}
}
(out, leftover)
}
/// Minimal percent-encoding for a single path segment - everything but
/// unreserved characters.
#[cfg(feature = "ssr")]
fn urlencoding_encode(seg: &str) -> String {
let mut out = String::with_capacity(seg.len());
for b in seg.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(feature = "ssr")]
async fn fetch_url_json(
url: &str,
params: &std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
let (templated, leftover) = template_url(url, params);
let mut parsed = assert_public_https_url(&templated)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
for (k, v) in &leftover {
parsed.query_pairs_mut().append_pair(k, v);
}
let client = openidconnect::reqwest::Client::new();
let text = client
.get(parsed.clone())
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?
.text()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string()))
}
/// `https://` only, and only if every address the host resolves to is
/// public - rejects loopback (127.0.0.1, ::1), RFC1918/link-local
/// private ranges, and unspecified/multicast addresses. Resolves via
/// real DNS (not just a syntax check) so a hostname that *points at* an
/// internal address is caught too, not just a literal internal IP
/// spelled out directly.
#[cfg(feature = "ssr")]
async fn assert_public_https_url(url: &str) -> anyhow::Result<url::Url> {
let parsed = url::Url::parse(url)?;
if parsed.scheme() != "https" {
anyhow::bail!("only https:// resource urls are allowed");
}
let host = parsed
.host_str()
.ok_or_else(|| anyhow::anyhow!("resource url has no host"))?;
let port = parsed.port_or_known_default().unwrap_or(443);
let mut resolved = false;
for addr in tokio::net::lookup_host((host, port))
.await
.map_err(|e| anyhow::anyhow!("resolving resource host {host}: {e}"))?
{
resolved = true;
if !is_public_ip(&addr.ip()) {
anyhow::bail!("resource host {host} resolves to a non-public address");
}
}
if !resolved {
anyhow::bail!("resource host {host} did not resolve to any address");
}
Ok(parsed)
}
#[cfg(feature = "ssr")]
fn is_public_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
!(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| v4.is_documentation())
}
std::net::IpAddr::V6(v6) => {
let is_unique_local = (v6.segments()[0] & 0xfe00) == 0xfc00;
let is_link_local = (v6.segments()[0] & 0xffc0) == 0xfe80;
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| is_unique_local
|| is_link_local)
}
}
}
/// Runs `filter_src` (a jq filter) over `input`, collecting every value
/// it yields into a JSON array - e.g. `.[] | {name, url: .html_url}`
/// over a Gitea repo list yields one shaped object per repo. Pure Rust
/// via the `jaq` crate family, no shell-out to a `jq` binary.
#[cfg(feature = "ssr")]
pub fn apply_jq(
filter_src: &str,
input: &serde_json::Value,
) -> Result<serde_json::Value, ServerFnError> {
use jaq_core::load::{Arena, File, Loader};
use jaq_core::{data, unwrap_valr, Compiler, Ctx, Vars};
use jaq_json::{read, Val};
let input_text = input.to_string();
let input_val = read::parse_single(input_text.as_bytes())
.map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?;
let program = File { code: filter_src, path: () };
let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs());
let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs());
let loader = Loader::new(defs);
let arena = Arena::default();
let modules = loader
.load(&arena, program)
.map_err(|e| ServerFnError::new(format!("jq filter parse error: {e:?}")))?;
let filter = Compiler::default()
.with_funs(funs)
.compile(modules)
.map_err(|e| ServerFnError::new(format!("jq filter compile error: {e:?}")))?;
let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, Vars::new([]));
let mut outputs = Vec::new();
for result in filter.id.run((ctx, input_val)).map(unwrap_valr) {
let val = result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?;
let text = val.to_string();
let json: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?;
outputs.push(json);
}
Ok(serde_json::Value::Array(outputs))
}
#[cfg(test)]
mod tests {
use super::*;
/// A canned, Gitea-API-shaped fixture - the same fields the real
/// `/users/{username}/starred` endpoint returns (Gitea 1.27:
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
/// when the repo's Website setting is empty) - run through the
/// filter `index.yaml`'s "What we've built" resource actually
/// declares, confirming the `jaq` integration produces the shape
/// the frontend showcase card expects: the Website setting wins
/// over the repo url, and a private repo without one gets a null
/// url (unlinked card) instead of a link that 404s for visitors.
#[test]
fn jq_shapes_gitea_repo_list_for_the_showcase() {
let input = serde_json::json!([
{
"name": "cnats",
"description": "A NATS-backed chat client",
"html_url": "https://project.uhhm.no/bl/cnats",
"website": "",
"stars_count": 3,
"private": false
},
{
"name": "secret-product",
"description": "Private repo advertised via its homepage",
"html_url": "https://project.uhhm.no/uhhm/secret-product",
"website": "https://secret-product.example",
"stars_count": 2,
"private": true
},
{
"name": "internal-tool",
"description": "Private repo with no homepage",
"html_url": "https://project.uhhm.no/uhhm/internal-tool",
"website": "",
"stars_count": 1,
"private": true
}
]);
let filter = r#".[] | {name: .name, description: .description,
stars: .stars_count,
url: (if .website != null and .website != "" then .website
elif .private then null
else .html_url end)}"#;
let shaped = apply_jq(filter, &input).expect("filter runs");
let items = shaped.as_array().expect("array output");
assert_eq!(items.len(), 3);
// Public without a website still links to the repo.
assert_eq!(items[0]["name"], "cnats");
assert_eq!(items[0]["url"], "https://project.uhhm.no/bl/cnats");
assert_eq!(items[0]["stars"], 3);
// Private with a website links there, never to the repo.
assert_eq!(items[1]["url"], "https://secret-product.example");
// Private without a website gets no link at all.
assert_eq!(items[2]["url"], serde_json::Value::Null);
// The filter never mentions `private` - confirms shaping
// actually drops fields, not just passes the object through.
assert!(items[0].get("private").is_none());
}
/// Gitea-1.27-shaped release fixtures through the filter
/// redoal/questions' "Our Composition" feature declares. While the
/// repo is private the filter pins `url: null` - a private
/// release's html_url 404s for anonymous visitors, so the card
/// must render an unlinked heading instead.
#[test]
fn jq_shapes_gitea_release_list_for_redoal() {
let input = serde_json::json!([
{
"name": "varde bring-up",
"tag_name": "v0.3.0",
"body": "Embedded varde-core on iOS.",
"published_at": "2026-08-16T09:00:00Z",
"html_url": "https://project.uhhm.no/redoal/redoal/releases/tag/v0.3.0",
"draft": false,
"prerelease": true
},
{
"name": "first echo",
"tag_name": "v0.1.0",
"body": "Gesture keys round-trip.",
"published_at": "2026-05-01T09:00:00Z",
"html_url": "https://project.uhhm.no/redoal/redoal/releases/tag/v0.1.0",
"draft": false,
"prerelease": false
}
]);
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, published: .published_at, url: null}";
let shaped = apply_jq(filter, &input).expect("filter runs");
let items = shaped.as_array().expect("array output");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["name"], "varde bring-up");
assert_eq!(items[0]["tag"], "v0.3.0");
assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private");
assert!(items[0].get("html_url").is_none());
}
#[test]
fn is_public_ip_rejects_loopback_and_private() {
let loopback: std::net::IpAddr = "127.0.0.1".parse().unwrap();
let private: std::net::IpAddr = "10.0.0.5".parse().unwrap();
let link_local: std::net::IpAddr = "169.254.1.1".parse().unwrap();
let public: std::net::IpAddr = "1.1.1.1".parse().unwrap();
assert!(!is_public_ip(&loopback));
assert!(!is_public_ip(&private));
assert!(!is_public_ip(&link_local));
assert!(is_public_ip(&public));
}
#[test]
fn template_url_substitutes_and_keeps_slashes() {
let params = std::collections::HashMap::from([
("path".to_string(), "questions/a b.yaml".to_string()),
("ref".to_string(), "main".to_string()),
]);
let (url, leftover) =
template_url("https://x.example/api/contents/{path}", &params);
// `/` survives, the space percent-encodes, and the param that
// matched a placeholder is consumed.
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
assert_eq!(leftover.len(), 1);
assert_eq!(leftover.get("ref").map(String::as_str), Some("main"));
}
#[test]
fn jq_base64d_decodes_gitea_content() {
// "id: /x\n" base64-encoded, in the shape Gitea's contents API
// returns a file as.
let fixture = serde_json::json!({ "content": "aWQ6IC94Cg==", "encoding": "base64" });
let out = apply_jq(".content | @base64d", &fixture).unwrap();
assert_eq!(out, serde_json::json!(["id: /x\n"]));
}
}