2026-08-06 08:52:29 +02:00
|
|
|
//! Generic, authorized reads of live data declared in content
|
2026-07-29 19:38:40 +02:00
|
|
|
//! (`content::ResourceSpec`, on a `Feature`). The one property that
|
2026-08-06 08:52:29 +02:00
|
|
|
//! 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.
|
2026-07-29 19:38:40 +02:00
|
|
|
|
|
|
|
|
use leptos::prelude::*;
|
|
|
|
|
|
2026-08-05 15:04:02 +02:00
|
|
|
/// 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"
|
2026-08-06 08:52:29 +02:00
|
|
|
/// 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.
|
2026-08-15 12:18:15 +02:00
|
|
|
#[server(endpoint = "get_resource")]
|
2026-07-29 19:38:40 +02:00
|
|
|
pub async fn get_resource(
|
|
|
|
|
question_id: String,
|
2026-08-05 15:04:02 +02:00
|
|
|
alternative: String,
|
2026-07-29 19:38:40 +02:00
|
|
|
feature_name: String,
|
2026-08-12 16:53:12 +02:00
|
|
|
// 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)]
|
2026-08-06 08:52:29 +02:00
|
|
|
params: std::collections::HashMap<String, String>,
|
2026-07-29 19:38:40 +02:00
|
|
|
) -> Result<serde_json::Value, ServerFnError> {
|
|
|
|
|
use crate::server::AppState;
|
|
|
|
|
|
|
|
|
|
let state = expect_context::<AppState>();
|
2026-08-06 11:57:15 +02:00
|
|
|
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`.
|
2026-08-15 12:18:15 +02:00
|
|
|
#[server(endpoint = "get_requirement_options")]
|
2026-08-06 11:57:15 +02:00
|
|
|
pub async fn get_requirement_options(
|
|
|
|
|
question_id: String,
|
|
|
|
|
alternative: String,
|
|
|
|
|
feature_name: String,
|
|
|
|
|
requirement_name: String,
|
2026-08-12 16:53:12 +02:00
|
|
|
// See get_resource's `params` for why this is needed.
|
|
|
|
|
#[server(default)]
|
2026-08-06 11:57:15 +02:00
|
|
|
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, ¶ms).await
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 13:49:42 +02:00
|
|
|
/// 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).
|
2026-08-15 12:18:15 +02:00
|
|
|
#[server(endpoint = "get_requirement_binding")]
|
2026-08-13 13:49:42 +02:00
|
|
|
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, ¶ms).await
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 11:57:15 +02:00
|
|
|
/// 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> {
|
2026-07-29 19:38:40 +02:00
|
|
|
let question = state
|
|
|
|
|
.questions
|
2026-08-05 07:20:40 +02:00
|
|
|
.load()
|
2026-08-06 11:57:15 +02:00
|
|
|
.get(question_id)
|
2026-08-05 07:20:40 +02:00
|
|
|
.cloned()
|
2026-07-29 19:38:40 +02:00
|
|
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
2026-08-06 11:57:15 +02:00
|
|
|
question
|
2026-07-29 19:38:40 +02:00
|
|
|
.alternatives
|
|
|
|
|
.iter()
|
2026-08-05 15:04:02 +02:00
|
|
|
.find(|a| a.name == alternative)
|
|
|
|
|
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
|
|
|
|
|
.features
|
|
|
|
|
.iter()
|
2026-07-29 19:38:40 +02:00
|
|
|
.find(|f| f.name == feature_name)
|
2026-08-06 11:57:15 +02:00
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| ServerFnError::new("unknown feature"))
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
|
2026-08-06 11:57:15 +02:00
|
|
|
#[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(());
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
2026-08-06 11:57:15 +02:00
|
|
|
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;
|
2026-07-29 19:38:40 +02:00
|
|
|
|
2026-08-06 08:52:29 +02:00
|
|
|
let value = match &resource.source {
|
|
|
|
|
ResourceSource::Kv { bucket } => {
|
2026-08-06 12:42:54 +02:00
|
|
|
// 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)) => {
|
2026-08-06 08:52:29 +02:00
|
|
|
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()))?
|
|
|
|
|
}
|
2026-08-06 12:42:54 +02:00
|
|
|
(None, Some(store)) => {
|
2026-08-06 08:52:29 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
2026-08-06 08:52:29 +02:00
|
|
|
serde_json::Value::Array(items)
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|
|
|
|
|
ResourceSource::GiteaStarred { username } => {
|
2026-08-06 11:57:15 +02:00
|
|
|
fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await?
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|
|
|
|
|
ResourceSource::GiteaOrgRepos { org } => {
|
2026-08-06 11:57:15 +02:00
|
|
|
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|
2026-08-23 13:03:29 +02:00
|
|
|
ResourceSource::GiteaReleases { owner, repo } => {
|
|
|
|
|
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
|
|
|
|
|
.await?
|
|
|
|
|
}
|
2026-08-06 11:57:15 +02:00
|
|
|
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
2026-08-06 08:52:29 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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.
|
2026-08-13 13:49:42 +02:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 08:52:29 +02:00
|
|
|
#[cfg(feature = "ssr")]
|
|
|
|
|
async fn fetch_url_json(
|
|
|
|
|
url: &str,
|
|
|
|
|
params: &std::collections::HashMap<String, String>,
|
|
|
|
|
) -> Result<serde_json::Value, ServerFnError> {
|
2026-08-13 13:49:42 +02:00
|
|
|
let (templated, leftover) = template_url(url, params);
|
|
|
|
|
let mut parsed = assert_public_https_url(&templated)
|
2026-08-06 08:52:29 +02:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
2026-08-13 13:49:42 +02:00
|
|
|
for (k, v) in &leftover {
|
2026-08-06 08:52:29 +02:00
|
|
|
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)
|
2026-07-29 19:38:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-06 08:52:29 +02:00
|
|
|
|
|
|
|
|
/// 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
|
2026-08-23 11:27:49 +02:00
|
|
|
/// `/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
|
2026-08-06 08:52:29 +02:00
|
|
|
/// filter `index.yaml`'s "What we've built" resource actually
|
|
|
|
|
/// declares, confirming the `jaq` integration produces the shape
|
2026-08-23 11:27:49 +02:00
|
|
|
/// 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.
|
2026-08-06 08:52:29 +02:00
|
|
|
#[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",
|
2026-08-23 11:27:49 +02:00
|
|
|
"website": "",
|
|
|
|
|
"stars_count": 3,
|
2026-08-06 08:52:29 +02:00
|
|
|
"private": false
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-08-23 11:27:49 +02:00
|
|
|
"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
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|
|
|
|
|
]);
|
2026-08-23 11:27:49 +02:00
|
|
|
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)}"#;
|
2026-08-06 08:52:29 +02:00
|
|
|
|
|
|
|
|
let shaped = apply_jq(filter, &input).expect("filter runs");
|
|
|
|
|
let items = shaped.as_array().expect("array output");
|
2026-08-23 11:27:49 +02:00
|
|
|
assert_eq!(items.len(), 3);
|
|
|
|
|
// Public without a website still links to the repo.
|
2026-08-06 08:52:29 +02:00
|
|
|
assert_eq!(items[0]["name"], "cnats");
|
|
|
|
|
assert_eq!(items[0]["url"], "https://project.uhhm.no/bl/cnats");
|
|
|
|
|
assert_eq!(items[0]["stars"], 3);
|
2026-08-23 11:27:49 +02:00
|
|
|
// 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);
|
2026-08-06 08:52:29 +02:00
|
|
|
// The filter never mentions `private` - confirms shaping
|
|
|
|
|
// actually drops fields, not just passes the object through.
|
|
|
|
|
assert!(items[0].get("private").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 13:03:29 +02:00
|
|
|
/// 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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 08:52:29 +02:00
|
|
|
#[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));
|
|
|
|
|
}
|
2026-08-13 13:49:42 +02:00
|
|
|
|
|
|
|
|
#[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"]));
|
|
|
|
|
}
|
2026-08-06 08:52:29 +02:00
|
|
|
}
|