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, 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 account;
16mod browse;
17mod caches;
18mod connect;
19mod favourite;
20mod history;
21mod oauth;
22mod pages;
23mod pair;
24mod properties;
25mod scan;
26mod scrobbling;
27mod session;
28mod setup;
29#[cfg(test)]
30mod tests;
31mod users;
32
33pub use oauth::RESOURCE_METADATA;
34pub use session::ProxyAuth;
35
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use axum::extract::{Path, RawQuery, Request, State};
40use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
41use axum::middleware::{Next, from_fn, from_fn_with_state};
42use axum::response::sse::{Event, Sse};
43use axum::response::{IntoResponse, Response};
44use axum::routing::{get, post};
45use koan_core::auth;
46use koan_core::db::pool::{Handle, Pool};
47use koan_core::db::queries;
48
49use crate::auth::AuthUser;
50use crate::auth::routes::{AuthRouteState, RateLimiter, login_rate_limit, rate_limit};
51use crate::covers::Covers;
52use crate::share::{asset, blocking, not_found};
53
54/// `'unsafe-eval'` because Datastar compiles its attribute expressions.
55/// Everything else is this server's own, and nothing is inline but the import
56/// map naming the player, allowed by its hash.
57static PAGE_CSP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
58    format!(
59        "default-src 'none'; script-src 'self' 'unsafe-eval' {}; style-src 'self'; font-src 'self'; \
60         img-src 'self'; media-src 'self' blob:; connect-src 'self'; base-uri 'self'; form-action 'self'; \
61         frame-ancestors 'none'",
62        ASSETS.imports.csp
63    )
64});
65/// `PAGE_CSP` as the header every page sends; OAuth's consent page widens the
66/// text instead.
67static PAGE_CSP_HEADER: std::sync::LazyLock<HeaderValue> =
68    std::sync::LazyLock::new(|| HeaderValue::from_str(&PAGE_CSP).expect("the CSP is ASCII"));
69
70/// Sent by the UI's own navigation, which wants the page content without the shell.
71const PARTIAL: &str = "x-koan-partial";
72
73const UI_CSS: &str = include_str!("../../assets/ui.css");
74const UI_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/ui.js"));
75const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
76const NO_COVER_SVG: &str = include_str!("../../assets/no-cover.svg");
77
78/// The page's stylesheet and scripts, each URL carrying a hash of its asset.
79pub(super) struct AssetUrls {
80    pub css: String,
81    pub css_hash: String,
82    pub ui_js: String,
83    pub player_js: String,
84    pub imports: crate::share::ImportMap,
85    pub datastar_js: String,
86}
87
88pub(super) static ASSETS: std::sync::LazyLock<AssetUrls> = std::sync::LazyLock::new(|| {
89    use crate::share::versioned;
90    let player_js = versioned("./ui/assets/player.js", crate::share::ENGINE_JS);
91    AssetUrls {
92        css: versioned("./ui/assets/ui.css", UI_CSS),
93        css_hash: crate::share::content_hash(UI_CSS),
94        ui_js: versioned("./ui/assets/ui.js", UI_JS),
95        imports: crate::share::ImportMap::player(&player_js),
96        player_js,
97        datastar_js: versioned("./ui/assets/datastar.js", DATASTAR_JS),
98    }
99});
100
101#[derive(Clone)]
102pub struct UiState {
103    pool: Arc<Pool>,
104    covers: Arc<Covers>,
105    /// The codecs and genres the filters offer, with the `library_stamp` they
106    /// were read at.
107    options: Arc<std::sync::Mutex<Option<(u64, pages::Options)>>>,
108    auth: AuthRouteState,
109    auth_enabled: bool,
110    /// `graphql.setup_wizard`: whether `/setup` may make the first admin.
111    setup_wizard: bool,
112    /// `server.url`, the address invites point clients at.
113    server_url: Option<String>,
114    /// OAuth codes awaiting their token request.
115    codes: oauth::Codes,
116    /// `mcp.redirect_hosts`.
117    redirect_hosts: Arc<Vec<String>>,
118    /// `graphql.proxy_auth_header`, when an authenticating proxy is trusted.
119    proxy_auth: Option<ProxyAuth>,
120    /// GraphQL's background jobs, which a rescan started here joins.
121    jobs: crate::graphql::jobs::JobRegistry,
122}
123
124impl UiState {
125    /// What pages name as `<base href>`; see `crate::base`.
126    fn base(&self) -> String {
127        crate::base::href(self.server_url.as_deref())
128    }
129}
130
131#[allow(clippy::too_many_arguments)]
132pub(crate) fn router(
133    pool: Arc<Pool>,
134    auth: AuthRouteState,
135    auth_enabled: bool,
136    setup_wizard: bool,
137    covers: Arc<Covers>,
138    server_url: Option<String>,
139    redirect_hosts: Vec<String>,
140    proxy_auth: Option<ProxyAuth>,
141    jobs: crate::graphql::jobs::JobRegistry,
142) -> axum::Router {
143    let state = UiState {
144        pool,
145        covers,
146        options: Arc::default(),
147        auth,
148        auth_enabled,
149        setup_wizard,
150        server_url,
151        codes: oauth::Codes::default(),
152        redirect_hosts: Arc::new(redirect_hosts),
153        proxy_auth,
154        jobs,
155    };
156    let base = crate::base::path_of(state.server_url.as_deref());
157    let gated = axum::Router::new()
158        .route("/", get(pages::albums))
159        .route("/albums", get(pages::albums))
160        .route("/album/{id}", get(pages::album))
161        .route("/album/{id}/share", post(pages::share_album))
162        .route("/artist/{id}/share", post(pages::share_artist))
163        .route("/artists", get(pages::artists))
164        .route("/artist/{id}", get(pages::artist))
165        .route("/tracks", get(pages::tracks))
166        .route("/track/{id}/properties", get(properties::page))
167        .route("/playlists", get(pages::playlists))
168        .route("/playlist/{id}", get(pages::playlist))
169        .route("/search", get(pages::search))
170        .route("/search/results", get(pages::search_results))
171        .route("/queue", get(pages::queue))
172        .route("/library", get(pages::library))
173        .route("/library/scan", get(scan::watch).post(scan::start))
174        .route("/library/caches", get(caches::watch))
175        .route("/library/caches/clear", post(caches::clear))
176        .route("/library/caches/warm", post(caches::warm))
177        .route("/favourites", get(pages::favourites))
178        .route("/recent", get(pages::recent))
179        .route("/history", get(history::page))
180        .route("/favourite/{kind}/{id}", post(favourite::toggle))
181        .route("/history/forget", post(history::forget))
182        .route("/connect", get(connect::page))
183        .route("/account", get(account::page))
184        .route("/account/keys", post(account::create_key))
185        .route("/account/keys/{id}/revoke", post(account::revoke_key))
186        .route("/account/app-passwords", post(account::create_app_password))
187        .route(
188            "/account/app-passwords/{id}/revoke",
189            post(account::revoke_app_password),
190        )
191        // Where the keys page was.
192        .route("/keys", get(|| async { see_other("/account") }))
193        .route("/scrobbling", get(scrobbling::page))
194        .route(
195            "/scrobbling/lastfm/connect",
196            get(scrobbling::lastfm_connect),
197        )
198        .route(
199            "/scrobbling/lastfm/return/{state}",
200            get(scrobbling::lastfm_return),
201        )
202        .route(
203            "/scrobbling/lastfm/disconnect",
204            post(scrobbling::lastfm_disconnect),
205        )
206        .route("/scrobbling/listenbrainz", post(scrobbling::connect))
207        .route(
208            "/scrobbling/listenbrainz/disconnect",
209            post(scrobbling::disconnect),
210        )
211        .route("/users", get(users::page).post(users::create))
212        .route("/users/{id}/invite", post(users::invite))
213        .route("/users/{id}/password", post(users::set_password))
214        .route("/users/{id}/password/form", post(users::password_form))
215        .route("/users/{id}/role", post(users::set_role))
216        .route("/users/{id}/delete", post(users::delete))
217        .route("/ui/stream/{id}", get(stream))
218        .route("/ui/cover/{id}", get(cover))
219        .layer(from_fn(require_datastar_on_post))
220        .layer(from_fn_with_state(state.clone(), gate))
221        .layer(axum::middleware::map_response(stamp_stylesheet));
222    // A plain form, since it answers with a redirect to the client: it proves
223    // its origin the way the sign-in form does.
224    let consent = axum::Router::new()
225        .route(
226            "/oauth/authorize",
227            get(oauth::authorize).post(oauth::approve),
228        )
229        .layer(from_fn_with_state(state.clone(), gate));
230    // Approving a device waiting to sign in: plain forms too, reached from a
231    // phone that may never have opened the UI. See `crate::pair`.
232    let pairing = axum::Router::new()
233        .route("/pair", get(pair::form))
234        .route("/pair/{pair}", get(pair::confirm))
235        .route("/pair/{pair}/approve", post(pair::approve))
236        .route("/pair/{pair}/decline", post(pair::decline))
237        .layer(from_fn_with_state(state.clone(), gate));
238    // Checking a password is deliberately expensive, so the form shares the
239    // JSON login's per-IP window.
240    let sign_in = get(session::login_form).merge(
241        post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
242    );
243    // Making the first admin hashes a password, so it shares sign-in's window.
244    let setup = get(setup::form)
245        .merge(post(setup::create).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)));
246    axum::Router::new()
247        .merge(gated)
248        .merge(consent)
249        .merge(pairing)
250        .route(
251            "/.well-known/oauth-protected-resource",
252            get(oauth::protected_resource),
253        )
254        .route(oauth::RESOURCE_METADATA, get(oauth::protected_resource))
255        .route(
256            "/.well-known/oauth-authorization-server",
257            get(oauth::authorization_server),
258        )
259        // Unauthenticated, so capped per IP: registering stores nothing, but
260        // signs a client id each time.
261        .route(
262            "/oauth/register",
263            post(oauth::register)
264                .layer(axum::extract::DefaultBodyLimit::max(
265                    oauth::MAX_REGISTRATION_BODY,
266                ))
267                .layer(from_fn_with_state(
268                    Arc::new(RateLimiter::new(3600, 10)),
269                    rate_limit,
270                )),
271        )
272        .route(
273            "/oauth/token",
274            post(oauth::token).layer(from_fn_with_state(
275                Arc::new(RateLimiter::new(60, 60)),
276                rate_limit,
277            )),
278        )
279        .route("/login", sign_in)
280        .route("/setup", setup)
281        .route("/auth/resume", get(session::resume))
282        .route(session::PROXY_RESUME, get(session::proxy_resume))
283        .route("/ui/renew", post(session::proxy_renew))
284        .route("/auth/renew", post(session::renew))
285        .route("/auth/signout", post(session::signout))
286        .route("/ui/assets/{name}", get(ui_asset))
287        // Where anything that wants an icon for this host looks first, before
288        // reading a page: without it, a favicon service settles for the parent
289        // domain's.
290        .route(
291            "/favicon.ico",
292            get(|| ui_asset(Path("icon-192.png".into()), RawQuery(None))),
293        )
294        .route(
295            "/apple-touch-icon.png",
296            get(|| ui_asset(Path("apple-touch-icon.png".into()), RawQuery(None))),
297        )
298        .merge(path_inserted_discovery(&base))
299        .with_state(state)
300}
301
302/// Under a path, where RFC 8414 and RFC 9728 put the metadata: between the
303/// host and the path (`/.well-known/oauth-authorization-server/koan`), outside
304/// what a proxy forwards for the path. It reaches here when the proxy forwards
305/// that too; see `crate::base`.
306fn path_inserted_discovery(base: &str) -> axum::Router<UiState> {
307    if base.is_empty() || crate::base::problem(base).is_some() {
308        return axum::Router::new();
309    }
310    axum::Router::new()
311        .route(
312            &format!("/.well-known/oauth-authorization-server{base}"),
313            get(oauth::authorization_server),
314        )
315        .route(
316            &format!("/.well-known/oauth-protected-resource{base}/mcp"),
317            get(oauth::protected_resource),
318        )
319}
320
321/// Which stylesheet the markup was written for. A tab open across an upgrade
322/// keeps the old one while navigation and patches bring new markup; ui.js
323/// swaps the stylesheet when this names another.
324async fn stamp_stylesheet(mut res: Response) -> Response {
325    if let Ok(v) = HeaderValue::from_str(&ASSETS.css_hash) {
326        res.headers_mut().insert("x-koan-css", v);
327    }
328    res
329}
330
331async fn ui_asset(Path(name): Path<String>, query: RawQuery) -> Response {
332    const JS: &str = "text/javascript; charset=utf-8";
333    match name.as_str() {
334        "ui.css" => asset(UI_CSS, "text/css; charset=utf-8", query),
335        "ui.js" => asset(UI_JS, JS, query),
336        "player.js" => asset(crate::share::ENGINE_JS, JS, query),
337        "datastar.js" => asset(DATASTAR_JS, JS, query),
338        other => crate::share::binary_asset(other).unwrap_or_else(not_found),
339    }
340}
341
342fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
343    headers
344        .get_all(header::COOKIE)
345        .iter()
346        .filter_map(|v| v.to_str().ok())
347        .flat_map(|v| v.split(';'))
348        .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
349}
350
351/// A request the UI's script makes rather than a page the browser navigates to.
352/// Such a request cannot follow a redirect to a sign-in page usefully, so it
353/// is refused outright and the script reloads.
354fn is_navigation(req: &Request) -> bool {
355    req.method() == Method::GET
356        && !req.headers().contains_key(PARTIAL)
357        && !req.headers().contains_key("datastar-request")
358        && !req.uri().path().starts_with("/ui/")
359}
360
361/// Let a signed-in user through; send a page load to resume its session, and
362/// refuse anything else. Behind an authenticating proxy a session is good only
363/// for the account the proxy names, so a browser whose proxy sign-in changed
364/// hands over to the new account, and a header from the proxy that names no
365/// one account lets no session through.
366async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
367    let user = if s.auth_enabled {
368        let vouched = session::vouched(&s, req.headers(), req.extensions());
369        if vouched == session::Vouch::Unusable {
370            return session::unusable_header(&s);
371        }
372        let claims = cookie(req.headers(), "koan_access")
373            .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok());
374        match (claims, vouched) {
375            (Some(claims), session::Vouch::Absent) => {
376                crate::auth::current_user(&s.pool, claims).await
377            }
378            (Some(claims), session::Vouch::Named(name)) if name == claims.username => {
379                crate::auth::current_user(&s.pool, claims).await
380            }
381            _ => None,
382        }
383    } else {
384        Some(AuthUser::anonymous_admin())
385    };
386    match user {
387        Some(user) => {
388            req.extensions_mut().insert(user);
389            next.run(req).await
390        }
391        None if is_navigation(&req) => {
392            let here = req
393                .uri()
394                .path_and_query()
395                .map_or("/", |p| p.as_str())
396                .to_owned();
397            let resume = if s.proxy_auth.is_some() {
398                session::PROXY_RESUME
399            } else {
400                "/auth/resume"
401            };
402            see_other(&format!("{resume}?next={}", encode(&here)))
403        }
404        None => (
405            StatusCode::UNAUTHORIZED,
406            [(header::CACHE_CONTROL, "no-store")],
407            "signed out",
408        )
409            .into_response(),
410    }
411}
412
413/// Datastar sends this header on every request it makes, and a cross-site form
414/// or a CORS-safelisted fetch cannot set it: a state-changing POST without it
415/// did not come from this UI.
416async fn require_datastar_on_post(req: Request, next: Next) -> Response {
417    if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
418        return StatusCode::FORBIDDEN.into_response();
419    }
420    next.run(req).await
421}
422
423fn encode(s: &str) -> String {
424    form_urlencoded::byte_serialize(s.as_bytes()).collect()
425}
426
427fn see_other(location: &str) -> Response {
428    (
429        StatusCode::SEE_OTHER,
430        [
431            (header::LOCATION, location),
432            (header::CACHE_CONTROL, "no-store"),
433        ],
434    )
435        .into_response()
436}
437
438/// An HTML page with the UI's security headers.
439fn html(status: StatusCode, body: impl Into<String>) -> Response {
440    let mut resp = (status, body.into()).into_response();
441    let h = resp.headers_mut();
442    h.insert(
443        header::CONTENT_TYPE,
444        HeaderValue::from_static("text/html; charset=utf-8"),
445    );
446    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
447    h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
448    h.insert(header::CONTENT_SECURITY_POLICY, PAGE_CSP_HEADER.clone());
449    h.insert(
450        header::REFERRER_POLICY,
451        HeaderValue::from_static("same-origin"),
452    );
453    h.insert(
454        header::X_CONTENT_TYPE_OPTIONS,
455        HeaderValue::from_static("nosniff"),
456    );
457    h.insert(
458        "x-robots-tag",
459        HeaderValue::from_static("noindex, nofollow"),
460    );
461    resp
462}
463
464/// A Datastar `patch-elements` event. Without a selector the element replaces
465/// the one with its id; with one, `mode` says where it goes.
466fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
467    let mut lines = Vec::new();
468    if let Some((selector, mode)) = target {
469        lines.push(format!("selector {selector}"));
470        lines.push(format!("mode {mode}"));
471    }
472    lines.extend(html.lines().map(|l| format!("elements {l}")));
473    Event::default()
474        .event("datastar-patch-elements")
475        .data(lines.join("\n"))
476}
477
478fn events(events: Vec<Event>) -> Response {
479    let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
480    let mut resp = Sse::new(stream).into_response();
481    resp.headers_mut()
482        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
483    resp
484}
485
486fn open(pool: &Pool) -> Option<Handle<'_>> {
487    pool.get()
488        .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
489        .ok()
490}
491
492/// A failed read as the page's empty or not-found state, logged where it
493/// happened so that a broken database is not mistaken for a missing row.
494pub(crate) trait Logged<T> {
495    fn logged(self) -> Option<T>;
496}
497
498impl<T, E: std::fmt::Display> Logged<T> for Result<T, E> {
499    #[track_caller]
500    fn logged(self) -> Option<T> {
501        let at = std::panic::Location::caller();
502        self.inspect_err(|e| {
503            log::error!("web UI: a read failed at {}:{}: {e}", at.file(), at.line())
504        })
505        .ok()
506    }
507}
508
509/// A track's audio, from a local file; Range requests are honoured, so the
510/// browser can seek a stream.
511async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
512    let path = blocking(move || {
513        let db = open(&s.pool)?;
514        let t = queries::tracks_by_ids(&db.conn, &[id]).logged()?.pop()?;
515        crate::subsonic::track_file_path(&t).map(PathBuf::from)
516    })
517    .await;
518    let Some(path) = path else {
519        return not_found();
520    };
521    match crate::subsonic::serve_local_file(&path, &headers).await {
522        Ok(mut resp) => {
523            resp.headers_mut().insert(
524                header::CACHE_CONTROL,
525                HeaderValue::from_static("private, max-age=3600"),
526            );
527            resp
528        }
529        Err(_) => not_found(),
530    }
531}
532
533/// Query parameters for `cover`.
534#[derive(serde::Deserialize, Default)]
535#[serde(default)]
536struct CoverQuery {
537    size: Option<u32>,
538    /// The album's cover version, from `pages::cover_url`. With it the URL
539    /// names these exact bytes, so the browser may keep them for good.
540    v: Option<String>,
541}
542
543/// An album's cover at one of `covers::SIZES`, from the art embedded in the
544/// first of its tracks that has any.
545async fn cover(
546    State(s): State<UiState>,
547    Path(id): Path<i64>,
548    axum::extract::Query(q): axum::extract::Query<CoverQuery>,
549) -> Response {
550    let size = crate::covers::snap(q.size);
551    let found = blocking(move || {
552        let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).logged()?;
553        (!tracks.is_empty()).then(|| s.covers.cover(&tracks, size))
554    })
555    .await;
556    match found {
557        Some(Some(art)) => crate::share::jpeg(Some(art), q.v.is_some()),
558        // A record with no artwork draws what the apps draw, not a broken
559        // image. Not kept for good: art added beside the files changes no
560        // track's mtime, so the URL stays the same when it arrives.
561        Some(None) => (
562            [
563                (header::CONTENT_TYPE, "image/svg+xml"),
564                (header::CACHE_CONTROL, "private, max-age=3600"),
565            ],
566            NO_COVER_SVG,
567        )
568            .into_response(),
569        None => not_found(),
570    }
571}