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