Bound inputs: a field that loads its value from a sibling-parameterized resource
Deploy / deploy (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m7s
Requirement.bind {field, param, resource}: when the watched sibling
changes (a file select, say), the bind's resource is fetched with the
sibling's value as a parameter and the result becomes this field's
value - the on-site editing flow for /develop-proposal, and a generic
select-plus-detail primitive anywhere else. Url sources gain {name}
path templating (percent-encoded, / preserved, SSRF check runs after
substitution); an empty sibling never fetches and never clears an
edit. Bound textareas render monospace.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d8af23167
commit
357d755b8c
+102
-2
@@ -76,6 +76,37 @@ pub async fn get_requirement_options(
|
||||
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]
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -239,15 +270,60 @@ async fn fetch_gitea_json(
|
||||
/// 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 mut parsed = assert_public_https_url(url)
|
||||
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 params {
|
||||
for (k, v) in &leftover {
|
||||
parsed.query_pairs_mut().append_pair(k, v);
|
||||
}
|
||||
let client = openidconnect::reqwest::Client::new();
|
||||
@@ -412,4 +488,28 @@ mod tests {
|
||||
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"]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user