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:
76
src/web.rs
76
src/web.rs
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user