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