//! 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, ) -> Result { use crate::server::AppState; let state = expect_context::(); 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, ¶ms).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, ) -> Result { use crate::server::AppState; let state = expect_context::(); 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, ¶ms).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, ) -> Result { use crate::server::AppState; let state = expect_context::(); 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, ¶ms).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 { let question = state .questions .load() .get(question_id) .cloned() .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::(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, ) -> Result { 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 = 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::(&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, ) -> Result { 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, std::collections::HashMap) { 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::>() .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, ) -> Result { 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 { 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 { 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::>::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}", ¶ms); // `/` 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"])); } }