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

@@ -81,7 +81,10 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER NOT NULL DEFAULT 0,
-- For whoever maintains the server. NULL where it is not known.
created INTEGER,
last_login INTEGER
last_login INTEGER,
-- The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
theme TEXT,
theme_mode TEXT
);
-- What one person wants from a feed. The feed, its items and its files are shared; this
@@ -176,6 +179,9 @@ fn migrate(conn: &Connection) -> Result<()> {
("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"),
("entry_state", "duration", "INTEGER"),
// Kept per account so a theme follows you to another browser; it was in localStorage.
("users", "theme", "TEXT"),
("users", "theme_mode", "TEXT"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -1180,6 +1186,23 @@ impl Db {
Ok(())
}
/// The theme this person chose, and light, dark or auto; None for either until they choose.
pub fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?)
}
pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1",
params![user_id, theme, mode],
)?;
Ok(())
}
/// The user behind a session cookie, if it is still live. Idle sessions expire after
/// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive.
pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {

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]