A separate admin page: server settings, accounts and the log

/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:28:28 +00:00
parent 2d158a4540
commit aeb686163b
13 changed files with 1260 additions and 1097 deletions

View File

@@ -59,6 +59,8 @@ pub fn router(state: WebState) -> Router {
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs))
.route("/api/events", get(events))
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
@@ -69,6 +71,7 @@ pub fn router(state: WebState) -> Router {
.route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
.route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log))
@@ -493,6 +496,32 @@ async fn app_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
}
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
async fn app_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
)
}
/// The admin page and its script go to admins only: not just hidden from everyone else, never
/// sent. Anyone else asking for the page is sent back to the app.
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
}
async fn admin_js(user: crate::db::User) -> Response {
if !user.is_admin {
return (StatusCode::FORBIDDEN, "only an admin").into_response();
}
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
}
async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
}
@@ -548,9 +577,9 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
}
// Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
// quotes, so LOG_BUTTON is spelled the way the minifier leaves it.
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
const LOG_BUTTON: &str = "<button id=logs ";
const ADMIN_LINK: &str = "<a id=admin ";
/// 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.
@@ -561,24 +590,33 @@ async fn index(State(state): State<WebState>, user: crate::db::User) -> impl Int
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.
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
/// showed it for a moment on every load, until /api/me answered (issue #29).
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)
let mut page = with_theme(INDEX, theme);
if !admin
&& let Some(at) = page.find(ADMIN_LINK)
&& let Some(len) = page[at..].find("</a>")
{
// 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.replace_range(at..at + len + "</a>".len(), "");
}
page
}
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
/// on any browser.
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
let (Some(t), Some(m)) = theme else { return page.to_owned() };
if !theme_ok(&t, &m) {
return page.to_owned();
}
// 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.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
}
#[derive(Serialize)]
struct FeedRow {
id: String,
@@ -923,10 +961,12 @@ 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, (None, None)).contains("<button id=logs hidden "));
assert!(!page_for(true, (None, None)).contains("id=logs hidden"));
fn only_an_admin_is_sent_the_admin_link() {
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
let page = page_for(false, (None, None));
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone");
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
}
#[test]