forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.6 KiB
1//! Page: email notifications (`/settings/notifications`). One checkbox: whether notifications are
2//! also sent by email. The page notes when the viewer's address is not verified, or when instance
3//! mail is off, since then nothing is sent. Every POST checks the CSRF token first.
4
5use forge_core::{Session, Store, User};
6use serde::Deserialize;
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::content::Form;
10use topcoat::router::error::{SeeOther, see_other};
11use topcoat::router::{page, query_params, route};
12use topcoat::view::{View, component, view};
13
14use crate::auth::{require_csrf, require_user};
15use crate::i18n::{self, Key, Lang, t};
16use crate::state::AppState;
17use crate::views::shell;
18
19#[query_params(error = bad_request)]
20struct NotificationsQuery {
21 saved: Option<String>,
22}
23
24#[derive(Deserialize)]
25struct NotificationsForm {
26 #[serde(default)]
27 csrf: String,
28 /// A cleared checkbox sends nothing, so an absent field means "off".
29 #[serde(default)]
30 email_notifications: Option<String>,
31}
32
33struct NotificationsPage {
34 lang: Lang,
35 csrf: String,
36 on: bool,
37 /// A note when the address is not verified, `None` when it is.
38 unverified_note: Option<&'static str>,
39 /// A note when instance mail is off, `None` when it is on.
40 server_note: Option<&'static str>,
41 notice: Option<&'static str>,
42}
43
44#[component]
45async fn notifications_body(page: NotificationsPage) -> Result<impl View> {
46 let NotificationsPage {
47 lang,
48 csrf,
49 on,
50 unverified_note,
51 server_note,
52 notice,
53 } = page;
54 Ok(view! {
55 <div class="settings-layout">
56 <section class="page-head">
57 <h1>(t(lang, Key::Notifications))</h1>
58 </section>
59 if let Some(message) = notice {
60 <section class="card notice" role="status">
61 <p>(message)</p>
62 </section>
63 }
64 <section class="card">
65 <form method="post" action="/settings/notifications" class="stack">
66 <input type="hidden" name="csrf" value=(csrf.as_str())>
67 <label>
68 <input type="checkbox" name="email_notifications" value="1" checked=(on)>
69 <span>(t(lang, Key::EmailNotifyLabel))</span>
70 </label>
71 <small class="muted">(t(lang, Key::EmailNotifyHelp))</small>
72 if let Some(message) = unverified_note {
73 <p class="muted">(message)</p>
74 }
75 if let Some(message) = server_note {
76 <p class="muted">(message)</p>
77 }
78 <button type="submit" class="primary">(t(lang, Key::Save))</button>
79 </form>
80 </section>
81 </div>
82 })
83}
84
85/// Builds the page for a viewer, reading the instance mail switch and the address state.
86async fn page_for(
87 store: &Store,
88 viewer: &(User, Session),
89 lang: Lang,
90 notice: Option<&'static str>,
91) -> forge_core::Result<NotificationsPage> {
92 let server_on = store.mail_settings().await?.enabled;
93 let verified = store.email_is_verified(&viewer.0.id).await?;
94 Ok(NotificationsPage {
95 lang,
96 csrf: viewer.1.csrf_token.clone(),
97 on: viewer.0.email_notifications,
98 unverified_note: (!verified).then(|| t(lang, Key::EmailNotifyUnverified)),
99 server_note: (!server_on).then(|| t(lang, Key::EmailNotifyServerOff)),
100 notice,
101 })
102}
103
104#[page("/settings/notifications")]
105async fn notifications_page(cx: &Cx) -> Result<impl View> {
106 let viewer = require_user(cx).await?;
107 let store: &Store = &app_context::<AppState>(cx).store;
108 let lang = i18n::lang(cx, Some(&viewer.0));
109 let saved = query_params::<NotificationsQuery>(cx)?.saved.clone();
110 let notice = (saved.as_deref() == Some("1")).then(|| t(lang, Key::EmailNotifySaved));
111 let page = page_for(store, &viewer, lang, notice).await?;
112 Ok(view! {
113 shell(title: t(lang, Key::Notifications), viewer: Some(&viewer), notifications_body(page: page))
114 })
115}
116
117#[route(POST "/settings/notifications")]
118async fn notifications_save(cx: &Cx, Form(form): Form<NotificationsForm>) -> Result<SeeOther> {
119 let viewer = require_csrf(cx, &form.csrf).await?;
120 let store: &Store = &app_context::<AppState>(cx).store;
121 store
122 .set_email_notifications(&viewer.0.id, form.email_notifications.is_some())
123 .await?;
124 Ok(see_other("/settings/notifications?saved=1"))
125}
126
127#[cfg(test)]
128mod tests {
129 use forge_core::Store;
130 use topcoat::router::request::Request;
131 use topcoat::router::{Body, Router, to_bytes};
132
133 use crate::auth::SESSION_COOKIE;
134 use crate::test_support::{app_router, create_session, test_state};
135
136 const TOKEN: &str = "notif-session";
137 const CSRF: &str = "notif-csrf";
138
139 struct Fixture {
140 _dir: tempfile::TempDir,
141 router: Router,
142 store: Store,
143 user_id: String,
144 }
145
146 async fn fixture() -> Fixture {
147 let dir = tempfile::tempdir().expect("tempdir");
148 let data_dir = dir.path().join("data");
149 std::fs::create_dir_all(&data_dir).expect("data dir");
150 let state = test_state(&data_dir).await;
151 let user = state
152 .store
153 .create_user("nina", None, "h", false)
154 .await
155 .expect("user");
156 create_session(&state.store, &user.id, TOKEN, CSRF).await;
157 Fixture {
158 _dir: dir,
159 router: app_router(state.clone()),
160 store: state.store.clone(),
161 user_id: user.id,
162 }
163 }
164
165 async fn send(
166 router: &Router,
167 method: &str,
168 uri: &str,
169 token: Option<&str>,
170 form: Option<String>,
171 ) -> (u16, String) {
172 let mut builder = Request::builder().method(method).uri(uri);
173 if let Some(token) = token {
174 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
175 }
176 let body = match form {
177 Some(form) => {
178 builder = builder.header("content-type", "application/x-www-form-urlencoded");
179 Body::from(form)
180 }
181 None => Body::empty(),
182 };
183 let res = router.handle(builder.body(body).expect("request")).await;
184 let status = res.status().as_u16();
185 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
186 (status, String::from_utf8_lossy(&bytes).into_owned())
187 }
188
189 async fn flag(fx: &Fixture) -> bool {
190 fx.store
191 .user_by_id(&fx.user_id)
192 .await
193 .expect("lookup")
194 .expect("user")
195 .email_notifications
196 }
197
198 #[tokio::test]
199 async fn the_page_needs_a_session() {
200 let fx = fixture().await;
201 let (status, body) = send(&fx.router, "GET", "/settings/notifications", None, None).await;
202 assert_ne!(status, 200, "{body}");
203 assert!(!body.contains("Email me about notifications"));
204 }
205
206 #[tokio::test]
207 async fn the_page_shows_the_checkbox_and_the_notes() {
208 let fx = fixture().await;
209 let (status, body) = send(
210 &fx.router,
211 "GET",
212 "/settings/notifications",
213 Some(TOKEN),
214 None,
215 )
216 .await;
217 assert_eq!(status, 200, "{body}");
218 assert!(body.contains("Email me about notifications"), "{body}");
219 assert!(body.contains("Verify your email address first"), "{body}");
220 assert!(
221 body.contains("Email is turned off on this server"),
222 "{body}"
223 );
224 }
225
226 #[tokio::test]
227 async fn the_checkbox_toggles_the_flag() {
228 let fx = fixture().await;
229 assert!(flag(&fx).await, "on by default");
230
231 let off = format!("csrf={CSRF}");
232 let (status, _) = send(
233 &fx.router,
234 "POST",
235 "/settings/notifications",
236 Some(TOKEN),
237 Some(off),
238 )
239 .await;
240 assert_eq!(status, 303);
241 assert!(!flag(&fx).await, "an unchecked box turns it off");
242
243 let on = format!("csrf={CSRF}&email_notifications=1");
244 let (status, _) = send(
245 &fx.router,
246 "POST",
247 "/settings/notifications",
248 Some(TOKEN),
249 Some(on),
250 )
251 .await;
252 assert_eq!(status, 303);
253 assert!(flag(&fx).await);
254 }
255
256 #[tokio::test]
257 async fn a_wrong_csrf_token_changes_nothing() {
258 let fx = fixture().await;
259 let (status, _) = send(
260 &fx.router,
261 "POST",
262 "/settings/notifications",
263 Some(TOKEN),
264 Some("csrf=wrong".to_owned()),
265 )
266 .await;
267 assert_ne!(status, 303);
268 assert!(flag(&fx).await, "still on");
269 }
270}