ipx-web: the page in its own container, behind Caddy (#203)

web/build.mjs builds a static site, web/dist, with the icons and typeface
beside the pages; Dockerfile.web serves it with Caddy, which passes /api
and /media to ipx on the same origin, so cookies, live updates and
Cloudflare Access work as before. ipx serves no page any more: build.rs
and every include_str! of the web files are gone, and its image is built
without node.

What ipx did to the page as it served it is kept another way. Caddy asks
ipx before serving a page (forward_auth): /api/page-check sends a browser
not signed in to /login and a non-admin away from /admin, and refuses them
/admin.js; /api/login-check sends someone Access vouches for past the
form; a page asked for with ?token= goes to /api/signin, which sets the
cookie. The theme and the admin link, once written into the page per
person, are set by web/boot.js in <head> from the theme cookie and the
last visit, before the first frame, and /api/me brings the version and
the account's theme from before.

Caddy strips the Cf-Access-* headers from every peer but TUNNEL_PEER, the
address cloudflared's requests come from, which is the boundary
trusted_proxies drew; ipx then trusts only Caddy's address.

The browser suite runs Caddy in front of its daemon, so it tests what
production would run. Not deployed: production's cutover is on #203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 05:06:53 +00:00
parent c47aa57909
commit f9870938bf
21 changed files with 276 additions and 278 deletions

View File

@@ -1,14 +0,0 @@
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first, at the repo's root.
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=../../web");
println!("cargo:rerun-if-changed=../../package-lock.json");
let out = std::env::var("OUT_DIR").unwrap();
let status = Command::new("node")
.args(["../../web/build.mjs", &out])
.status()
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
}

View File

