forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
3.1 KiB
1//! Route: `POST /-/theme`, the theme toggle in the top bar and the footer form for anonymous
2//! visitors. Anonymous visitors get the `forge_theme` cookie. Signed-in users get their stored
3//! preference saved (CSRF checked, language kept as it is). Either way the browser returns to the
4//! `back` field when it is a safe local path, and to `/` otherwise.
5
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::route;
12
13use forge_core::AuditAction;
14use forge_core::Store;
15use forge_core::store::ui_prefs::THEMES;
16
17use crate::audit::{self, Actor, Target};
18use crate::auth::{current_user, require_csrf};
19use crate::state::AppState;
20use crate::theme::set_theme_cookie;
21
22#[derive(Deserialize)]
23struct ThemeForm {
24 #[serde(default)]
25 csrf: String,
26 #[serde(default)]
27 theme: String,
28 #[serde(default)]
29 back: String,
30}
31
32/// `back` when it is a safe local path, otherwise `/`. A safe path starts with a single `/` and
33/// has no backslash and no control character (CR and LF in particular), so it cannot leave the
34/// site.
35pub fn safe_back(back: &str) -> &str {
36 let local = back.starts_with('/')
37 && !back.starts_with("//")
38 && !back.contains('\\')
39 && !back.chars().any(char::is_control);
40 if local { back } else { "/" }
41}
42
43#[route(POST "/-/theme")]
44async fn set_theme(cx: &Cx, Form(form): Form<ThemeForm>) -> Result<SeeOther> {
45 let back = safe_back(&form.back);
46 if current_user(cx).await?.is_none() {
47 if !THEMES.contains(&form.theme.as_str()) {
48 return Err(forge_core::Error::Invalid("theme".into()).into());
49 }
50 set_theme_cookie(cx, &form.theme);
51 return Ok(see_other(back));
52 }
53 let viewer = require_csrf(cx, &form.csrf).await?;
54 if !THEMES.contains(&form.theme.as_str()) {
55 return Err(forge_core::Error::Invalid("theme".into()).into());
56 }
57 let store: &Store = &app_context::<AppState>(cx).store;
58 store
59 .set_ui_prefs(&viewer.0.id, &form.theme, &viewer.0.language)
60 .await?;
61 audit::record(
62 store,
63 Some(cx),
64 Actor::User(&viewer.0),
65 AuditAction::UiPrefsChanged,
66 Target {
67 kind: "user",
68 id: &viewer.0.id,
69 name: &viewer.0.username,
70 },
71 serde_json::json!({ "theme": form.theme, "language": viewer.0.language }),
72 )
73 .await;
74 Ok(see_other(back))
75}
76
77#[cfg(test)]
78mod tests {
79 use super::safe_back;
80
81 #[test]
82 fn local_paths_are_kept() {
83 assert_eq!(safe_back("/explore?q=rust"), "/explore?q=rust");
84 assert_eq!(safe_back("/"), "/");
85 assert_eq!(safe_back("/acme/app/issues/3"), "/acme/app/issues/3");
86 }
87
88 #[test]
89 fn anything_else_falls_back_to_the_root() {
90 for back in [
91 "",
92 "//evil.com",
93 "https://evil.com",
94 "/\\evil",
95 "\\\\evil.com",
96 "evil.com/x",
97 "/ok\r\nSet-Cookie: x=1",
98 "/ok\nx",
99 ] {
100 assert_eq!(safe_back(back), "/", "{back:?}");
101 }
102 }
103}