diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1c01a..e4f2552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Directory shows a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a switch there, and subscriber counts are every sharing server's (#205). - A feed's settings have a Private switch: a private feed is never shared with the directory. A feed with a login, or whose address carries a key, is private already (#205). +- 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 4138a72..a8b3dc0 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)) @@ -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, 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 { @@ -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 = ", 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, @@ -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("")); - 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_requests_trace_is_the_pages_from_its_traceparent() { 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 3863bbf..1104ef5 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 d2f06b9..9b7aeaa 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -35,10 +35,20 @@ module.exports = defineConfig({ stdout: 'ignore', env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` }, }, + { + // 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 fe4cd1f..a15591b 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 7691301..f18c147 100644 --- a/tests/ui/global-setup.js +++ b/tests/ui/global-setup.js @@ -41,7 +41,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 49e628e..caf3875 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 a327ef8..a8bf73d 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -719,6 +719,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;