@@ -8,7 +8,7 @@ use axum::{
http::{StatusCode, header},
middleware::{self, Next},
response::{
Html, IntoResponse, Redirect, Response,
IntoResponse, Redirect, Response,
sse::{Event as SseEvent, Sse},
},
routing::{delete, get, patch, post},
@@ -39,7 +39,6 @@ pub struct WebState {
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
@@ -70,31 +69,17 @@ pub fn router(state: WebState) -> Router {
.route("/api/logs", get(logs))
.route("/api/client", post(client_event))
.route("/api/events", get(events))
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media))
.route("/media/{id}/deadair.m3u8", get(media_deadair))
.route("/media/{id}/deadair.mp3", get(media_piece))
.route("/media/{id}/deadair", get(media_save))
.route("/api/art", get(art))
.route("/api/page-check", get(page_check))
.route("/api/signin", get(signin))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/login", get(login_page))
.route("/api/login", post(login))
.route("/icon.png", get(icon))
.route("/logo.svg", get(logo))
.route("/logo-dark.svg", get(logo_dark))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/favicon-dark.png", get(favicon_dark))
.route("/apple-touch-icon.png", get(touch_icon))
// iOS asks for this one first when the site is added to a home screen (#109).
.route("/apple-touch-icon-precomposed.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
.route("/hls.js", get(hls_js))
.route("/inter.woff2", get(inter))
.route("/api/login-check", get(login_check))
.route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log))
.with_state(state)
@@ -384,9 +369,15 @@ async fn me(
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
// The theme the account kept from before themes were each browser's: theme.ts takes it, once,
// into a browser that has none of its own yet.
let theme = match state.ctx.db.theme(user.id).await.unwrap_or_default() {
(Some(t), Some(m)) if theme_ok(&t, &m) => Some(serde_json::json!({ "name": t, "mode": m })),
_ => None,
};
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair_saved": saved,
"deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
}))
}
@@ -583,142 +574,41 @@ async fn remove_user(
Ok(StatusCode::NO_CONTENT)
}
/// The password form, except for someone the proxy vouches for: they are signed in already, and
/// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
/// What Caddy asks before it serves the app or the admin page (#203): a signed-in person may have
/// the app, an admin the admin page; anyone else is sent where they belong. The auth layer has
/// already sent a browser that is not signed in to /login. The path is Caddy's
/// `X-Forwarded-Uri`; the page itself is Caddy's to serve.
async fn page_check(user: crate::db::User, headers: HeaderMap) -> Response {
let uri = headers.get("x-forwarded-uri").and_then(|v| v.to_str().ok()).unwrap_or("/");
match page_redirect(uri, user.is_admin) {
// The page sends its visitor back to the app; its script is refused outright.
Some(_) if uri.starts_with("/admin.js") => (StatusCode::FORBIDDEN, "only an admin").into_response(),
Some(to) => Redirect::to(to).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
}
}
/// The shared token in the address, `/?token=`, as Caddy passes it on (#203): the auth layer has
/// signed the browser in and set its cookie, and it goes on to the app without the token in view.
async fn signin(_user: crate::db::User) -> Redirect {
Redirect::to("/")
}
/// Where someone asking for `uri` goes instead, if not there: the admin page and its script are
/// sent to admins only, not just hidden from everyone else.
fn page_redirect(uri: &str, admin: bool) -> Option<&'static str> {
let path = uri.split(['?', '#']).next().unwrap_or("");
(!admin && (path == "/admin" || path.starts_with("/admin.") || path.starts_with("/admin/"))).then_some("/")
}
/// What Caddy asks before it serves the sign-in page: someone the proxy vouches for is signed in
/// already, and goes to the app, since the form only made it look as if they were not.
async fn login_check(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
return Redirect::to("/").into_response();
Redirect::to("/").into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
.into_response()
}
/// The pages are checked on every visit, so a browser always has the one naming the current
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
/// changes its name in the page, and the browser fetches it.
const PAGE_CACHE: &str = "no-cache";
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
/// page needs its own before anyone has signed in, and neither holds anything private.
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 cookie the page keeps its theme in, `<theme>.<mode>`, written by theme.ts.
const THEME_COOKIE: &str = "ipx_theme";
/// The theme this browser chose, from its cookie: per device, so a phone and a desktop signed in
/// as the same person can each have their own (issue #69). A browser without one yet gets the
/// theme the account kept from before, which theme.ts then writes into the cookie.
async fn page_theme(state: &WebState, user: &crate::db::User, headers: &HeaderMap) -> (Option<String>, Option<String>) {
if let Some((t, m)) = headers_cookie(headers, THEME_COOKIE).as_deref().and_then(|v| v.split_once('.')) {
return (Some(t.to_owned()), Some(m.to_owned()));
}
state.ctx.db.theme(user.id).await.unwrap_or_default()
}
/// 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, headers: HeaderMap) -> Response {
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = page_theme(&state, &user, &headers).await;
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")))
}
/// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181): the page loads
/// it only there, by the name build.mjs gives it.
async fn hls_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/hls.js")))
}
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
}
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
/// it, so it sits outside the auth layer with /login.
async fn logo() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../../../web/logo.svg"),
)
}
/// logo.svg recoloured for a dark page. The pages pick one or the other by their light or dark
/// mode.
async fn logo_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../../../web/logo-dark.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../../../web/ipodderx-icon.png").as_slice(),
)
}
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
/// own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../../../web/favicon.png").as_slice(),
)
}
/// web/logo-dark.svg at 128px, the tab's icon while the page is dark.
async fn favicon_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../../../web/favicon-dark.png").as_slice(),
)
}
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
/// a transparent icon's background black and cuts its own corners.
async fn touch_icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../../../web/apple-touch-icon.png").as_slice(),
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt.
async fn inter() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
include_bytes!("../../../web/InterVariable.woff2").as_slice(),
)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
@@ -731,48 +621,6 @@ 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 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 ADMIN_LINK: &str = "<a id=admin ";
// The logo's tooltip. Filled in here, not by build.mjs, because build.rs does not rerun when only
// Cargo.toml's version changes, and the page would go on naming the last release.
const VERSION_SLOT: &str = "iPX {version}";
/// 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(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> impl IntoResponse {
let theme = page_theme(&state, &user, &headers).await;
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
}
const HTML_TAG: &str = "<html lang=en>";
/// 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 = with_theme(INDEX, theme).replacen(VERSION_SLOT, concat!("iPX ", env!("CARGO_PKG_VERSION")), 1);
if !admin
&& let Some(at) = page.find(ADMIN_LINK)
&& let Some(len) = page[at..].find("</a>")
{
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,
@@ -1172,31 +1020,24 @@ mod tests {
}
#[test]
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");
fn only_an_admin_is_given_the_admin_page_or_its_script() {
for uri in ["/admin", "/admin.js?v=abc", "/admin#log", "/admin.html"] {
assert_eq!(page_redirect(uri, false), Some("/"), "{uri}");
assert_eq!(page_redirect(uri, true), None, "{uri}");
}
for uri in ["/", "/?feed=x", "/app.js?v=1", "/administer"] {
assert_eq!(page_redirect(uri, false), None, "{uri}");
}
}
#[test]
fn the_logo_names_the_version() {
// If the minifier drifts from VERSION_SLOT, replacen matches nothing and says nothing.
let page = page_for(false, (None, None));
assert!(!page.contains(VERSION_SLOT) && page.contains(concat!("iPX ", env!("CARGO_PKG_VERSION"))));
fn only_a_plain_theme_name_is_handed_on() {
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
// theme.ts writes it into markup, so nothing else gets there.
assert!(!theme_ok("x onload=alert(1)", "dark"));
assert!(!theme_ok("modern", "dark\""));
}
#[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]
fn a_requests_trace_is_the_pages_from_its_traceparent() {