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