csaf-crud 1.5.26

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! `POST /preferences/theme` — the per-browser theme choice.
//!
//! The Theme menu in the header is a real form (`system`, `light`, `dark`).
//! With JavaScript, `csaf-ui.js` applies the choice at once and writes the
//! same cookie; this route is what makes the control work without it. The
//! cookie is per browser, so one visitor's choice never changes the theme for
//! anyone else (the previous `/settings/toggle-theme` flipped a global
//! setting). The redirect target is the `Referer` **path only**, as for the
//! language switcher, so there is no open-redirect surface.

// Route handlers take state/parts/params by value to match `HandlerFn`.
#![allow(clippy::needless_pass_by_value)]

use http::{Response, header};

use crate::app_state::AppState;
use crate::router::{Body, form_field, parse_form_body, redirect};
use crate::routes::lang::return_path;
use crate::routes::layout::{THEME_COOKIE, ThemeChoice};

/// One year, in seconds: a theme choice is long-lived.
const COOKIE_MAX_AGE: u32 = 31_536_000;

/// `POST /preferences/theme` — store the chosen theme and go back.
///
/// An unknown or missing `theme` value changes nothing but still redirects,
/// so a stale form never errors.
pub async fn set_theme(
    _state: AppState,
    parts: http::request::Parts,
    _params: Vec<(String, String)>,
) -> Response<Body> {
    let mut response = redirect(&return_path(&parts));
    if let Some(choice) = chosen_theme(&parts)
        && let Ok(value) = header::HeaderValue::from_str(&theme_cookie(choice))
    {
        response.headers_mut().insert(header::SET_COOKIE, value);
    }
    response
}

/// The valid theme in the submitted form, if any.
fn chosen_theme(parts: &http::request::Parts) -> Option<ThemeChoice> {
    let form = parse_form_body(parts).ok()?;
    ThemeChoice::from_value(form_field(&form, "theme")?)
}

/// The `Set-Cookie` value for a theme choice.
///
/// `Secure` because the server is HTTPS-only; `SameSite=Lax` so the redirect
/// that follows this POST carries it. Not `HttpOnly`: `csaf-ui.js` writes the
/// same cookie to apply a choice without a round trip.
fn theme_cookie(choice: ThemeChoice) -> String {
    format!(
        "{THEME_COOKIE}={}; Path=/; Max-Age={COOKIE_MAX_AGE}; SameSite=Lax; Secure",
        choice.as_str()
    )
}

#[cfg(test)]
mod tests {
    #![allow(clippy::missing_panics_doc)]

    use bytes::Bytes;
    use http::Request;

    use super::{chosen_theme, theme_cookie};
    use crate::router::RequestBody;
    use crate::routes::layout::ThemeChoice;

    fn parts_with_form(form: &str) -> http::request::Parts {
        let (mut parts, ()) = Request::builder()
            .method("POST")
            .uri("/preferences/theme")
            .body(())
            .expect("request builds")
            .into_parts();
        parts
            .extensions
            .insert(RequestBody(Bytes::copy_from_slice(form.as_bytes())));
        parts
    }

    #[test]
    fn each_valid_choice_is_accepted() {
        for (value, want) in [
            ("system", ThemeChoice::System),
            ("light", ThemeChoice::Light),
            ("dark", ThemeChoice::Dark),
        ] {
            let parts = parts_with_form(&format!("theme={value}"));
            assert_eq!(chosen_theme(&parts), Some(want), "theme={value}");
        }
    }

    #[test]
    fn unknown_or_missing_values_are_rejected() {
        // A hostile or stale form must never produce a cookie value.
        for form in ["theme=purple", "theme=", "theme=dark%3Bx", "other=dark", ""] {
            assert_eq!(chosen_theme(&parts_with_form(form)), None, "form={form:?}");
        }
    }

    #[test]
    fn a_request_without_a_body_is_rejected() {
        let (parts, ()) = Request::builder()
            .method("POST")
            .uri("/preferences/theme")
            .body(())
            .expect("request builds")
            .into_parts();
        assert_eq!(chosen_theme(&parts), None);
    }

    #[test]
    fn cookie_is_site_wide_secure_samesite_lax_and_script_writable() {
        let cookie = theme_cookie(ThemeChoice::Dark);
        assert!(cookie.starts_with("csaf_theme=dark;"), "{cookie}");
        for attr in ["Path=/", "Max-Age=31536000", "SameSite=Lax", "Secure"] {
            assert!(cookie.contains(attr), "{attr} missing from {cookie}");
        }
        // csaf-ui.js must be able to overwrite it, so it is NOT HttpOnly.
        assert!(!cookie.contains("HttpOnly"), "{cookie}");
    }
}