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