Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s
Deploy / deploy (push) Successful in 33s
New Alternative.self_transition: like ResourceSpec.transitions, but for an anonymous visitor holding one specific item's own chain hash (from a ?chain= link, the same reference /subscribed?chain=... already carries) rather than a signed-in owner browsing a whole bucket. email is a second factor checked against the stored item, not the lookup key - defense in depth against a leaked/guessed chain hash alone. AlternativeCard renders it as a single confirm button when both ?chain= and ?email= are present; QuestionView hides the alternative entirely otherwise, rather than showing a dead card with nothing to click. Powers /subscribed's new "Unsubscribe" alternative, which the newsletter's own recipient links now carry - no email lookup needed, just the same chain_hash the subscription itself already produced.
This commit is contained in:
@@ -186,3 +186,88 @@ pub async fn transition_answer(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The self-service counterpart to `transition_answer`: no signed-in
|
||||
/// session, no group check - authorized instead by already holding
|
||||
/// `item_id` (a chain hash, opaque and unguessable) plus a matching
|
||||
/// `email`, both carried in the link itself (see
|
||||
/// `content::SelfTransition`). Deliberately returns the same generic
|
||||
/// error for "no such item" and "email doesn't match" - a real
|
||||
/// unsubscribe link should never let someone probe which chain hashes
|
||||
/// or emails exist.
|
||||
#[server]
|
||||
pub async fn self_transition_answer(
|
||||
question_id: String,
|
||||
alternative: String,
|
||||
item_id: String,
|
||||
email: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::chain::hash_node;
|
||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let question = state
|
||||
.questions
|
||||
.load()
|
||||
.get(&question_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||
let self_transition = question
|
||||
.alternatives
|
||||
.iter()
|
||||
.find(|a| a.name == alternative)
|
||||
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
|
||||
.self_transition
|
||||
.clone()
|
||||
.ok_or_else(|| ServerFnError::new("alternative has no self transition"))?;
|
||||
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&self_transition.bucket)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
||||
let bytes = store
|
||||
.get(&item_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new("not found"))?;
|
||||
let mut answer: Answer =
|
||||
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let stored_email = answer.responses.get("email").and_then(|v| v.as_str());
|
||||
if stored_email != Some(email.as_str()) {
|
||||
return Err(ServerFnError::new("not found"));
|
||||
}
|
||||
if answer.state != OPEN_STATE {
|
||||
return Err(ServerFnError::new("already processed"));
|
||||
}
|
||||
|
||||
let decided_ms = chrono::Utc::now().timestamp_millis();
|
||||
answer.state = self_transition.to.clone();
|
||||
answer.decided_ms = Some(decided_ms);
|
||||
answer.decided_by = Some(email.clone());
|
||||
|
||||
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
store
|
||||
.put(&item_id, data.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let decision_payload = serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
|
||||
let parent_hashes = vec![item_id.clone()];
|
||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
|
||||
let event = AnswerSubmitted {
|
||||
chain_hash,
|
||||
parent_hashes,
|
||||
question_id,
|
||||
alternative: self_transition.label,
|
||||
responses: decision_payload,
|
||||
timestamp_ms: decided_ms,
|
||||
};
|
||||
emit_answer_submitted(&state.nats, &event)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+76
-2
@@ -7,7 +7,7 @@ use leptos_router::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::answers::{Answer, TransitionAnswer};
|
||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
|
||||
use crate::auth::{current_user, User};
|
||||
use crate::content::{is_qualified, Alternative, Question, Transition};
|
||||
use crate::resource::get_resource;
|
||||
@@ -73,6 +73,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
}
|
||||
});
|
||||
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
|
||||
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
|
||||
|
||||
let question = Resource::new(move || path.get(), get_question);
|
||||
let user = Resource::new(|| (), |_| current_user());
|
||||
@@ -100,6 +101,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
<QuestionView
|
||||
question=q
|
||||
parent_hash=parent_hash.get()
|
||||
query_email=query_email.get()
|
||||
user=current
|
||||
/>
|
||||
}
|
||||
@@ -116,6 +118,7 @@ fn QuestionPage() -> impl IntoView {
|
||||
fn QuestionView(
|
||||
question: Question,
|
||||
parent_hash: Option<String>,
|
||||
query_email: Option<String>,
|
||||
user: Option<User>,
|
||||
) -> impl IntoView {
|
||||
let question_id = question.id.clone();
|
||||
@@ -162,7 +165,24 @@ fn QuestionView(
|
||||
/>
|
||||
<div class="alternatives">
|
||||
<For
|
||||
each=move || question.alternatives.clone()
|
||||
each={
|
||||
let parent_hash = parent_hash.clone();
|
||||
let query_email = query_email.clone();
|
||||
move || {
|
||||
// A self_transition alternative has nothing to show
|
||||
// (no action to submit, no button target) without
|
||||
// both link params present - hide it rather than
|
||||
// render a dead-looking empty card for anyone who
|
||||
// reaches this question the normal way.
|
||||
let has_link = parent_hash.is_some() && query_email.is_some();
|
||||
question
|
||||
.alternatives
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|a| a.self_transition.is_none() || has_link)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
key=|a| a.name.clone()
|
||||
children=move |alt: Alternative| {
|
||||
view! {
|
||||
@@ -170,6 +190,7 @@ fn QuestionView(
|
||||
question_id=question_id.clone()
|
||||
alternative=alt
|
||||
parent_hash=parent_hash.clone()
|
||||
query_email=query_email.clone()
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -298,7 +319,59 @@ fn AlternativeCard(
|
||||
question_id: String,
|
||||
alternative: Alternative,
|
||||
parent_hash: Option<String>,
|
||||
query_email: Option<String>,
|
||||
) -> impl IntoView {
|
||||
// A link carrying both ?chain= and ?email= against an alternative
|
||||
// that declares a self_transition renders as a one-button
|
||||
// confirmation instead of the normal form - the visitor already
|
||||
// holds everything the action needs (see
|
||||
// `answers::self_transition_answer`), there's nothing left to type.
|
||||
if let (Some(st), Some(item_id), Some(email)) =
|
||||
(alternative.self_transition.clone(), parent_hash.clone(), query_email)
|
||||
{
|
||||
let self_transition = ServerAction::<SelfTransitionAnswer>::new();
|
||||
let question_id_for_action = question_id.clone();
|
||||
let alt_name_for_action = alternative.name.clone();
|
||||
let item_id_for_action = item_id.clone();
|
||||
let email_for_action = email.clone();
|
||||
let label = st.label.clone();
|
||||
return view! {
|
||||
<section class="alt-card">
|
||||
<h2>{alternative.name.clone()}</h2>
|
||||
<p class="alt-description">{alternative.description.clone()}</p>
|
||||
{move || {
|
||||
let question_id_for_action = question_id_for_action.clone();
|
||||
let alt_name_for_action = alt_name_for_action.clone();
|
||||
let item_id_for_action = item_id_for_action.clone();
|
||||
let email_for_action = email_for_action.clone();
|
||||
let label = label.clone();
|
||||
match self_transition.value().get() {
|
||||
None => view! {
|
||||
<button
|
||||
class="alt-submit"
|
||||
disabled=move || self_transition.pending().get()
|
||||
on:click=move |_| {
|
||||
self_transition.dispatch(SelfTransitionAnswer {
|
||||
question_id: question_id_for_action.clone(),
|
||||
alternative: alt_name_for_action.clone(),
|
||||
item_id: item_id_for_action.clone(),
|
||||
email: email_for_action.clone(),
|
||||
});
|
||||
}
|
||||
>
|
||||
{label.clone()}
|
||||
</button>
|
||||
}
|
||||
.into_any(),
|
||||
Some(Ok(())) => view! { <p>"Done."</p> }.into_any(),
|
||||
Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
|
||||
}
|
||||
}}
|
||||
</section>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let has_action = alternative.action.is_some();
|
||||
|
||||
// File fields don't fit a live-typed RwSignal<String> - they get
|
||||
@@ -596,6 +669,7 @@ fn AlternativeCard(
|
||||
})}
|
||||
</section>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
|
||||
|
||||
@@ -58,6 +58,26 @@ pub struct Alternative {
|
||||
/// silently break if the wording changes later.
|
||||
#[serde(default)]
|
||||
pub record_as: Option<String>,
|
||||
/// A transition anyone holding the *right item's own reference* can
|
||||
/// fire - not gated by `requires_group` like `ResourceSpec.transitions`,
|
||||
/// which is for signed-in owners browsing a whole bucket. This is
|
||||
/// the opposite shape: an anonymous visitor who already holds one
|
||||
/// specific chain hash (from a `?chain=` link - see `chain.rs`,
|
||||
/// the same reference `/subscribed?chain=...` already carries) may
|
||||
/// transition *that one item*, and only that one, without ever
|
||||
/// being able to enumerate or touch anyone else's. `email` is a
|
||||
/// second factor checked against the stored item's own `email`
|
||||
/// response field - not the lookup key, just cheap defense in depth
|
||||
/// against a leaked/guessed chain hash alone being sufficient.
|
||||
#[serde(default)]
|
||||
pub self_transition: Option<SelfTransition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SelfTransition {
|
||||
pub bucket: String,
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user