forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
29.4 KiB
1//! Page: profile (`/settings/profile`). Username capitalization, avatar upload and removal,
2//! the "about" text and the timezone. Every POST checks the CSRF token first.
3
4use forge_core::AuditAction;
5use forge_core::names::capitalization_change;
6use forge_core::tz::{all_timezones, validate_timezone};
7use forge_core::{Session, Store, User, avatar::MAX_UPLOAD_BYTES, avatar::process_avatar};
8use serde::Deserialize;
9use topcoat::Result;
10use topcoat::context::{Cx, app_context};
11use topcoat::router::content::Form;
12use topcoat::router::content::multipart::Multipart;
13use topcoat::router::error::{SeeOther, see_other};
14use topcoat::router::{BodyLimit, page, query_params, route};
15use topcoat::view::{View, component, view};
16
17use crate::audit::{self, Actor, Target};
18use crate::auth::{require_csrf, require_user};
19use crate::i18n::{self, Key, Lang, about_too_long, t};
20use crate::state::AppState;
21use crate::views::{alert, avatar_url, shell, user_facing_message};
22
23/// Longest "about" text, in characters.
24pub const MAX_ABOUT_CHARS: usize = 2000;
25
26/// Multipart framing overhead allowed on top of the image itself.
27const MULTIPART_OVERHEAD: usize = 64 * 1024;
28
29/// Body limit for the avatar upload route. The default 2 MiB is too small for a 5 MiB image.
30pub fn avatar_body_limit() -> BodyLimit {
31 BodyLimit::max(MAX_UPLOAD_BYTES + MULTIPART_OVERHEAD).at("/settings/profile/avatar")
32}
33
34#[query_params(error = bad_request)]
35struct ProfileQuery {
36 saved: Option<String>,
37}
38
39#[derive(Deserialize)]
40struct ProfileForm {
41 #[serde(default)]
42 csrf: String,
43 #[serde(default)]
44 username: String,
45}
46
47#[derive(Deserialize)]
48struct DetailsForm {
49 #[serde(default)]
50 csrf: String,
51 #[serde(default)]
52 about: String,
53 #[serde(default)]
54 timezone: String,
55}
56
57#[derive(Deserialize)]
58struct CsrfForm {
59 #[serde(default)]
60 csrf: String,
61}
62
63/// Values shown in the forms. On a failed POST these are what the user typed.
64struct Draft {
65 username: String,
66 about: String,
67 timezone: String,
68}
69
70impl Draft {
71 fn from_user(user: &User) -> Self {
72 Self {
73 username: user.username.clone(),
74 about: user.about.clone(),
75 timezone: user.timezone.clone(),
76 }
77 }
78}
79
80/// Per-section error messages.
81#[derive(Default)]
82struct Errors {
83 username: Option<String>,
84 avatar: Option<String>,
85 details: Option<String>,
86}
87
88struct Zone {
89 name: String,
90 selected: bool,
91}
92
93struct ProfilePage {
94 lang: Lang,
95 csrf: String,
96 current: String,
97 draft: Draft,
98 errors: Errors,
99 notice: Option<&'static str>,
100 avatar_src: String,
101 has_avatar: bool,
102 zones: Vec<Zone>,
103}
104
105#[component]
106async fn profile_body(page: ProfilePage) -> Result<impl View> {
107 let ProfilePage {
108 lang,
109 csrf,
110 current,
111 draft,
112 errors,
113 notice,
114 avatar_src,
115 has_avatar,
116 zones,
117 } = page;
118 Ok(view! {
119 <div class="settings-layout">
120 <section class="page-head">
121 <h1>(t(lang, Key::Profile))</h1>
122 </section>
123 if let Some(message) = notice {
124 <section class="card notice" role="status">
125 <p>(message)</p>
126 </section>
127 }
128 <section class="card">
129 <h2>(t(lang, Key::Username))</h2>
130 <p>(t(lang, Key::CurrentUsername)) <strong>(current.as_str())</strong></p>
131 if let Some(message) = errors.username {
132 alert(message: message.as_str())
133 }
134 <form method="post" action="/settings/profile" class="stack">
135 <input type="hidden" name="csrf" value=(csrf.as_str())>
136 <label>
137 <span>(t(lang, Key::Username))</span>
138 <input type="text" name="username" maxlength="64" required=(true) autocomplete="username" value=(draft.username.as_str())>
139 <small class="muted">(t(lang, Key::UsernameHint))</small>
140 </label>
141 <button type="submit" class="primary">(t(lang, Key::Save))</button>
142 </form>
143 </section>
144 <section class="card">
145 <h2>(t(lang, Key::Avatar))</h2>
146 <div class="avatar-row">
147 <img class="avatar avatar-lg" src=(avatar_src.as_str()) alt=(t(lang, Key::AvatarAlt)) width="96" height="96">
148 <div class="stack">
149 if let Some(message) = errors.avatar {
150 alert(message: message.as_str())
151 }
152 <form method="post" action="/settings/profile/avatar" enctype="multipart/form-data" class="stack">
153 <input type="hidden" name="csrf" value=(csrf.as_str())>
154 <label>
155 <span>(t(lang, Key::Image))</span>
156 <input type="file" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" required=(true)>
157 <small class="muted">(t(lang, Key::AvatarHint))</small>
158 </label>
159 <button type="submit" class="primary">(t(lang, Key::Upload))</button>
160 </form>
161 if has_avatar {
162 <form method="post" action="/settings/profile/avatar/delete">
163 <input type="hidden" name="csrf" value=(csrf.as_str())>
164 <button type="submit" class="link-button">(t(lang, Key::RemoveAvatar))</button>
165 </form>
166 }
167 </div>
168 </div>
169 </section>
170 <section class="card">
171 <h2>(t(lang, Key::AboutAndTimezone))</h2>
172 if let Some(message) = errors.details {
173 alert(message: message.as_str())
174 }
175 <form method="post" action="/settings/profile/details" class="stack">
176 <input type="hidden" name="csrf" value=(csrf.as_str())>
177 <label>
178 <span>(t(lang, Key::About))</span>
179 <textarea name="about" rows="8" maxlength="2000">(draft.about.as_str())</textarea>
180 <small class="muted">(t(lang, Key::AboutHint))</small>
181 </label>
182 <label>
183 <span>(t(lang, Key::Timezone))</span>
184 <select name="timezone">
185 for zone in zones.into_iter() {
186 <option value=(zone.name.clone()) selected=(zone.selected)>(zone.name)</option>
187 }
188 </select>
189 </label>
190 <button type="submit" class="primary">(t(lang, Key::Save))</button>
191 </form>
192 </section>
193 </div>
194 })
195}
196
197/// Collects what the profile page shows. `draft` fills the forms; `errors` shows under each section.
198async fn page_data(
199 viewer: &(User, Session),
200 lang: Lang,
201 draft: Draft,
202 errors: Errors,
203 notice: Option<&'static str>,
204) -> Result<ProfilePage> {
205 let hash = viewer.0.avatar_hash.clone();
206 let zones = all_timezones()
207 .into_iter()
208 .map(|name| {
209 let selected = name == draft.timezone;
210 Zone { name, selected }
211 })
212 .collect();
213 Ok(ProfilePage {
214 lang,
215 csrf: viewer.1.csrf_token.clone(),
216 current: viewer.0.username.clone(),
217 draft,
218 errors,
219 notice,
220 avatar_src: avatar_url(&viewer.0.username, hash.as_deref()),
221 has_avatar: hash.is_some(),
222 zones,
223 })
224}
225
226fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> {
227 match saved {
228 Some("1") => Some(t(lang, Key::OkUsernameUpdated)),
229 Some("avatar") => Some(t(lang, Key::OkAvatarUpdated)),
230 Some("avatar-removed") => Some(t(lang, Key::OkAvatarRemoved)),
231 Some("details") => Some(t(lang, Key::OkProfileSaved)),
232 _ => None,
233 }
234}
235
236/// Checks the "about" length and the timezone name. Returns the message-carrying error.
237fn check_details(lang: Lang, about: &str, timezone: &str) -> forge_core::Result<()> {
238 if about.chars().count() > MAX_ABOUT_CHARS {
239 return Err(forge_core::Error::Invalid(about_too_long(
240 lang,
241 MAX_ABOUT_CHARS,
242 )));
243 }
244 validate_timezone(timezone)
245}
246
247/// Stores a processed avatar, or removes it if no image was sent.
248async fn store_avatar(
249 store: &Store,
250 user_id: &str,
251 image: Option<Vec<u8>>,
252 lang: Lang,
253) -> forge_core::Result<()> {
254 let Some(bytes) = image else {
255 return Err(forge_core::Error::Invalid(
256 t(lang, Key::ErrChooseImage).into(),
257 ));
258 };
259 // Decoding and resizing are CPU-bound; keep them off the async workers.
260 let joined = tokio::task::spawn_blocking(move || process_avatar(&bytes)).await;
261 let processed = joined.map_err(std::io::Error::other)??;
262 store
263 .set_avatar(user_id, &processed.png, &processed.hash)
264 .await
265}
266
267#[page("/settings/profile")]
268async fn profile_page(cx: &Cx) -> Result<impl View> {
269 let viewer = require_user(cx).await?;
270 let lang = i18n::lang(cx, Some(&viewer.0));
271 let saved = query_params::<ProfileQuery>(cx)?.saved.clone();
272 let draft = Draft::from_user(&viewer.0);
273 let page = page_data(
274 &viewer,
275 lang,
276 draft,
277 Errors::default(),
278 notice_for(lang, saved.as_deref()),
279 )
280 .await?;
281 Ok(view! {
282 shell(title: "Profile", viewer: Some(&viewer), profile_body(page: page))
283 })
284}
285
286#[page(POST "/settings/profile")]
287async fn profile_save(cx: &Cx, Form(form): Form<ProfileForm>) -> Result<impl View> {
288 let viewer = require_csrf(cx, &form.csrf).await?;
289 let lang = i18n::lang(cx, Some(&viewer.0));
290 let store = &app_context::<AppState>(cx).store;
291
292 let result = match capitalization_change(&viewer.0.username, &form.username) {
293 Ok(display) => store.set_username_display(&viewer.0.id, &display).await,
294 Err(e) => Err(e),
295 };
296 let message = match result {
297 Ok(()) => {
298 audit::record(
299 store,
300 Some(cx),
301 Actor::User(&viewer.0),
302 AuditAction::UsernameChanged,
303 Target {
304 kind: "user",
305 id: &viewer.0.id,
306 name: form.username.trim(),
307 },
308 serde_json::json!({ "from": viewer.0.username }),
309 )
310 .await;
311 return Err(see_other("/settings/profile?saved=1").into());
312 }
313 Err(e) => match user_facing_message(&e) {
314 Some(message) => message,
315 None => return Err(e.into()),
316 },
317 };
318
319 let draft = Draft {
320 username: form.username,
321 ..Draft::from_user(&viewer.0)
322 };
323 let errors = Errors {
324 username: Some(message),
325 ..Errors::default()
326 };
327 let page = page_data(&viewer, lang, draft, errors, None).await?;
328 Ok(view! {
329 shell(title: "Profile", viewer: Some(&viewer), profile_body(page: page))
330 })
331}
332
333#[page(POST "/settings/profile/details")]
334async fn details_save(cx: &Cx, Form(form): Form<DetailsForm>) -> Result<impl View> {
335 let viewer = require_csrf(cx, &form.csrf).await?;
336 let store = &app_context::<AppState>(cx).store;
337 let lang = i18n::lang(cx, Some(&viewer.0));
338 let about = form.about.replace("\r\n", "\n");
339
340 let result = match check_details(lang, &about, &form.timezone) {
341 Ok(()) => {
342 store
343 .set_profile(&viewer.0.id, &about, &form.timezone)
344 .await
345 }
346 Err(e) => Err(e),
347 };
348 let message = match result {
349 Ok(()) => return Err(see_other("/settings/profile?saved=details").into()),
350 Err(e) => match user_facing_message(&e) {
351 Some(message) => message,
352 None => return Err(e.into()),
353 },
354 };
355
356 let draft = Draft {
357 username: viewer.0.username.clone(),
358 about,
359 timezone: form.timezone,
360 };
361 let errors = Errors {
362 details: Some(message),
363 ..Errors::default()
364 };
365 let page = page_data(&viewer, lang, draft, errors, None).await?;
366 Ok(view! {
367 shell(title: "Profile", viewer: Some(&viewer), profile_body(page: page))
368 })
369}
370
371/// Reads the multipart body. The CSRF field is checked before the image is decoded.
372#[page(POST "/settings/profile/avatar")]
373async fn avatar_upload(cx: &Cx, mut multipart: Multipart) -> Result<impl View> {
374 let mut csrf = String::new();
375 let mut image = None;
376 while let Some(field) = multipart.next_field().await? {
377 let name = field.name().map(str::to_owned);
378 match name.as_deref() {
379 Some("csrf") => csrf = field.text().await?,
380 Some("avatar") => image = Some(field.bytes().await?.to_vec()),
381 _ => {}
382 }
383 }
384 let viewer = require_csrf(cx, &csrf).await?;
385 let lang = i18n::lang(cx, Some(&viewer.0));
386 let store = &app_context::<AppState>(cx).store;
387
388 let message = match store_avatar(store, &viewer.0.id, image, lang).await {
389 Ok(()) => return Err(see_other("/settings/profile?saved=avatar").into()),
390 Err(e) => match user_facing_message(&e) {
391 Some(message) => message,
392 None => return Err(e.into()),
393 },
394 };
395 let errors = Errors {
396 avatar: Some(message),
397 ..Errors::default()
398 };
399 let page = page_data(&viewer, lang, Draft::from_user(&viewer.0), errors, None).await?;
400 Ok(view! {
401 shell(title: "Profile", viewer: Some(&viewer), profile_body(page: page))
402 })
403}
404
405#[route(POST "/settings/profile/avatar/delete")]
406async fn avatar_remove(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
407 let viewer = require_csrf(cx, &form.csrf).await?;
408 app_context::<AppState>(cx)
409 .store
410 .delete_avatar(&viewer.0.id)
411 .await?;
412 Ok(see_other("/settings/profile?saved=avatar-removed"))
413}
414
415#[cfg(test)]
416pub(crate) mod tests {
417
418 use forge_core::{Store, User};
419
420 use topcoat::router::request::Request;
421 use topcoat::router::{Body, Router, to_bytes};
422
423 use crate::auth::SESSION_COOKIE;
424
425 pub(crate) const TOKEN: &str = "profile-test-session";
426 pub(crate) const CSRF: &str = "profile-test-csrf";
427
428 pub(crate) struct Fixture {
429 pub router: Router,
430 pub store: Store,
431 pub user: User,
432 _dir: tempfile::TempDir,
433 }
434
435 pub(crate) async fn fixture() -> Fixture {
436 let dir = tempfile::tempdir().expect("tempdir");
437 let path = dir.path().to_path_buf();
438 let state = crate::test_support::test_state(&path).await;
439 let store = state.store.clone();
440 let router = crate::test_support::app_router(state);
441 let user = store
442 .create_user("tahinli", None, "not-a-real-hash", false)
443 .await
444 .unwrap();
445 crate::test_support::create_session(&store, &user.id, TOKEN, CSRF).await;
446 Fixture {
447 router,
448 store,
449 user,
450 _dir: dir,
451 }
452 }
453
454 /// A response with its status, headers and raw body.
455 pub(crate) struct Reply {
456 pub status: u16,
457 pub headers: http::HeaderMap,
458 pub body: Vec<u8>,
459 }
460
461 impl Reply {
462 pub fn header(&self, name: &str) -> Option<String> {
463 self.headers
464 .get(name)
465 .and_then(|v| v.to_str().ok())
466 .map(str::to_owned)
467 }
468
469 pub fn text(&self) -> String {
470 String::from_utf8_lossy(&self.body).into_owned()
471 }
472 }
473
474 /// Sends one request with an optional session cookie and an explicit content type.
475 pub(crate) async fn send_full(
476 router: &Router,
477 method: &str,
478 uri: &str,
479 signed_in: bool,
480 content_type: Option<&str>,
481 body: Vec<u8>,
482 ) -> Reply {
483 let mut builder = Request::builder().method(method).uri(uri);
484 if signed_in {
485 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}"));
486 }
487 if let Some(content_type) = content_type {
488 builder = builder.header("content-type", content_type);
489 }
490 let res = router.handle(builder.body(Body::from(body)).unwrap()).await;
491 let status = res.status().as_u16();
492 let headers = res.headers().clone();
493 let body = to_bytes(res.into_body(), usize::MAX)
494 .await
495 .unwrap()
496 .to_vec();
497 Reply {
498 status,
499 headers,
500 body,
501 }
502 }
503
504 /// Sends one request with an optional session cookie. Returns `(status, location, body)`.
505 pub(crate) async fn send(
506 router: &Router,
507 method: &str,
508 uri: &str,
509 signed_in: bool,
510 form: Option<&str>,
511 ) -> (u16, Option<String>, String) {
512 let reply = match form {
513 Some(form) => {
514 send_full(
515 router,
516 method,
517 uri,
518 signed_in,
519 Some("application/x-www-form-urlencoded"),
520 form.as_bytes().to_vec(),
521 )
522 .await
523 }
524 None => send_full(router, method, uri, signed_in, None, Vec::new()).await,
525 };
526 let location = reply.header("location");
527 (reply.status, location, reply.text())
528 }
529
530 /// Builds a `multipart/form-data` body. Each field is `(name, Some((filename, type)) for
531 /// files, bytes)`. Returns the content type and the body.
532 /// One multipart field: name, optional `(filename, content type)` and the bytes.
533 pub(crate) type Field<'a> = (&'a str, Option<(&'a str, &'a str)>, &'a [u8]);
534
535 pub(crate) fn multipart(fields: &[Field<'_>]) -> (String, Vec<u8>) {
536 const BOUNDARY: &str = "forgeTestBoundary7MA4YWxkTrZu0gW";
537 let mut body = Vec::new();
538 for (name, file, data) in fields {
539 body.extend_from_slice(
540 format!("--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"{name}\"")
541 .as_bytes(),
542 );
543 if let Some((filename, content_type)) = file {
544 body.extend_from_slice(
545 format!("; filename=\"{filename}\"\r\nContent-Type: {content_type}").as_bytes(),
546 );
547 }
548 body.extend_from_slice(b"\r\n\r\n");
549 body.extend_from_slice(data);
550 body.extend_from_slice(b"\r\n");
551 }
552 body.extend_from_slice(format!("--{BOUNDARY}--\r\n").as_bytes());
553 (format!("multipart/form-data; boundary={BOUNDARY}"), body)
554 }
555
556 /// A 64x48 PNG generated with the `image` crate.
557 pub(crate) fn png_bytes() -> Vec<u8> {
558 let img = image::RgbImage::from_fn(64, 48, |x, y| {
559 image::Rgb([(x * 4) as u8, (y * 5) as u8, 128])
560 });
561 let mut out = Vec::new();
562 image::DynamicImage::ImageRgb8(img)
563 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
564 .expect("encode png");
565 out
566 }
567
568 /// Uploads `file` as the avatar of the signed-in test user, with `csrf` as the token.
569 pub(crate) async fn upload_avatar(router: &Router, csrf: &str, file: &[u8]) -> Reply {
570 let csrf_field = csrf.as_bytes();
571 let (content_type, body) = multipart(&[
572 ("csrf", None, csrf_field),
573 ("avatar", Some(("avatar.png", "image/png")), file),
574 ]);
575 send_full(
576 router,
577 "POST",
578 "/settings/profile/avatar",
579 true,
580 Some(&content_type),
581 body,
582 )
583 .await
584 }
585
586 #[tokio::test]
587 async fn tahinli_to_capitalized_succeeds_and_dashboard_shows_it() {
588 let fx = fixture().await;
589 let form = format!("csrf={CSRF}&username=Tahinli");
590 let (status, location, _) =
591 send(&fx.router, "POST", "/settings/profile", true, Some(&form)).await;
592 assert_eq!(status, 303);
593 assert_eq!(location.as_deref(), Some("/settings/profile?saved=1"));
594
595 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
596 assert_eq!(stored.username, "Tahinli");
597
598 let (status, _, body) = send(&fx.router, "GET", "/", true, None).await;
599 assert_eq!(status, 200);
600 assert!(body.contains("<summary aria-label=\"Tahinli\">"), "{body}");
601
602 let (status, _, body) =
603 send(&fx.router, "GET", "/settings/profile?saved=1", true, None).await;
604 assert_eq!(status, 200);
605 assert!(body.contains("Username updated."), "{body}");
606 }
607
608 #[tokio::test]
609 async fn a_different_name_is_rejected_and_nothing_is_saved() {
610 let fx = fixture().await;
611 let form = format!("csrf={CSRF}&username=other");
612 let (status, _, body) =
613 send(&fx.router, "POST", "/settings/profile", true, Some(&form)).await;
614 assert_eq!(status, 200);
615 assert!(
616 body.contains("Only the capitalization can be changed."),
617 "{body}"
618 );
619 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
620 assert_eq!(stored.username, "tahinli");
621 }
622
623 #[tokio::test]
624 async fn post_without_a_valid_csrf_token_is_rejected() {
625 let fx = fixture().await;
626 let forged = "csrf=wrong&username=Tahinli";
627 let (status, _, _) =
628 send(&fx.router, "POST", "/settings/profile", true, Some(forged)).await;
629 assert_eq!(status, 403);
630 let missing = "username=Tahinli";
631 let (status, _, _) =
632 send(&fx.router, "POST", "/settings/profile", true, Some(missing)).await;
633 assert_eq!(status, 403);
634 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
635 assert_eq!(stored.username, "tahinli");
636 }
637
638 #[tokio::test]
639 async fn anonymous_requests_are_sent_to_login() {
640 let fx = fixture().await;
641 let (status, location, _) = send(&fx.router, "GET", "/settings/profile", false, None).await;
642 assert_eq!(status, 303);
643 assert!(location.unwrap_or_default().starts_with("/login?next="));
644
645 let form = format!("csrf={CSRF}&username=Tahinli");
646 let (status, location, _) =
647 send(&fx.router, "POST", "/settings/profile", false, Some(&form)).await;
648 assert_eq!(status, 303);
649 assert!(location.unwrap_or_default().starts_with("/login?next="));
650 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
651 assert_eq!(stored.username, "tahinli");
652 }
653
654 #[tokio::test]
655 async fn about_and_timezone_round_trip() {
656 let fx = fixture().await;
657 let form = format!("csrf={CSRF}&about=Hello+*world*&timezone=Europe%2FIstanbul");
658 let (status, location, _) = send(
659 &fx.router,
660 "POST",
661 "/settings/profile/details",
662 true,
663 Some(&form),
664 )
665 .await;
666 assert_eq!(status, 303);
667 assert_eq!(location.as_deref(), Some("/settings/profile?saved=details"));
668
669 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
670 assert_eq!(stored.about, "Hello *world*");
671 assert_eq!(stored.timezone, "Europe/Istanbul");
672
673 let (status, _, body) = send(&fx.router, "GET", "/settings/profile", true, None).await;
674 assert_eq!(status, 200);
675 assert!(body.contains("Hello *world*"), "{body}");
676 assert!(
677 body.contains(r#"value="Europe/Istanbul" selected"#),
678 "{body}"
679 );
680 }
681
682 #[tokio::test]
683 async fn a_bad_timezone_is_rejected_and_nothing_is_saved() {
684 let fx = fixture().await;
685 let form = format!("csrf={CSRF}&about=kept&timezone=Mars%2FOlympus");
686 let (status, location, body) = send(
687 &fx.router,
688 "POST",
689 "/settings/profile/details",
690 true,
691 Some(&form),
692 )
693 .await;
694 assert_eq!(status, 200);
695 assert!(location.is_none());
696 assert!(body.contains(r#"role="alert""#), "{body}");
697 let stored = fx.store.user_by_id(&fx.user.id).await.unwrap().unwrap();
698 assert_eq!(stored.timezone, "UTC");
699 assert_eq!(stored.about, "");
700 }
701
702 #[tokio::test]
703 async fn details_post_without_csrf_is_rejected() {
704 let fx = fixture().await;
705 let form = "about=x&timezone=UTC";
706 let (status, _, _) = send(
707 &fx.router,
708 "POST",
709 "/settings/profile/details",
710 true,
711 Some(form),
712 )
713 .await;
714 assert_eq!(status, 403);
715 }
716
717 #[tokio::test]
718 async fn avatar_upload_without_csrf_is_rejected() {
719 let fx = fixture().await;
720 let png = png_bytes();
721 let (content_type, body) = multipart(&[("avatar", Some(("a.png", "image/png")), &png[..])]);
722 let reply = send_full(
723 &fx.router,
724 "POST",
725 "/settings/profile/avatar",
726 true,
727 Some(&content_type),
728 body,
729 )
730 .await;
731 assert_eq!(reply.status, 403);
732 assert!(fx.store.avatar_hash(&fx.user.id).await.unwrap().is_none());
733 }
734
735 #[tokio::test]
736 async fn svg_upload_is_rejected_and_nothing_is_stored() {
737 let fx = fixture().await;
738 let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#;
739 let (content_type, body) = multipart(&[
740 ("csrf", None, CSRF.as_bytes()),
741 ("avatar", Some(("evil.svg", "image/svg+xml")), &svg[..]),
742 ]);
743 let reply = send_full(
744 &fx.router,
745 "POST",
746 "/settings/profile/avatar",
747 true,
748 Some(&content_type),
749 body,
750 )
751 .await;
752 assert_eq!(reply.status, 200);
753 assert!(reply.text().contains(r#"role="alert""#));
754 assert!(fx.store.avatar_hash(&fx.user.id).await.unwrap().is_none());
755 }
756
757 #[tokio::test]
758 async fn avatar_upload_then_remove_is_served_correctly() {
759 let fx = fixture().await;
760 let png = png_bytes();
761
762 let reply = upload_avatar(&fx.router, CSRF, &png).await;
763 assert_eq!(reply.status, 303);
764 assert_eq!(
765 reply.header("location").as_deref(),
766 Some("/settings/profile?saved=avatar")
767 );
768 let stored_hash = fx.store.avatar_hash(&fx.user.id).await.unwrap().unwrap();
769 assert_eq!(stored_hash.len(), 16);
770
771 // The page shows the new avatar with the hash as its version.
772 let (_, _, body) = send(&fx.router, "GET", "/settings/profile", true, None).await;
773 assert!(body.contains(&format!("?v={stored_hash}")), "{body}");
774
775 // The top-nav avatar uses the real hash too, not the default URL.
776 let (_, _, body) = send(&fx.router, "GET", "/", true, None).await;
777 let nav_src = format!(r#"src="/avatars/tahinli?v={stored_hash}""#);
778 assert!(body.contains(&nav_src), "{body}");
779
780 // Served as a stored PNG with a hash ETag, and 304 on a match.
781 let served = send_full(
782 &fx.router,
783 "GET",
784 "/avatars/tahinli",
785 false,
786 None,
787 Vec::new(),
788 )
789 .await;
790 assert_eq!(served.status, 200);
791 assert_eq!(served.header("content-type").as_deref(), Some("image/png"));
792 assert_eq!(served.header("etag"), Some(format!("\"{stored_hash}\"")));
793 assert_eq!(
794 served.header("x-content-type-options").as_deref(),
795 Some("nosniff")
796 );
797 assert!(served.body.starts_with(&[0x89, b'P', b'N', b'G']));
798
799 let uri = format!("/avatars/tahinli?v={stored_hash}");
800 let immutable = send_full(&fx.router, "GET", &uri, false, None, Vec::new()).await;
801 assert_eq!(
802 immutable.header("cache-control").as_deref(),
803 Some("public, max-age=31536000, immutable")
804 );
805 let etag = format!("\"{stored_hash}\"");
806 let mut request = Request::builder().method("GET").uri(&uri);
807 request = request.header("if-none-match", etag.as_str());
808 let not_modified = fx.router.handle(request.body(Body::empty()).unwrap()).await;
809 assert_eq!(not_modified.status().as_u16(), 304);
810
811 // Removing gives the generated default SVG.
812 let form = format!("csrf={CSRF}");
813 let (status, location, _) = send(
814 &fx.router,
815 "POST",
816 "/settings/profile/avatar/delete",
817 true,
818 Some(&form),
819 )
820 .await;
821 assert_eq!(status, 303);
822 assert_eq!(
823 location.as_deref(),
824 Some("/settings/profile?saved=avatar-removed")
825 );
826 assert!(fx.store.avatar_hash(&fx.user.id).await.unwrap().is_none());
827 let (_, _, body) = send(&fx.router, "GET", "/", true, None).await;
828 assert!(
829 body.contains(r#"src="/avatars/tahinli?v=default""#),
830 "{body}"
831 );
832
833 let default = send_full(
834 &fx.router,
835 "GET",
836 "/avatars/tahinli",
837 false,
838 None,
839 Vec::new(),
840 )
841 .await;
842 assert_eq!(default.status, 200);
843 assert_eq!(
844 default.header("content-type").as_deref(),
845 Some("image/svg+xml")
846 );
847 assert_eq!(
848 default.header("x-content-type-options").as_deref(),
849 Some("nosniff")
850 );
851 assert!(default.text().starts_with("<svg"), "{}", default.text());
852 }
853
854 #[tokio::test]
855 async fn avatar_remove_without_csrf_is_rejected() {
856 let fx = fixture().await;
857 let (status, _, _) = send(
858 &fx.router,
859 "POST",
860 "/settings/profile/avatar/delete",
861 true,
862 Some("csrf=nope"),
863 )
864 .await;
865 assert_eq!(status, 403);
866 }
867}