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:
@@ -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).
|
- 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).
|
- 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).
|
- 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).
|
- `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).
|
- 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).
|
||||||
|
|||||||
26
CLAUDE.md
26
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` |
|
| 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` |
|
| 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` |
|
| 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`) |
|
| 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
|
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
|
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,
|
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
|
`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
|
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
||||||
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
||||||
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
||||||
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
||||||
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
|
||||||
deploy changes them and their names. So **every page change needs a
|
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
|
||||||
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
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 `<head>`, 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
|
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
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
# 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.
|
# 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
|
FROM rust:1-slim-trixie AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential nodejs npm \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
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
|
# Dependencies first, so editing the source does not rebuild librqbit every time: each crate of
|
||||||
# the workspace as an empty stub, its manifest real.
|
# the workspace as an empty stub, its manifest real.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
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
|
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
|
||||||
|
|
||||||
COPY crates ./crates
|
COPY crates ./crates
|
||||||
COPY web ./web
|
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# 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
|
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
|
||||||
|
|
||||||
|
|||||||
13
Dockerfile.web
Normal file
13
Dockerfile.web
Normal file
@@ -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
|
||||||
@@ -31,11 +31,13 @@ With Docker:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t ipx .
|
docker build -t ipx .
|
||||||
|
docker build -f Dockerfile.web -t ipx-web .
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` is a sample: ipx alone, with its database in SQLite and its three volumes
|
`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. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
|
(`/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.
|
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
|
||||||
|
|
||||||
From source:
|
From source:
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ use axum::{
|
|||||||
http::{StatusCode, header},
|
http::{StatusCode, header},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{
|
response::{
|
||||||
Html, IntoResponse, Redirect, Response,
|
IntoResponse, Redirect, Response,
|
||||||
sse::{Event as SseEvent, Sse},
|
sse::{Event as SseEvent, Sse},
|
||||||
},
|
},
|
||||||
routing::{delete, get, patch, post},
|
routing::{delete, get, patch, post},
|
||||||
@@ -39,7 +39,6 @@ pub struct WebState {
|
|||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
|
||||||
.route("/api/me", get(me).patch(patch_me))
|
.route("/api/me", get(me).patch(patch_me))
|
||||||
.route("/api/tokens", get(list_tokens).post(add_token))
|
.route("/api/tokens", get(list_tokens).post(add_token))
|
||||||
.route("/api/tokens/{id}", axum::routing::delete(remove_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/logs", get(logs))
|
||||||
.route("/api/client", post(client_event))
|
.route("/api/client", post(client_event))
|
||||||
.route("/api/events", get(events))
|
.route("/api/events", get(events))
|
||||||
.route("/admin", get(admin_page))
|
|
||||||
.route("/admin.js", get(admin_js))
|
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
.route("/media/{id}/deadair.m3u8", get(media_deadair))
|
.route("/media/{id}/deadair.m3u8", get(media_deadair))
|
||||||
.route("/media/{id}/deadair.mp3", get(media_piece))
|
.route("/media/{id}/deadair.mp3", get(media_piece))
|
||||||
.route("/media/{id}/deadair", get(media_save))
|
.route("/media/{id}/deadair", get(media_save))
|
||||||
.route("/api/art", get(art))
|
.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))
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
// 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("/api/login", post(login))
|
||||||
.route("/icon.png", get(icon))
|
.route("/api/login-check", get(login_check))
|
||||||
.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_layer(middleware::from_fn(name_span))
|
.route_layer(middleware::from_fn(name_span))
|
||||||
.layer(middleware::from_fn(access_log))
|
.layer(middleware::from_fn(access_log))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
@@ -384,9 +369,15 @@ async fn me(
|
|||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||||
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
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;
|
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!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
"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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
/// What Caddy asks before it serves the app or the admin page (#203): a signed-in person may have
|
||||||
/// the form only made it look as if they were not.
|
/// the app, an admin the admin page; anyone else is sent where they belong. The auth layer has
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
/// 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() {
|
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 {
|
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
|
// 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.
|
// 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)]
|
#[derive(Serialize)]
|
||||||
struct FeedRow {
|
struct FeedRow {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1172,31 +1020,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_an_admin_is_sent_the_admin_link() {
|
fn only_an_admin_is_given_the_admin_page_or_its_script() {
|
||||||
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
|
for uri in ["/admin", "/admin.js?v=abc", "/admin#log", "/admin.html"] {
|
||||||
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
|
assert_eq!(page_redirect(uri, false), Some("/"), "{uri}");
|
||||||
let page = page_for(false, (None, None));
|
assert_eq!(page_redirect(uri, true), None, "{uri}");
|
||||||
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");
|
for uri in ["/", "/?feed=x", "/app.js?v=1", "/administer"] {
|
||||||
|
assert_eq!(page_redirect(uri, false), None, "{uri}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_logo_names_the_version() {
|
fn only_a_plain_theme_name_is_handed_on() {
|
||||||
// If the minifier drifts from VERSION_SLOT, replacen matches nothing and says nothing.
|
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
|
||||||
let page = page_for(false, (None, None));
|
// theme.ts writes it into markup, so nothing else gets there.
|
||||||
assert!(!page.contains(VERSION_SLOT) && page.contains(concat!("iPX ", env!("CARGO_PKG_VERSION"))));
|
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]
|
#[test]
|
||||||
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
fn a_requests_trace_is_the_pages_from_its_traceparent() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# A sample: ipx on its own, keeping everything in SQLite. Build the image first
|
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
|
||||||
# (`docker build -t ipx .`), then `docker compose up -d`, and sign in at http://<host>:8099
|
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
|
||||||
# as admin / ipodderx. docs/configuration.md has the rest, Postgres included.
|
# `docker compose up -d`, and sign in at http://<host>:8099 as admin / ipodderx.
|
||||||
|
# docs/configuration.md has the rest, Postgres included.
|
||||||
services:
|
services:
|
||||||
ipx:
|
ipx:
|
||||||
image: ipx
|
image: ipx
|
||||||
@@ -13,8 +14,8 @@ services:
|
|||||||
TZ: "UTC"
|
TZ: "UTC"
|
||||||
# For Postgres instead of SQLite:
|
# For Postgres instead of SQLite:
|
||||||
# IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx"
|
# 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:
|
ports:
|
||||||
- "8099:8099" # web UI
|
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
- "6881:6881/udp" # DHT
|
- "6881:6881/udp" # DHT
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,3 +28,22 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
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
|
||||||
|
|||||||
@@ -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/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/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/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 | — |
|
| `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` | — |
|
||||||
| `crates/ipx/build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
| `web/boot.js` | In `<head>`: 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
|
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||||
build needs node and `npm ci` run once.
|
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
|
## A scan
|
||||||
|
|
||||||
@@ -125,8 +127,8 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
|
|||||||
|
|
||||||
| Route | |
|
| Route | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /` | the app |
|
| `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=` |
|
||||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
||||||
|
|||||||
10
docs/sso.md
10
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
|
ipx was part of the `content` project. If the subnet is ever changed, update `trusted_proxies` to
|
||||||
match.
|
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
|
### Names
|
||||||
|
|
||||||
The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a
|
The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a
|
||||||
|
|||||||
@@ -35,10 +35,20 @@ module.exports = defineConfig({
|
|||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
|
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.
|
// Build first so the tests always run against current source.
|
||||||
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
command: 'cargo build -q && exec ./target/debug/ipx daemon',
|
||||||
port: 8791,
|
port: 8796,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
|
|||||||
@@ -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(cookie?.value).toBe('flatremix.light');
|
||||||
expect(patched, 'nothing sent to the account').toBe(false);
|
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.
|
// This browser: boot.js reads the cookie in <head> and draws the page in it from the first
|
||||||
const res = await page.reload();
|
// frame, before the app's script has run (#203).
|
||||||
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
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.
|
// Another browser, the same account: not this one's theme.
|
||||||
const other = await browser.newContext();
|
const other = await browser.newContext();
|
||||||
const p2 = await other.newPage();
|
const p2 = await other.newPage();
|
||||||
const res2 = await p2.goto(`/?token=${TOKEN}`);
|
await p2.goto(`/?token=${TOKEN}`);
|
||||||
expect(await res2.text()).not.toContain('data-theme=flatremix');
|
await expect(p2.locator('#feedlist')).toBeVisible();
|
||||||
|
expect((await p2.evaluate(firstFrame)).theme).not.toBe('flatremix');
|
||||||
await other.close();
|
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('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardx').click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
// The page is the same for everyone now (#203): the link is there, and hidden from anyone /api/me
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
// 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.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await expect(page).toHaveURL(/:8791\/$/);
|
await expect(page).toHaveURL(/:8791\/$/);
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ enabled = false
|
|||||||
|
|
||||||
[web]
|
[web]
|
||||||
enabled = true
|
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}"
|
token = "${TOKEN}"
|
||||||
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
|
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
|
||||||
trusted_header = "X-Test-User"
|
trusted_header = "X-Test-User"
|
||||||
|
|||||||
75
web/Caddyfile
Normal file
75
web/Caddyfile
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
<script src="/boot.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="adminpage">
|
<body class="adminpage">
|
||||||
<!-- The server sends this page, and its script, to admins only. -->
|
<!-- Caddy sends this page, and its script, to admins only: it asks ipx first (/api/page-check). -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
|
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
|
||||||
|
|||||||
@@ -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 h1{font-size:16px;margin:0;font-weight:650}
|
||||||
body.adminpage #topbar .logo{border-radius:6px}
|
body.adminpage #topbar .logo{border-radius:6px}
|
||||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
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{max-width:780px;margin:0 auto}
|
||||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||||
body.adminpage #log{max-width:none}
|
body.adminpage #log{max-width:none}
|
||||||
|
|||||||
6
web/boot.js
Normal file
6
web/boot.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Runs in <head>, 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{}})();
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
// Builds the frontend (#203): 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
|
// by swc into a script of its own (app.js, login.js), the page minified, its <script data-src>
|
||||||
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
// pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
|
||||||
// include_str!s the results, so the binary still carries everything and nothing is served
|
// is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
|
||||||
// from disk.
|
|
||||||
//
|
//
|
||||||
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||||
// server lets a browser keep that for a year without asking again. A changed script is a new
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||||
// URL, and the page, which the browser checks on every visit, is what carries it.
|
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
||||||
|
// server ignores the query.
|
||||||
//
|
//
|
||||||
// node web/build.mjs [out-dir] default out-dir: web/dist
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
@@ -81,7 +81,7 @@ export function buildPage(name, { minify = true } = {}) {
|
|||||||
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||||
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
|
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
|
||||||
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||||
if (!minify) return { html: out, js, script };
|
if (!minify) return { html: out, js, script };
|
||||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
@@ -95,6 +95,16 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
fs.mkdirSync(out, { recursive: true });
|
fs.mkdirSync(out, { recursive: true });
|
||||||
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
||||||
|
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
|
||||||
|
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
|
||||||
|
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
|
||||||
|
// nothing here shows any more but the classic theme.
|
||||||
|
for (const [from, to] of [
|
||||||
|
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||||
|
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
||||||
|
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||||
|
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||||
|
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||||
for (const name of Object.keys(PAGES)) {
|
for (const name of Object.keys(PAGES)) {
|
||||||
const { html, js, script } = buildPage(name);
|
const { html, js, script } = buildPage(name);
|
||||||
fs.writeFileSync(path.join(out, name), html);
|
fs.writeFileSync(path.join(out, name), html);
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
<script src="/boot.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
||||||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||||||
A phone hides the item group, which it has no table for. -->
|
A phone hides the item group, which it has no table for. -->
|
||||||
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
<span id="applogo" title="iPX"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
||||||
<div class="tgroup">
|
<div class="tgroup">
|
||||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||||
|
|||||||
@@ -719,6 +719,12 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
|
|||||||
api('/api/me').then(u=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#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(()=>{});
|
}).catch(()=>{});
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ function saveTheme(){
|
|||||||
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
(() => {
|
(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
// Without a cookie, the server sent the theme the account kept from before; this browser takes
|
// The head script put this browser's theme from its cookie on the page. Without one, /api/me's
|
||||||
// it as its own, once, so nobody has to choose again.
|
// answer brings the account's from before (dialogs.ts).
|
||||||
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
|
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,
|
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
|
||||||
// becomes its cookie, once.
|
// becomes its cookie, once.
|
||||||
let name: string | null = null, mode: string | null = null;
|
let name: string | null = null, mode: string | null = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user