//! Route: `POST /-/theme`, the theme toggle in the top bar and the footer form for anonymous //! visitors. Anonymous visitors get the `forge_theme` cookie. Signed-in users get their stored //! preference saved (CSRF checked, language kept as it is). Either way the browser returns to the //! `back` field when it is a safe local path, and to `/` otherwise. use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::route; use forge_core::AuditAction; use forge_core::Store; use forge_core::store::ui_prefs::THEMES; use crate::audit::{self, Actor, Target}; use crate::auth::{current_user, require_csrf}; use crate::state::AppState; use crate::theme::set_theme_cookie; #[derive(Deserialize)] struct ThemeForm { #[serde(default)] csrf: String, #[serde(default)] theme: String, #[serde(default)] back: String, } /// `back` when it is a safe local path, otherwise `/`. A safe path starts with a single `/` and /// has no backslash and no control character (CR and LF in particular), so it cannot leave the /// site. pub fn safe_back(back: &str) -> &str { let local = back.starts_with('/') && !back.starts_with("//") && !back.contains('\\') && !back.chars().any(char::is_control); if local { back } else { "/" } } #[route(POST "/-/theme")] async fn set_theme(cx: &Cx, Form(form): Form) -> Result { let back = safe_back(&form.back); if current_user(cx).await?.is_none() { if !THEMES.contains(&form.theme.as_str()) { return Err(forge_core::Error::Invalid("theme".into()).into()); } set_theme_cookie(cx, &form.theme); return Ok(see_other(back)); } let viewer = require_csrf(cx, &form.csrf).await?; if !THEMES.contains(&form.theme.as_str()) { return Err(forge_core::Error::Invalid("theme".into()).into()); } let store: &Store = &app_context::(cx).store; store .set_ui_prefs(&viewer.0.id, &form.theme, &viewer.0.language) .await?; audit::record( store, Some(cx), Actor::User(&viewer.0), AuditAction::UiPrefsChanged, Target { kind: "user", id: &viewer.0.id, name: &viewer.0.username, }, serde_json::json!({ "theme": form.theme, "language": viewer.0.language }), ) .await; Ok(see_other(back)) } #[cfg(test)] mod tests { use super::safe_back; #[test] fn local_paths_are_kept() { assert_eq!(safe_back("/explore?q=rust"), "/explore?q=rust"); assert_eq!(safe_back("/"), "/"); assert_eq!(safe_back("/acme/app/issues/3"), "/acme/app/issues/3"); } #[test] fn anything_else_falls_back_to_the_root() { for back in [ "", "//evil.com", "https://evil.com", "/\\evil", "\\\\evil.com", "evil.com/x", "/ok\r\nSet-Cookie: x=1", "/ok\nx", ] { assert_eq!(safe_back(back), "/", "{back:?}"); } } }