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