Keep the theme on the account, not in the browser

- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:33:59 +00:00
parent 9b2537761f
commit 9c16408d04
6 changed files with 164 additions and 27 deletions

View File

@@ -36,7 +36,7 @@ pub struct WebState {
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me))
.route("/api/me", get(me).patch(patch_me))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -317,7 +317,37 @@ async fn me(
) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
}))
}
#[derive(Deserialize)]
struct MePatch {
theme: String,
mode: String,
}
/// Saves the theme to the account, so it follows the person rather than the browser.
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?;
Ok(StatusCode::NO_CONTENT)
}
fn theme_ok(theme: &str, mode: &str) -> bool {
(1..=20).contains(&theme.len())
&& theme.bytes().all(|b| b.is_ascii_lowercase())
&& ["light", "dark", "auto"].contains(&mode)
}
// ---- accounts: admin only ----
@@ -524,16 +554,29 @@ const LOG_BUTTON: &str = "<button id=logs ";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered.
async fn index(user: crate::db::User) -> impl IntoResponse {
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin)))
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
}
fn page_for(admin: bool) -> std::borrow::Cow<'static, str> {
if admin {
INDEX.into()
} else {
INDEX.replacen(LOG_BUTTON, "<button id=logs hidden ", 1).into()
const HTML_TAG: &str = "<html lang=en>";
/// The page for this person: their theme on its <html> tag, so the page is drawn in it from the
/// first frame on any browser, and without the log button unless they are an admin.
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
let mut page = INDEX.to_owned();
if let (Some(t), Some(m)) = theme
&& theme_ok(&t, &m)
{
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here
// for the first two. theme.ts works Auto out from the system.
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
page = page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1);
}
if !admin {
page = page.replacen(LOG_BUTTON, "<button id=logs hidden ", 1);
}
page
}
#[derive(Serialize)]
@@ -871,8 +914,19 @@ mod tests {
#[test]
fn only_an_admin_is_sent_the_log_button() {
// If the markup drifts from LOG_BUTTON, replacen matches nothing and says nothing.
assert!(page_for(false).contains("<button id=logs hidden "));
assert!(!page_for(true).contains("id=logs hidden"));
assert!(page_for(false, (None, None)).contains("<button id=logs hidden "));
assert!(!page_for(true, (None, None)).contains("id=logs hidden"));
}
#[test]
fn the_page_arrives_in_the_theme_the_account_chose() {
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
// Whatever is in the column is written into markup, so only a plain name gets there.
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
assert!(page("modern", "dark\"").contains("<html lang=en>"));
}
#[test]