Test / test (pull_request) Successful in 1m12s
A session link top right on a site with sign-in, and on a page with
pictures the wordmark as its own bar over the strip. A list may be
narrowed to the signed-in person's own records ({user.email}, from the
session), and an attachment follows the list that shows its record.
Form controls are sized in em: the date picker's icon and the file
picker's button scale and take the field's colours.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
761 lines
33 KiB
Rust
761 lines
33 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`/`GiteaReleases`/`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, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
|
let resource = feature
|
|
.resource
|
|
.as_ref()
|
|
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
|
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
|
|
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).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, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?;
|
|
let requirement = feature
|
|
.requirements
|
|
.iter()
|
|
.find(|r| r.name == requirement_name)
|
|
.ok_or_else(|| ServerFnError::new("unknown requirement"))?;
|
|
// Inline options: return them as {id,label} objects, the shape the
|
|
// SelectField renders, without touching a resource.
|
|
if !requirement.options.is_empty() {
|
|
let items: Vec<serde_json::Value> = requirement
|
|
.options
|
|
.iter()
|
|
.map(|o| serde_json::json!({ "id": o, "label": o }))
|
|
.collect();
|
|
return Ok(serde_json::Value::Array(items));
|
|
}
|
|
let resource = requirement
|
|
.resource
|
|
.as_ref()
|
|
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
|
|
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
|
|
fetch_resource_value(&state, resource, &as_the_person_asking(params).await).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, crate::server::storyline::current().await, &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(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
|
|
fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).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,
|
|
storyline: usize,
|
|
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().at(storyline), 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(
|
|
state: &crate::server::AppState,
|
|
question_id: &str,
|
|
alternative: &str,
|
|
feature: &str,
|
|
) -> Result<(), ServerFnError> {
|
|
use crate::access::{decide_and_publish, Action, Principal, Resource};
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
|
|
|
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()))?;
|
|
let resource = Resource::Feature {
|
|
page: question_id.to_string(),
|
|
alternative: alternative.to_string(),
|
|
feature: feature.to_string(),
|
|
};
|
|
let decision = decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&Principal::from_user(user.as_ref()),
|
|
Action::Read,
|
|
&resource,
|
|
serde_json::json!({}),
|
|
);
|
|
if decision.allow {
|
|
Ok(())
|
|
} else if user.is_none() {
|
|
Err(ServerFnError::new("not signed in"))
|
|
} else {
|
|
Err(ServerFnError::new("not authorized"))
|
|
}
|
|
}
|
|
|
|
/// Whether a stored record passes the resource's `states` filter. No
|
|
/// filter shows everything, which is what every resource written
|
|
/// before this existed means. A record with no `state` at all is not
|
|
/// an aggregate and is left alone.
|
|
#[cfg(feature = "ssr")]
|
|
fn shows_state(resource: &crate::content::ResourceSpec, value: &serde_json::Value) -> bool {
|
|
if resource.states.is_empty() {
|
|
return true;
|
|
}
|
|
match value.get("state").and_then(|s| s.as_str()) {
|
|
Some(state) => resource.states.iter().any(|s| s == state),
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
/// Whether a stored record passes the resource's `where` filter. The
|
|
/// field is looked for in the record's `responses`, which is where a
|
|
/// form's answers live, and then on the record itself, so a listing
|
|
/// can be narrowed by what was submitted or by what the runtime
|
|
/// stamped on it. A missing field never matches: "everything that did
|
|
/// not say" is not something a page should be able to ask for by
|
|
/// accident.
|
|
///
|
|
/// `equals: "{user.email}"` is the signed-in person's own address: a
|
|
/// list of "my cases". It is filled from the session, never from the
|
|
/// caller, and with nobody signed in it matches nothing.
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) fn matches_filter(
|
|
resource: &crate::content::ResourceSpec,
|
|
value: &serde_json::Value,
|
|
params: &std::collections::HashMap<String, String>,
|
|
) -> bool {
|
|
let Some(filter) = &resource.filter else {
|
|
return true;
|
|
};
|
|
let equals = if filter.equals.contains("{user.email}") {
|
|
match params.get(USER_EMAIL).filter(|e| !e.is_empty()) {
|
|
Some(email) => filter.equals.replace("{user.email}", email),
|
|
None => return false,
|
|
}
|
|
} else {
|
|
filter.equals.clone()
|
|
};
|
|
let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals);
|
|
// Addresses compare without case; everything else as written.
|
|
let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted };
|
|
let found = value
|
|
.get("responses")
|
|
.and_then(|r| r.get(&filter.field))
|
|
.or_else(|| value.get(&filter.field));
|
|
match found {
|
|
Some(serde_json::Value::String(s)) => same(s),
|
|
Some(other) => same(&other.to_string()),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// The parameter a `where` reads the signed-in person's address from.
|
|
/// Set by the server from the session; a caller's own is thrown away.
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) const USER_EMAIL: &str = "__user_email";
|
|
|
|
/// The caller's parameters with the session's person put in and
|
|
/// anything pretending to be them taken out.
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) fn with_person(mut params: std::collections::HashMap<String, String>, user: Option<&crate::auth::User>) -> std::collections::HashMap<String, String> {
|
|
params.retain(|k, _| !k.starts_with("__"));
|
|
if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) {
|
|
params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase());
|
|
}
|
|
params
|
|
}
|
|
|
|
#[cfg(feature = "ssr")]
|
|
async fn as_the_person_asking(params: std::collections::HashMap<String, String>) -> std::collections::HashMap<String, String> {
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
|
let user = match leptos_axum::extract::<tower_sessions::Session>().await {
|
|
Ok(session) => session.get::<User>(SESSION_USER_KEY).await.ok().flatten(),
|
|
Err(_) => None,
|
|
};
|
|
with_person(params, user.as_ref())
|
|
}
|
|
|
|
#[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"))?;
|
|
let value: serde_json::Value =
|
|
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
// A state filter holds for one record as well as a
|
|
// listing, or naming the key would walk around it.
|
|
if !shows_state(resource, &value) || !matches_filter(resource, &value, params) {
|
|
return Err(ServerFnError::new("resource key not found"));
|
|
}
|
|
value
|
|
}
|
|
(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) {
|
|
if shows_state(resource, &value) && matches_filter(resource, &value, params) {
|
|
items.push(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
serde_json::Value::Array(items)
|
|
}
|
|
}
|
|
}
|
|
ResourceSource::GiteaStarred { username } => {
|
|
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), 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 mut req = crate::http::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 response = req
|
|
.send()
|
|
.await
|
|
.and_then(|r| r.error_for_status())
|
|
.map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?;
|
|
let text = crate::http::text(response, crate::http::GITEA_CAP)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?;
|
|
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 response = crate::http::client()
|
|
.get(parsed.clone())
|
|
.send()
|
|
.await
|
|
.and_then(|r| r.error_for_status())
|
|
.map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?;
|
|
// Content picks the URL; the cap is what keeps a runaway endpoint
|
|
// from becoming a runaway allocation here.
|
|
let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?;
|
|
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::*;
|
|
|
|
fn spec(yaml: &str) -> crate::content::ResourceSpec {
|
|
serde_yaml::from_str(yaml).unwrap()
|
|
}
|
|
|
|
fn record(state: &str, responses: serde_json::Value) -> serde_json::Value {
|
|
serde_json::json!({ "id": "r", "state": state, "responses": responses })
|
|
}
|
|
|
|
// A register that is public by law is public about what was
|
|
// decided, not about what was withdrawn.
|
|
#[test]
|
|
fn a_listing_can_be_narrowed_to_the_states_worth_showing() {
|
|
let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n");
|
|
let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n");
|
|
for state in ["approved", "refused", "withdrawn", "open"] {
|
|
assert!(shows_state(&all, &record(state, serde_json::json!({}))));
|
|
}
|
|
assert!(shows_state(&decided, &record("approved", serde_json::json!({}))));
|
|
assert!(shows_state(&decided, &record("refused", serde_json::json!({}))));
|
|
assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({}))));
|
|
// Something that is not an aggregate has no state to filter on
|
|
// and is left alone.
|
|
assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" })));
|
|
}
|
|
|
|
// The other half of a record belonging to a record: the
|
|
// application's page asking for the objections that name it.
|
|
#[test]
|
|
fn a_listing_can_be_narrowed_to_the_children_of_one_record() {
|
|
let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n");
|
|
let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]);
|
|
let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" }));
|
|
let theirs = record("open", serde_json::json!({ "case": "def456" }));
|
|
let neither = record("open", serde_json::json!({ "your_objection": "no case named" }));
|
|
assert!(matches_filter(&children, &mine, ¶ms));
|
|
assert!(!matches_filter(&children, &theirs, ¶ms));
|
|
// A record that did not say is not everyone's child.
|
|
assert!(!matches_filter(&children, &neither, ¶ms));
|
|
|
|
// "My cases": the session's address, never the caller's.
|
|
let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n");
|
|
let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } });
|
|
let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } });
|
|
let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
|
|
let forged: std::collections::HashMap<String, String> = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect();
|
|
let asked = with_person(forged.clone(), Some(&kari));
|
|
assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked));
|
|
// Nobody signed in: the forged parameter is dropped, and nothing matches.
|
|
let anonymous = with_person(forged, None);
|
|
assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous));
|
|
// No filter, no narrowing: every resource written before this
|
|
// existed still lists its whole bucket.
|
|
let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n");
|
|
assert!(matches_filter(&plain, &theirs, ¶ms));
|
|
}
|
|
|
|
#[test]
|
|
fn a_filter_can_name_a_value_outright() {
|
|
let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n");
|
|
let params = std::collections::HashMap::new();
|
|
assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms));
|
|
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
|
}
|
|
|
|
/// 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"]));
|
|
}
|
|
}
|