Drop question-report entirely, obfuscate the mailto link instead
Deploy / deploy (push) Successful in 1m2s
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:
co-authored by
Claude Sonnet 5
parent
27dac946a3
commit
35663a28c1
+32
-69
@@ -163,7 +163,6 @@ fn QuestionView(
|
|||||||
.into_any();
|
.into_any();
|
||||||
}
|
}
|
||||||
|
|
||||||
let question_id_for_note = question_id.clone();
|
|
||||||
view! {
|
view! {
|
||||||
<Hero
|
<Hero
|
||||||
title=question.name.clone()
|
title=question.name.clone()
|
||||||
@@ -208,58 +207,52 @@ fn QuestionView(
|
|||||||
.responsible
|
.responsible
|
||||||
.clone()
|
.clone()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
view! { <ResponsibleNote question_id=question_id_for_note.clone() responsible=r/> }
|
view! { <ResponsibleNote responsible=r/> }
|
||||||
})}
|
})}
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Asked by X — contact them if you get stuck", plus a one-click
|
/// "Asked by X — contact them if you get stuck."
|
||||||
/// "report this question" action (see `report_question`) - mainly
|
/// The mailto address is assembled from `data-user`/`data-domain` on a
|
||||||
/// meant for a question that reads as unhelpfully LLM-generated, so
|
/// real mouse event, never baked into the server-rendered `href` -
|
||||||
/// whoever's responsible for it hears about it.
|
/// 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]
|
#[component]
|
||||||
fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoView {
|
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
|
||||||
let report = ServerAction::<ReportQuestion>::new();
|
let (user, domain) = responsible
|
||||||
let reported = RwSignal::new(false);
|
.contact
|
||||||
Effect::new(move |_| {
|
.split_once('@')
|
||||||
if report.value().get().is_some_and(|r| r.is_ok()) {
|
.map(|(u, d)| (u.to_string(), d.to_string()))
|
||||||
reported.set(true);
|
.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! {
|
view! {
|
||||||
<p class="question-responsible">
|
<p class="question-responsible">
|
||||||
"Asked by " {responsible.name.clone()}
|
"Asked by " {responsible.name.clone()}
|
||||||
<br/>
|
<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."
|
" 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>
|
</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1411,36 +1404,6 @@ pub async fn submit_answer(
|
|||||||
Ok(SubmitResult { next, chain_hash })
|
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
|
/// Uploads one file to `POST /upload` and returns its stored object key
|
||||||
/// - the value a `type: file` requirement contributes to
|
/// - the value a `type: file` requirement contributes to
|
||||||
/// `responses_json`, same as any other field. A plain `fetch`, not a
|
/// `responses_json`, same as any other field. A plain `fetch`, not a
|
||||||
|
|||||||
+2
-4
@@ -21,10 +21,8 @@ pub struct Question {
|
|||||||
pub qualifies: Option<String>,
|
pub qualifies: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub alternatives: Vec<Alternative>,
|
pub alternatives: Vec<Alternative>,
|
||||||
/// Who to contact if a visitor gets stuck or finds this question
|
/// Who to contact if a visitor gets stuck - rendered as a small line
|
||||||
/// unhelpful - rendered as a small line on the page, and the person
|
/// on the page.
|
||||||
/// a "report this question" action names in its own event payload
|
|
||||||
/// (see `events::store`, `question_report` aggregate-less events).
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub responsible: Option<Responsible>,
|
pub responsible: Option<Responsible>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,25 +254,6 @@ main.not-found {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-report {
|
|
||||||
font: inherit;
|
|
||||||
color: var(--ink-dim);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-report:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-report:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alt-card {
|
.alt-card {
|
||||||
background: var(--paper-raised);
|
background: var(--paper-raised);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user