Skip to main content

koan_server/ui/
mod.rs

1//! The web UI: sign in, browse the library and play it in the browser.
2//!
3//! Server-rendered HTML, with Datastar for the parts that change in place
4//! (search as you type, loading more, the share button) and a small script of
5//! its own that swaps only the page content on navigation, so the player keeps
6//! playing. Playback happens in the browser, streaming from `/ui/stream`: a
7//! headless server has no speakers.
8//!
9//! Sign-in is koan's own session: the same `HttpOnly` access and refresh
10//! cookies the JSON login sets, so tokens never reach page script. The gate
11//! accepts a valid access cookie; a page load without one goes through
12//! `/auth/resume`, which spends the refresh cookie (scoped to `/auth`, so only
13//! that route sees it) for fresh cookies, or on to the sign-in form.
14
15mod browse;
16mod keys;
17mod pages;
18mod session;
19#[cfg(test)]
20mod tests;
21
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use axum::extract::{Path, Request, State};
26use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
27use axum::middleware::{Next, from_fn, from_fn_with_state};
28use axum::response::sse::{Event, Sse};
29use axum::response::{IntoResponse, Response};
30use axum::routing::{get, post};
31use koan_core::auth::{self, Role};
32use koan_core::db::pool::{Handle, Pool};
33use koan_core::db::queries;
34
35use crate::auth::AuthUser;
36use crate::auth::routes::{AuthRouteState, login_rate_limit};
37use crate::covers::Covers;
38use crate::share::{asset, blocking, not_found};
39
40/// `'unsafe-eval'` because Datastar compiles its attribute expressions.
41/// Everything else is this server's own, and nothing is inline.
42const PAGE_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self'; \
43     img-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; \
44     frame-ancestors 'none'";
45
46/// Sent by the UI's own navigation, which wants the page content without the shell.
47const PARTIAL: &str = "x-koan-partial";
48
49const UI_CSS: &str = include_str!("../../assets/ui.css");
50const UI_JS: &str = include_str!("../../assets/ui.js");
51const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
52
53#[derive(Clone)]
54pub struct UiState {
55    pool: Arc<Pool>,
56    covers: Arc<Covers>,
57    /// The codecs and genres the filters offer, with when they were read.
58    options: Arc<std::sync::Mutex<Option<(std::time::Instant, pages::Options)>>>,
59    auth: AuthRouteState,
60    auth_enabled: bool,
61}
62
63pub fn router(
64    pool: Arc<Pool>,
65    auth: AuthRouteState,
66    auth_enabled: bool,
67    covers: Arc<Covers>,
68) -> axum::Router {
69    let state = UiState {
70        pool,
71        covers,
72        options: Arc::default(),
73        auth,
74        auth_enabled,
75    };
76    let gated = axum::Router::new()
77        .route("/", get(pages::albums))
78        .route("/albums", get(pages::albums))
79        .route("/albums/more", get(pages::albums_more))
80        .route("/album/{id}", get(pages::album))
81        .route("/album/{id}/share", post(pages::share_album))
82        .route("/artist/{id}/share", post(pages::share_artist))
83        .route("/artists", get(pages::artists))
84        .route("/artists/more", get(pages::artists_more))
85        .route("/artist/{id}", get(pages::artist))
86        .route("/search", get(pages::search))
87        .route("/search/results", get(pages::search_results))
88        .route("/queue", get(pages::queue))
89        .route("/keys", get(keys::page).post(keys::create))
90        .route("/keys/{id}/revoke", post(keys::revoke))
91        .route("/ui/stream/{id}", get(stream))
92        .route("/ui/cover/{id}", get(cover))
93        .layer(from_fn(require_datastar_on_post))
94        .layer(from_fn_with_state(state.clone(), gate));
95    // Checking a password is deliberately expensive, so the form shares the
96    // JSON login's per-IP window.
97    let sign_in = get(session::login_form).merge(
98        post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
99    );
100    axum::Router::new()
101        .merge(gated)
102        .route("/login", sign_in)
103        .route("/auth/resume", get(session::resume))
104        .route("/auth/renew", post(session::renew))
105        .route("/auth/signout", post(session::signout))
106        .route("/ui/assets/{name}", get(ui_asset))
107        .with_state(state)
108}
109
110async fn ui_asset(Path(name): Path<String>) -> Response {
111    const JS: &str = "text/javascript; charset=utf-8";
112    match name.as_str() {
113        "ui.css" => asset(UI_CSS, "text/css; charset=utf-8"),
114        "ui.js" => asset(UI_JS, JS),
115        "player.js" => asset(crate::share::ENGINE_JS, JS),
116        "datastar.js" => asset(DATASTAR_JS, JS),
117        other => crate::share::icon(other).unwrap_or_else(not_found),
118    }
119}
120
121fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
122    headers
123        .get_all(header::COOKIE)
124        .iter()
125        .filter_map(|v| v.to_str().ok())
126        .flat_map(|v| v.split(';'))
127        .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
128}
129
130/// A request the UI's script makes rather than a page the browser navigates to.
131/// Such a request cannot follow a redirect to a sign-in page usefully, so it
132/// is refused outright and the script reloads.
133fn is_navigation(req: &Request) -> bool {
134    req.method() == Method::GET
135        && !req.headers().contains_key(PARTIAL)
136        && !req.headers().contains_key("datastar-request")
137        && !req.uri().path().starts_with("/ui/")
138}
139
140/// Let a signed-in user through; send a page load to resume its session, and
141/// refuse anything else.
142async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
143    let user = if s.auth_enabled {
144        cookie(req.headers(), "koan_access")
145            .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok())
146            .map(|c| AuthUser {
147                user_id: c.sub,
148                role: c.role.parse().unwrap_or(Role::Readonly),
149                username: c.username,
150            })
151    } else {
152        Some(AuthUser::anonymous_admin())
153    };
154    match user {
155        Some(user) => {
156            req.extensions_mut().insert(user);
157            next.run(req).await
158        }
159        None if is_navigation(&req) => {
160            let here = req
161                .uri()
162                .path_and_query()
163                .map_or("/", |p| p.as_str())
164                .to_owned();
165            see_other(&format!("/auth/resume?next={}", encode(&here)))
166        }
167        None => (
168            StatusCode::UNAUTHORIZED,
169            [(header::CACHE_CONTROL, "no-store")],
170            "signed out",
171        )
172            .into_response(),
173    }
174}
175
176/// Datastar sends this header on every request it makes, and a cross-site form
177/// or a CORS-safelisted fetch cannot set it: a state-changing POST without it
178/// did not come from this UI.
179async fn require_datastar_on_post(req: Request, next: Next) -> Response {
180    if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
181        return StatusCode::FORBIDDEN.into_response();
182    }
183    next.run(req).await
184}
185
186fn encode(s: &str) -> String {
187    form_urlencoded::byte_serialize(s.as_bytes()).collect()
188}
189
190fn see_other(location: &str) -> Response {
191    (
192        StatusCode::SEE_OTHER,
193        [
194            (header::LOCATION, location),
195            (header::CACHE_CONTROL, "no-store"),
196        ],
197    )
198        .into_response()
199}
200
201/// An HTML page with the UI's security headers.
202fn html(status: StatusCode, body: String) -> Response {
203    let mut resp = (status, body).into_response();
204    let h = resp.headers_mut();
205    h.insert(
206        header::CONTENT_TYPE,
207        HeaderValue::from_static("text/html; charset=utf-8"),
208    );
209    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
210    h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
211    h.insert(
212        header::CONTENT_SECURITY_POLICY,
213        HeaderValue::from_static(PAGE_CSP),
214    );
215    h.insert(
216        header::REFERRER_POLICY,
217        HeaderValue::from_static("same-origin"),
218    );
219    h.insert(
220        header::X_CONTENT_TYPE_OPTIONS,
221        HeaderValue::from_static("nosniff"),
222    );
223    h.insert(
224        "x-robots-tag",
225        HeaderValue::from_static("noindex, nofollow"),
226    );
227    resp
228}
229
230/// A Datastar `patch-elements` event. Without a selector the element replaces
231/// the one with its id; with one, `mode` says where it goes.
232fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
233    let mut lines = Vec::new();
234    if let Some((selector, mode)) = target {
235        lines.push(format!("selector {selector}"));
236        lines.push(format!("mode {mode}"));
237    }
238    lines.extend(html.lines().map(|l| format!("elements {l}")));
239    Event::default()
240        .event("datastar-patch-elements")
241        .data(lines.join("\n"))
242}
243
244fn events(events: Vec<Event>) -> Response {
245    let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
246    let mut resp = Sse::new(stream).into_response();
247    resp.headers_mut()
248        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
249    resp
250}
251
252fn open(pool: &Pool) -> Option<Handle<'_>> {
253    pool.get()
254        .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
255        .ok()
256}
257
258/// A track's audio, from a local file; Range requests are honoured, so the
259/// browser can seek a stream.
260async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
261    let path = blocking(move || {
262        let db = open(&s.pool)?;
263        let t = queries::tracks_by_ids(&db.conn, &[id]).ok()?.pop()?;
264        crate::subsonic::track_file_path(&t).map(PathBuf::from)
265    })
266    .await;
267    let Some(path) = path else {
268        return not_found();
269    };
270    match crate::subsonic::serve_local_file(&path, &headers).await {
271        Ok(mut resp) => {
272            resp.headers_mut().insert(
273                header::CACHE_CONTROL,
274                HeaderValue::from_static("private, max-age=3600"),
275            );
276            resp
277        }
278        Err(_) => not_found(),
279    }
280}
281
282/// An album's cover, from the art embedded in the first of its tracks that has any.
283#[derive(serde::Deserialize, Default)]
284#[serde(default)]
285struct CoverQuery {
286    size: Option<u32>,
287    /// The album's cover version, from `pages::cover_url`. With it the URL
288    /// names these exact bytes, so the browser may keep them for good.
289    v: Option<String>,
290}
291
292/// An album's cover at one of `covers::SIZES`, from the art embedded in the
293/// first of its tracks that has any.
294async fn cover(
295    State(s): State<UiState>,
296    Path(id): Path<i64>,
297    axum::extract::Query(q): axum::extract::Query<CoverQuery>,
298) -> Response {
299    let size = crate::covers::snap(q.size);
300    let art = blocking(move || {
301        let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).ok()?;
302        s.covers.cover(&tracks, size)
303    })
304    .await;
305    crate::share::jpeg(art, q.v.is_some())
306}