Drop question-report entirely, obfuscate the mailto link instead
Deploy / deploy (push) Successful in 1m2s

The report button logged to an event stream nothing ever read - no
notification, no dashboard, just a durable no-op. A browser back
button already covers "this page wasn't helpful" better than a button
that silently does nothing visible to anyone but the visitor who
clicked it.

The mailto address is now assembled from data-user/data-domain on a
real mouse event instead of baked into the server-rendered href -
keeps a plain mailto: string (what bulk scrapers regex HTML for) out
of what a generic bot crawl sees, without hiding the contact option
from an actual visitor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 20:48:27 +02:00
co-authored by Claude Sonnet 5
parent 27dac946a3
commit 35663a28c1
3 changed files with 34 additions and 92 deletions
+32 -69
View File
@@ -163,7 +163,6 @@ fn QuestionView(
.into_any();
}
let question_id_for_note = question_id.clone();
view! {
<Hero
title=question.name.clone()
@@ -208,58 +207,52 @@ fn QuestionView(
.responsible
.clone()
.map(|r| {
view! { <ResponsibleNote question_id=question_id_for_note.clone() responsible=r/> }
view! { <ResponsibleNote responsible=r/> }
})}
}
.into_any()
}
/// "Asked by X — contact them if you get stuck", plus a one-click
/// "report this question" action (see `report_question`) - mainly
/// meant for a question that reads as unhelpfully LLM-generated, so
/// whoever's responsible for it hears about it.
/// "Asked by X — contact them if you get stuck."
/// The mailto address is assembled from `data-user`/`data-domain` on a
/// real mouse event, never baked into the server-rendered `href` -
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
#[component]
fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoView {
let report = ServerAction::<ReportQuestion>::new();
let reported = RwSignal::new(false);
Effect::new(move |_| {
if report.value().get().is_some_and(|r| r.is_ok()) {
reported.set(true);
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible
.contact
.split_once('@')
.map(|(u, d)| (u.to_string(), d.to_string()))
.unwrap_or((responsible.contact.clone(), String::new()));
// Zero captures (reads data-user/data-domain off the target element
// itself, not from the closure's environment) - Copy, so the same
// value works for both `on:` attributes below without cloning.
let assemble = move |ev: leptos::ev::MouseEvent| {
#[cfg(feature = "hydrate")]
{
let target = event_target::<web_sys::HtmlAnchorElement>(&ev);
if let (Some(user), Some(domain)) =
(target.get_attribute("data-user"), target.get_attribute("data-domain"))
{
let _ = target.set_attribute("href", &format!("mailto:{user}@{domain}"));
}
}
});
#[cfg(not(feature = "hydrate"))]
let _ = &ev;
};
view! {
<p class="question-responsible">
"Asked by " {responsible.name.clone()}
<br/>
""
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
<a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble>
"contact them"
</a>
" if you get stuck."
{move || {
if reported.get() {
view! { <span class="question-reported">" Reported — thanks."</span> }
.into_any()
} else {
let question_id = question_id.clone();
view! {
<button
type="button"
class="question-report"
disabled=move || report.pending().get()
on:click=move |_| {
report
.dispatch(ReportQuestion {
question_id: question_id.clone(),
reason: None,
});
}
>
" Not helpful? Report this question."
</button>
}
.into_any()
}
}}
</p>
}
}
@@ -1411,36 +1404,6 @@ pub async fn submit_answer(
Ok(SubmitResult { next, chain_hash })
}
/// Fire-and-forget "this question wasn't helpful" report - a plain log
/// entry in the event store (`aggregate_type: "question_report"`, no
/// state machine, nothing to transition), reviewable via
/// `events::store::load_events(js, "question_report", question_id)` the
/// same way any other aggregate's history is. No auth, same as reading
/// a public question - a report is cheap, low-stakes signal, not
/// something worth gating behind a session.
#[server]
pub async fn report_question(
question_id: String,
reason: Option<String>,
) -> Result<(), ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let occurred_at_ms = chrono::Utc::now().timestamp_millis();
crate::events::store::append_event(
&state.jetstream,
"question_report",
&question_id,
"reported",
serde_json::json!({ "reason": reason }),
occurred_at_ms,
None,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
/// Uploads one file to `POST /upload` and returns its stored object key
/// - the value a `type: file` requirement contributes to
/// `responses_json`, same as any other field. A plain `fetch`, not a
+2 -4
View File
@@ -21,10 +21,8 @@ pub struct Question {
pub qualifies: Option<String>,
#[serde(default)]
pub alternatives: Vec<Alternative>,
/// Who to contact if a visitor gets stuck or finds this question
/// unhelpful - rendered as a small line on the page, and the person
/// a "report this question" action names in its own event payload
/// (see `events::store`, `question_report` aggregate-less events).
/// Who to contact if a visitor gets stuck - rendered as a small line
/// on the page.
#[serde(default)]
pub responsible: Option<Responsible>,
}