Skip to main content

max / makenotwork

4.1 KB · 122 lines History Blame Raw
1 //! The page an acknowledgement alert's link lands on.
2 //!
3 //! Two handlers, and the split between them is the whole point. `GET` renders
4 //! what happened and records nothing. `POST` records that a human read it.
5 //!
6 //! Email verification gets away with acting on a bare `GET`, and this
7 //! deliberately does not. The entire value of the row is evidence that a person
8 //! saw the message, and link prefetchers, corporate mail scanners and
9 //! antivirus proxies follow `GET`s without anybody reading anything. A bare
10 //! `GET` here would silently record attention that never happened, which is
11 //! worse than recording nothing: it would stop the reminders on exactly the
12 //! alerts nobody read.
13
14 use axum::{
15 extract::{Path, State},
16 response::{IntoResponse, Response},
17 };
18 use sqlx::PgPool;
19
20 use crate::{db, error::Result, templates::AcknowledgeTemplate};
21
22 /// Render the alert behind a link token.
23 ///
24 /// An unknown token renders the same generic page as an expired one rather
25 /// than 404ing, so the endpoint does not answer "is this a real token" to
26 /// somebody guessing. Guessing is already infeasible (the token is an HMAC over
27 /// the signing secret), and this costs nothing.
28 #[tracing::instrument(skip_all, name = "email_actions::acknowledge_page")]
29 pub(super) async fn acknowledge_page(
30 State(db): State<PgPool>,
31 Path(token): Path<String>,
32 ) -> Result<Response> {
33 let Some(view) = db::acknowledgements::find_by_token(&db, &token).await? else {
34 return Ok(unknown_link());
35 };
36
37 Ok(AcknowledgeTemplate {
38 csrf_token: None,
39 title: view.kind.title().to_string(),
40 detail: paragraphs(&db::acknowledgements::detail_copy(view.kind, &view.details)),
41 token,
42 acknowledged: view.acknowledged,
43 }
44 .into_response())
45 }
46
47 /// Record that a human read it.
48 ///
49 /// Idempotent: a second submit renders the acknowledged page rather than an
50 /// error, because a double-submitted form is not a failure.
51 #[tracing::instrument(skip_all, name = "email_actions::acknowledge_handler")]
52 pub(super) async fn acknowledge_handler(
53 State(db): State<PgPool>,
54 Path(token): Path<String>,
55 ) -> Result<Response> {
56 let Some(view) = db::acknowledgements::find_by_token(&db, &token).await? else {
57 return Ok(unknown_link());
58 };
59
60 if db::acknowledgements::acknowledge(&db, &token).await? {
61 tracing::info!(kind = %view.kind, "acknowledgement recorded");
62 }
63
64 Ok(AcknowledgeTemplate {
65 csrf_token: None,
66 title: view.kind.title().to_string(),
67 detail: paragraphs(&db::acknowledgements::detail_copy(view.kind, &view.details)),
68 token,
69 acknowledged: true,
70 }
71 .into_response())
72 }
73
74 /// What a token we do not recognise gets. Deliberately says nothing about
75 /// whether the token was wrong, expired, or never existed.
76 fn unknown_link() -> Response {
77 AcknowledgeTemplate {
78 csrf_token: None,
79 title: "This link is no longer active".to_string(),
80 detail: vec![
81 "The link you followed does not point at anything we are still waiting on. \
82 If you were asked to confirm something and this is not it, sign in and \
83 check your account, or reply to the email."
84 .to_string(),
85 ],
86 token: String::new(),
87 acknowledged: true,
88 }
89 .into_response()
90 }
91
92 /// Split mail-body copy into paragraphs for the page.
93 ///
94 /// The copy has one source and its native form is an email body, so the blank
95 /// lines are already there. Splitting here keeps the template from parsing
96 /// prose.
97 fn paragraphs(body: &str) -> Vec<String> {
98 body.split("\n\n")
99 .map(|p| p.replace('\n', " ").trim().to_string())
100 .filter(|p| !p.is_empty())
101 .collect()
102 }
103
104 #[cfg(test)]
105 mod tests {
106 use super::paragraphs;
107
108 #[test]
109 fn splits_on_blank_lines_and_unwraps() {
110 let body = "First line\nwrapped.\n\nSecond paragraph.";
111 assert_eq!(
112 paragraphs(body),
113 vec!["First line wrapped.", "Second paragraph."]
114 );
115 }
116
117 #[test]
118 fn drops_empty_paragraphs() {
119 assert!(paragraphs("\n\n\n\n").is_empty());
120 }
121 }
122