diff --git a/CHANGELOG.md b/CHANGELOG.md index 7696cd7..3d8bc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The page is served by its own container, ipx-web (Caddy), which passes the API to ipx on the same address, so a change to the page deploys without rebuilding ipx, and ipx is built without node (#203). - Add a feed no longer asks for a folder; a feed's folder is its title (#201). - `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208). - The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202). diff --git a/CLAUDE.md b/CLAUDE.md index 359e247..743dbec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208). | Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` | | Downloads | `/mnt/user/ipx/downloads` | `/downloads` | | Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `ipx-dir.sdf1.net` through the tunnel with no Access in front: tokens are its gate. Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` | -| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | +| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. Once #203 is deployed, that port is `ipx-web`'s (Caddy), and ipx's own is reached only by it | `0.0.0.0:8099` | | Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.64.1`, the `ipx_default` gateway (its subnet pinned in the compose file), and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) | Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a @@ -98,13 +98,23 @@ running the command and kills the session (exit 144), which has happened more th There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet, `web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files -make up each page's script. `crates/ipx/build.rs` runs -`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and -`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page -loads its script as `/app.js?v=`, and `/app.css` the same way: the page is -served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a -deploy changes them and their names. So **every page change needs a -rebuild** before it is visible, and building needs node and `npm ci` run once. +make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses +swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the +pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves +(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx +serves no page. The page loads its script as `/app.js?v=`, and `/app.css` +and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser +keeps them until a deploy changes them and their names. So **every page change needs ipx-web +rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once. + +Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is +not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them +`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page +asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file +for everyone, so what ipx used to write into it per person is the page's own job now: +`web/boot.js`, in ``, puts the theme from its cookie and an admin's link from the last visit +on the page before the first frame, and `/api/me` brings the rest (version, the account's old +theme). The files in `web/src` are not modules. They are one script split up, concatenated in the order `web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new diff --git a/Dockerfile b/Dockerfile index 4cb98a8..8659c49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,12 @@ # Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C # toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers. -# build.rs builds the web pages from TypeScript with swc, which needs node. +# The pages are ipx-web's (Dockerfile.web), so no node here (#203). FROM rust:1-slim-trixie AS build RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential nodejs npm \ + build-essential \ && rm -rf /var/lib/apt/lists/* WORKDIR /src -# swc only: Playwright and TypeScript are for testing and type-checking, not for building. -COPY package.json package-lock.json ./ -RUN npm ci --omit=dev - # Dependencies first, so editing the source does not rebuild librqbit every time: each crate of # the workspace as an empty stub, its manifest real. COPY Cargo.toml Cargo.lock ./ @@ -24,7 +20,6 @@ RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \ && rm -rf crates/ipx/src crates/feed/src crates/directory/src COPY crates ./crates -COPY web ./web # cargo skips a rebuild if mtimes look untouched; make sure it does not. RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..da69071 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,13 @@ +# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx. +# Built from the repo's root: docker buildx build -f Dockerfile.web . +FROM node:22-slim AS build +WORKDIR /src +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY web ./web +RUN node web/build.mjs /dist + +FROM caddy:2 +COPY web/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /dist /srv +EXPOSE 8080 diff --git a/README.md b/README.md index c649fa7..d66b064 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,13 @@ With Docker: ```sh docker build -t ipx . +docker build -f Dockerfile.web -t ipx-web . docker compose up -d ``` -`docker-compose.yml` is a sample: ipx alone, with its database in SQLite and its three volumes -(`/config`, `/data` and `/downloads`) in folders beside the file. Change them to suit. The UI is on port 8099. BitTorrent uses 6881 +`docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes +(`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by +Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881 over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default. From source: diff --git a/crates/ipx/build.rs b/crates/ipx/build.rs deleted file mode 100644 index e156a2e..0000000 --- a/crates/ipx/build.rs +++ /dev/null @@ -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"); -} diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 65894f5..300ade4 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -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)) @@ -69,31 +68,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) @@ -383,9 +368,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"), })) } @@ -582,142 +573,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, 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, 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=, 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, `.`, 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, Option) { - 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, 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 { @@ -730,48 +620,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 = ", 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 = ""; - -/// The page for this person: their theme on its 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, Option)) -> 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("") - { - page.replace_range(at..at + len + "".len(), ""); - } - page -} - -/// A page with the account's theme on its tag, so it is drawn in it from the first frame -/// on any browser. -fn with_theme(page: &str, theme: (Option, Option)) -> 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!(""), 1) -} - #[derive(Serialize)] struct FeedRow { id: String, @@ -1223,31 +1071,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("")); - assert!(page("nordic", "auto").contains("")); - // 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("")); - } #[test] fn a_feed_with_a_credential_is_never_popular() { diff --git a/docker-compose.yml b/docker-compose.yml index 7ebfc4a..e126812 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ -# A sample: ipx on its own, keeping everything in SQLite. Build the image first -# (`docker build -t ipx .`), then `docker compose up -d`, and sign in at http://:8099 -# as admin / ipodderx. docs/configuration.md has the rest, Postgres included. +# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both +# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then +# `docker compose up -d`, and sign in at http://:8099 as admin / ipodderx. +# docs/configuration.md has the rest, Postgres included. services: ipx: image: ipx @@ -13,8 +14,8 @@ services: TZ: "UTC" # For Postgres instead of SQLite: # IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx" + # No web port of its own: ipx-web is the way in, and passes the API on to it. ports: - - "8099:8099" # web UI - "6881:6881/tcp" # BitTorrent peers - "6881:6881/udp" # DHT volumes: @@ -27,3 +28,22 @@ services: timeout: 5s retries: 3 start_period: 10s + + ipx-web: + image: ipx-web + container_name: ipx-web + restart: unless-stopped + environment: + IPX_UPSTREAM: "ipx:8099" + # Behind Cloudflare Access only: where cloudflared's requests come from, the one address + # whose Cf-Access-* headers are passed on. docs/sso.md says how to find it. + # TUNNEL_PEER: "192.168.64.1" + ports: + - "8099:8080" # the web UI + depends_on: + - ipx + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/favicon.png"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/docs/architecture.md b/docs/architecture.md index 659c348..95b87ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,11 +29,13 @@ The version is the workspace's, in the root `Cargo.toml`. | `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | | `web/app.css` | The stylesheet both pages share | — | | `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — | -| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — | -| `crates/ipx/build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | +| `web/build.mjs` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — | +| `web/boot.js` | In ``: the theme from its cookie, and an admin's link, before the first frame | — | +| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — | -The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a -build needs node and `npm ci` run once. +The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs +ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API +and the files, and no page. ## A scan @@ -125,8 +127,8 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en | Route | | |---|---| -| `GET /` | the app | -| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in | +| `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` | +| `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before | | `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe | | `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe | | `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) | diff --git a/docs/sso.md b/docs/sso.md index bf7d35c..7922250 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -84,6 +84,16 @@ stays `192.168.64.1` when the network is recreated. It was `192.168.16.1`, `cont ipx was part of the `content` project. If the subnet is ever changed, update `trusted_proxies` to match. +### Behind ipx-web (#203) + +With the page served by `ipx-web` (Caddy), cloudflared reaches Caddy instead, still through the +host's port 8099, so its requests still arrive from `192.168.64.1`, at Caddy now. Caddy passes the +`Cf-Access-*` headers on only from that address (`TUNNEL_PEER`) and strips them from everyone else, +the boundary `trusted_proxies` drew before. ipx then sees every request come from Caddy, so +`trusted_proxies` names Caddy's address on `ipx_default`, pinned in the compose file +(`192.168.64.10`), instead of the gateway. The token check is unchanged: a header Caddy passes on +still needs a valid `Cf-Access-Jwt-Assertion`. + ### Names The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a diff --git a/playwright.config.js b/playwright.config.js index c9b4fab..4b058ef 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -26,10 +26,20 @@ module.exports = defineConfig({ reuseExistingServer: false, stdout: 'ignore', }, + { + // The frontend as production serves it (#203): built from web/, served by Caddy on the + // suite's port, the API passed to the daemon behind it. + command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`, + url: 'http://127.0.0.1:8791/favicon.png', + reuseExistingServer: false, + timeout: 60_000, + stdout: 'ignore', + env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root }, + }, { // Build first so the tests always run against current source. command: 'cargo build -q && exec ./target/debug/ipx daemon', - port: 8791, + port: 8796, reuseExistingServer: false, timeout: 180_000, stdout: 'pipe', diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 8c30434..859713a 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -90,15 +90,19 @@ test('each browser keeps its own theme, in a cookie, not on the account', async expect(cookie?.value).toBe('flatremix.light'); expect(patched, 'nothing sent to the account').toBe(false); - // This browser: the server reads the cookie and draws the page in it from the first frame. - const res = await page.reload(); - expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light'); + // This browser: boot.js reads the cookie in and draws the page in it from the first + // frame, before the app's script has run (#203). + const firstFrame = () => document.documentElement.dataset; + await page.reload({ waitUntil: 'commit' }); + await page.waitForFunction(() => document.body); + expect(await page.evaluate(firstFrame)).toMatchObject({ theme: 'flatremix', choice: 'light', mode: 'light' }); // Another browser, the same account: not this one's theme. const other = await browser.newContext(); const p2 = await other.newPage(); - const res2 = await p2.goto(`/?token=${TOKEN}`); - expect(await res2.text()).not.toContain('data-theme=flatremix'); + await p2.goto(`/?token=${TOKEN}`); + await expect(p2.locator('#feedlist')).toBeVisible(); + expect((await p2.evaluate(firstFrame)).theme).not.toBe('flatremix'); await other.close(); }); @@ -583,8 +587,9 @@ test('a second person has their own feeds and their own read state', async ({ br await expect(page.locator('#modalCard')).toContainText('Subscriptions'); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this'); await page.locator('#modalCard .cardx').click(); - await expect(page.locator('#admin')).toHaveCount(0); - expect(await (await page.request.get('/')).text()).not.toContain('href=/admin'); + // The page is the same for everyone now (#203): the link is there, and hidden from anyone /api/me + // does not call an admin. + await expect(page.locator('#admin')).toBeHidden(); // Asking for it anyway goes back to the app, and its script is refused. await page.goto('/admin'); await expect(page).toHaveURL(/:8791\/$/); diff --git a/tests/ui/global-setup.js b/tests/ui/global-setup.js index 812d915..ca68e5d 100644 --- a/tests/ui/global-setup.js +++ b/tests/ui/global-setup.js @@ -35,7 +35,8 @@ enabled = false [web] enabled = true -bind = "127.0.0.1:8791" +# Behind Caddy, which serves the pages on 8791 and passes the API here (#203). +bind = "127.0.0.1:8796" token = "${TOKEN}" # The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1. trusted_header = "X-Test-User" diff --git a/web/Caddyfile b/web/Caddyfile new file mode 100644 index 0000000..dc8f25d --- /dev/null +++ b/web/Caddyfile @@ -0,0 +1,75 @@ +# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin, +# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it. +# IPX_UPSTREAM where ipx is, host:port (ipx:8099) +# WEB_BIND where to listen (:8080) +# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's +# requests come from. Nobody else's are, so nobody else can claim to be signed in +# by Cloudflare. Unset passes none. +{ + admin off + auto_https off +} + +http://{$WEB_BIND::8080} { + root * {$WEB_ROOT:/srv} + + # Before anything else, so no page or API call sees them: only ipx sets these. + @forged not remote_ip {$TUNNEL_PEER:255.255.255.255} + request_header @forged -Cf-Access-Authenticated-User-Email + request_header @forged -Cf-Access-Jwt-Assertion + + @api path /api/* /media/* + handle @api { + # Live updates are a stream: flushed as they come, never buffered. + reverse_proxy {$IPX_UPSTREAM:ipx:8099} { + flush_interval -1 + } + } + + # Signing in with the shared token in the address (?token=): ipx sets the session cookie and + # sends the browser on to the page without it. + @tokened { + path / /admin + query token=* + } + handle @tokened { + rewrite * /api/signin?{query} + reverse_proxy {$IPX_UPSTREAM:ipx:8099} + } + + # The app and the admin page go to whoever ipx says may have them: a browser not signed in is + # sent to /login, anyone but an admin asking for /admin to the app. + @page path / /index.html /admin /admin.html /admin.js + handle @page { + forward_auth {$IPX_UPSTREAM:ipx:8099} { + uri /api/page-check + } + rewrite / /index.html + rewrite /admin /admin.html + header Cache-Control no-cache + file_server + } + + # The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app. + @login path /login /login.html + handle @login { + forward_auth {$IPX_UPSTREAM:ipx:8099} { + uri /api/login-check + } + rewrite * /login.html + header Cache-Control no-cache + file_server + } + + # Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser + # keeps them a year; the icons a day, under names that do not change. + @hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js + header @hashed Cache-Control "public, max-age=31536000, immutable" + # The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own. + header /favicon.ico Content-Type image/png + @icons path *.png *.svg *.ico *.woff2 + header @icons Cache-Control "public, max-age=86400" + handle { + file_server + } +} diff --git a/web/admin.html b/web/admin.html index 205aafb..9836be3 100644 --- a/web/admin.html +++ b/web/admin.html @@ -9,9 +9,10 @@ + - +
diff --git a/web/app.css b/web/app.css index 396213a..a903e7b 100644 --- a/web/app.css +++ b/web/app.css @@ -888,6 +888,9 @@ body.adminpage #topbar{position:sticky;top:0;z-index:5} body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650} body.adminpage #topbar .logo{border-radius:6px} body.adminpage #topbar > a.btn{text-decoration:none} +/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head + script marks an admin's from the last visit before the first frame. */ +html:not([data-admin]) #topbar a#admin{display:none} body.adminpage #admin{max-width:780px;margin:0 auto} body.adminpage #admin h2{font-size:18px;margin:0 0 12px} body.adminpage #log{max-width:none} diff --git a/web/boot.js b/web/boot.js new file mode 100644 index 0000000..4a7f9ca --- /dev/null +++ b/web/boot.js @@ -0,0 +1,6 @@ +// Runs in , before the first frame (#203): this browser's theme, from its cookie, and +// whether the last visit here was an admin's, so the link to Admin is there from the start. The +// server wrote both into the page while it served it; the page is a static file now. +(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie); +if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];} +try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})(); diff --git a/web/build.mjs b/web/build.mjs index 54f21fb..999008b 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -1,12 +1,12 @@ -// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified -// by swc into a script of its own (app.js, login.js), and the page minified, its -//
- +
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 98741f4..63b6dea 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -705,6 +705,12 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':''); + $('#applogo').title=`iPX ${u.version}`; + // Whether the link to Admin shows, now and before the first frame of the next visit. + document.documentElement.toggleAttribute('data-admin',!!u.admin); + try{ localStorage.setItem('ipx.admin',u.admin?'1':'0'); }catch{} + // A browser with no theme of its own takes the one the account kept from before, once. + if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true); }).catch(()=>{}); $('#feedFilter').oninput=renderFeeds; // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a diff --git a/web/src/theme.ts b/web/src/theme.ts index 0d8d979..18c16b5 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -55,9 +55,9 @@ function saveTheme(){ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); }); (() => { const root = document.documentElement; - // Without a cookie, the server sent the theme the account kept from before; this browser takes - // it as its own, once, so nobody has to choose again. - if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie)); + // The head script put this browser's theme from its cookie on the page. Without one, /api/me's + // answer brings the account's from before (dialogs.ts). + if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice); // Nothing on the account either. A theme this browser kept in localStorage, from before that, // becomes its cookie, once. let name: string | null = null, mode: string | null = null;