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(())
|
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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::answers::{Answer, TransitionAnswer};
|
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
|
||||||
use crate::auth::{current_user, User};
|
use crate::auth::{current_user, User};
|
||||||
use crate::content::{is_qualified, Alternative, Question, Transition};
|
use crate::content::{is_qualified, Alternative, Question, Transition};
|
||||||
use crate::resource::get_resource;
|
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 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 question = Resource::new(move || path.get(), get_question);
|
||||||
let user = Resource::new(|| (), |_| current_user());
|
let user = Resource::new(|| (), |_| current_user());
|
||||||
@@ -100,6 +101,7 @@ fn QuestionPage() -> impl IntoView {
|
|||||||
<QuestionView
|
<QuestionView
|
||||||
question=q
|
question=q
|
||||||
parent_hash=parent_hash.get()
|
parent_hash=parent_hash.get()
|
||||||
|
query_email=query_email.get()
|
||||||
user=current
|
user=current
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -116,6 +118,7 @@ fn QuestionPage() -> impl IntoView {
|
|||||||
fn QuestionView(
|
fn QuestionView(
|
||||||
question: Question,
|
question: Question,
|
||||||
parent_hash: Option<String>,
|
parent_hash: Option<String>,
|
||||||
|
query_email: Option<String>,
|
||||||
user: Option<User>,
|
user: Option<User>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let question_id = question.id.clone();
|
let question_id = question.id.clone();
|
||||||
@@ -162,7 +165,24 @@ fn QuestionView(
|
|||||||
/>
|
/>
|
||||||
<div class="alternatives">
|
<div class="alternatives">
|
||||||
<For
|
<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()
|
key=|a| a.name.clone()
|
||||||
children=move |alt: Alternative| {
|
children=move |alt: Alternative| {
|
||||||
view! {
|
view! {
|
||||||
@@ -170,6 +190,7 @@ fn QuestionView(
|
|||||||
question_id=question_id.clone()
|
question_id=question_id.clone()
|
||||||
alternative=alt
|
alternative=alt
|
||||||
parent_hash=parent_hash.clone()
|
parent_hash=parent_hash.clone()
|
||||||
|
query_email=query_email.clone()
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -298,7 +319,59 @@ fn AlternativeCard(
|
|||||||
question_id: String,
|
question_id: String,
|
||||||
alternative: Alternative,
|
alternative: Alternative,
|
||||||
parent_hash: Option<String>,
|
parent_hash: Option<String>,
|
||||||
|
query_email: Option<String>,
|
||||||
) -> impl IntoView {
|
) -> 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();
|
let has_action = alternative.action.is_some();
|
||||||
|
|
||||||
// File fields don't fit a live-typed RwSignal<String> - they get
|
// File fields don't fit a live-typed RwSignal<String> - they get
|
||||||
@@ -596,6 +669,7 @@ fn AlternativeCard(
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
|
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
|
||||||
|
|||||||
@@ -58,6 +58,26 @@ pub struct Alternative {
|
|||||||
/// silently break if the wording changes later.
|
/// silently break if the wording changes later.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub record_as: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user