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 keys;
17mod pages;
18mod session;
19#[cfg(test)]
20mod tests;
21mod users;
22
23use std::path::PathBuf;
24use std::sync::Arc;
25
26use axum::extract::{Path, Request, State};
27use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
28use axum::middleware::{Next, from_fn, from_fn_with_state};
29use axum::response::sse::{Event, Sse};
30use axum::response::{IntoResponse, Response};
31use axum::routing::{get, post};
32use koan_core::auth::{self, Role};
33use koan_core::db::pool::{Handle, Pool};
34use koan_core::db::queries;
35
36use crate::auth::AuthUser;
37use crate::auth::routes::{AuthRouteState, login_rate_limit};
38use crate::covers::Covers;
39use crate::share::{asset, blocking, not_found};
40
41/// `'unsafe-eval'` because Datastar compiles its attribute expressions.
42/// Everything else is this server's own, and nothing is inline.
43const PAGE_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self'; \
44     img-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; \
45     frame-ancestors 'none'";
46
47/// Sent by the UI's own navigation, which wants the page content without the shell.
48const PARTIAL: &str = "x-koan-partial";
49
50const UI_CSS: &str = include_str!("../../assets/ui.css");
51const UI_JS: &str = include_str!("../../assets/ui.js");
52const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
53
54#[derive(Clone)]
55pub struct UiState {
56    pool: Arc<Pool>,
57    covers: Arc<Covers>,
58    /// The codecs and genres the filters offer, with when they were read.
59    options: Arc<std::sync::Mutex<Option<(std::time::Instant, pages::Options)>>>,
60    auth: AuthRouteState,
61    auth_enabled: bool,
62    /// `sharing.public_url`, the address invites point clients at.
63    public_url: Option<String>,
64}
65
66pub fn router(
67    pool: Arc<Pool>,
68    auth: AuthRouteState,
69    auth_enabled: bool,
70    covers: Arc<Covers>,
71    public_url: Option<String>,
72) -> axum::Router {
73    let state = UiState {
74        pool,
75        covers,
76        options: Arc::default(),
77        auth,
78        auth_enabled,
79        public_url,
80    };
81    let gated = axum::Router::new()
82        .route("/", get(pages::albums))
83        .route("/albums", get(pages::albums))
84        .route("/albums/more", get(pages::albums_more))
85        .route("/album/{id}", get(pages::album))
86        .route("/album/{id}/share", post(pages::share_album))
87        .route("/artist/{id}/share", post(pages::share_artist))
88        .route("/artists", get(pages::artists))
89        .route("/artists/more", get(pages::artists_more))
90        .route("/artist/{id}", get(pages::artist))
91        .route("/search", get(pages::search))
92        .route("/search/results", get(pages::search_results))
93        .route("/queue", get(pages::queue))
94        .route("/keys", get(keys::page).post(keys::create))
95        .route("/keys/{id}/revoke", post(keys::revoke))
96        .route("/users", get(users::page).post(users::create))
97        .route("/users/{id}/invite", post(users::invite))
98        .route("/users/{id}/role", post(users::set_role))
99        .route("/users/{id}/delete", post(users::delete))
100        .route("/ui/stream/{id}", get(stream))
101        .route("/ui/cover/{id}", get(cover))
102        .layer(from_fn(require_datastar_on_post))
103        .layer(from_fn_with_state(state.clone(), gate));
104    // Checking a password is deliberately expensive, so the form shares the
105    // JSON login's per-IP window.
106    let sign_in = get(session::login_form).merge(
107        post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
108    );
109    axum::Router::new()
110        .merge(gated)
111        .route("/login", sign_in)
112        .route("/auth/resume", get(session::resume))
113        .route("/auth/renew", post(session::renew))
114        .route("/auth/signout", post(session::signout))
115        .route("/ui/assets/{name}", get(ui_asset))
116        .with_state(state)
117}
118
119async fn ui_asset(Path(name): Path<String>) -> Response {
120    const JS: &str = "text/javascript; charset=utf-8";
121    match name.as_str() {
122        "ui.css" => asset(UI_CSS, "text/css; charset=utf-8"),
123        "ui.js" => asset(UI_JS, JS),
124        "player.js" => asset(crate::share::ENGINE_JS, JS),
125        "datastar.js" => asset(DATASTAR_JS, JS),
126        other => crate::share::icon(other).unwrap_or_else(not_found),
127    }
128}
129
130fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
131    headers
132        .get_all(header::COOKIE)
133        .iter()
134        .filter_map(|v| v.to_str().ok())
135        .flat_map(|v| v.split(';'))
136        .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
137}
138
139/// A request the UI's script makes rather than a page the browser navigates to.
140/// Such a request cannot follow a redirect to a sign-in page usefully, so it
141/// is refused outright and the script reloads.
142fn is_navigation(req: &Request) -> bool {
143    req.method() == Method::GET
144        && !req.headers().contains_key(PARTIAL)
145        && !req.headers().contains_key("datastar-request")
146        && !req.uri().path().starts_with("/ui/")
147}
148
149/// Let a signed-in user through; send a page load to resume its session, and
150/// refuse anything else.
151async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
152    let user = if s.auth_enabled {
153        cookie(req.headers(), "koan_access")
154            .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok())
155            .map(|c| AuthUser {
156                user_id: c.sub,
157                role: c.role.parse().unwrap_or(Role::Readonly),
158                username: c.username,
159            })
160    } else {
161        Some(AuthUser::anonymous_admin())
162    };
163    match user {
164        Some(user) => {
165            req.extensions_mut().insert(user);
166            next.run(req).await
167        }
168        None if is_navigation(&req) => {
169            let here = req
170                .uri()
171                .path_and_query()
172                .map_or("/", |p| p.as_str())
173                .to_owned();
174            see_other(&format!("/auth/resume?next={}", encode(&here)))
175        }
176        None => (
177            StatusCode::UNAUTHORIZED,
178            [(header::CACHE_CONTROL, "no-store")],
179            "signed out",
180        )
181            .into_response(),
182    }
183}
184
185/// Datastar sends this header on every request it makes, and a cross-site form
186/// or a CORS-safelisted fetch cannot set it: a state-changing POST without it
187/// did not come from this UI.
188async fn require_datastar_on_post(req: Request, next: Next) -> Response {
189    if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
190        return StatusCode::FORBIDDEN.into_response();
191    }
192    next.run(req).await
193}
194
195fn encode(s: &str) -> String {
196    form_urlencoded::byte_serialize(s.as_bytes()).collect()
197}
198
199fn see_other(location: &str) -> Response {
200    (
201        StatusCode::SEE_OTHER,
202        [
203            (header::LOCATION, location),
204            (header::CACHE_CONTROL, "no-store"),
205        ],
206    )
207        .into_response()
208}
209
210/// An HTML page with the UI's security headers.
211fn html(status: StatusCode, body: String) -> Response {
212    let mut resp = (status, body).into_response();
213    let h = resp.headers_mut();
214    h.insert(
215        header::CONTENT_TYPE,
216        HeaderValue::from_static("text/html; charset=utf-8"),
217    );
218    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
219    h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
220    h.insert(
221        header::CONTENT_SECURITY_POLICY,
222        HeaderValue::from_static(PAGE_CSP),
223    );
224    h.insert(
225        header::REFERRER_POLICY,
226        HeaderValue::from_static("same-origin"),
227    );
228    h.insert(
229        header::X_CONTENT_TYPE_OPTIONS,
230        HeaderValue::from_static("nosniff"),
231    );
232    h.insert(
233        "x-robots-tag",
234        HeaderValue::from_static("noindex, nofollow"),
235    );
236    resp
237}
238
239/// A Datastar `patch-elements` event. Without a selector the element replaces
240/// the one with its id; with one, `mode` says where it goes.
241fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
242    let mut lines = Vec::new();
243    if let Some((selector, mode)) = target {
244        lines.push(format!("selector {selector}"));
245        lines.push(format!("mode {mode}"));
246    }
247    lines.extend(html.lines().map(|l| format!("elements {l}")));
248    Event::default()
249        .event("datastar-patch-elements")
250        .data(lines.join("\n"))
251}
252
253fn events(events: Vec<Event>) -> Response {
254    let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
255    let mut resp = Sse::new(stream).into_response();
256    resp.headers_mut()
257        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
258    resp
259}
260
261fn open(pool: &Pool) -> Option<Handle<'_>> {
262    pool.get()
263        .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
264        .ok()
265}
266
267/// A track's audio, from a local file; Range requests are honoured, so the
268/// browser can seek a stream.
269async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
270    let path = blocking(move || {
271        let db = open(&s.pool)?;
272        let t = queries::tracks_by_ids(&db.conn, &[id]).ok()?.pop()?;
273        crate::subsonic::track_file_path(&t).map(PathBuf::from)
274    })
275    .await;
276    let Some(path) = path else {
277        return not_found();
278    };
279    match crate::subsonic::serve_local_file(&path, &headers).await {
280        Ok(mut resp) => {
281            resp.headers_mut().insert(
282                header::CACHE_CONTROL,
283                HeaderValue::from_static("private, max-age=3600"),
284            );
285            resp
286        }
287        Err(_) => not_found(),
288    }
289}
290
291/// An album's cover, from the art embedded in the first of its tracks that has any.
292#[derive(serde::Deserialize, Default)]
293#[serde(default)]
294struct CoverQuery {
295    size: Option<u32>,
296    /// The album's cover version, from `pages::cover_url`. With it the URL
297    /// names these exact bytes, so the browser may keep them for good.
298    v: Option<String>,
299}
300
301/// An album's cover at one of `covers::SIZES`, from the art embedded in the
302/// first of its tracks that has any.
303async fn cover(
304    State(s): State<UiState>,
305    Path(id): Path<i64>,
306    axum::extract::Query(q): axum::extract::Query<CoverQuery>,
307) -> Response {
308    let size = crate::covers::snap(q.size);
309    let art = blocking(move || {
310        let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).ok()?;
311        s.covers.cover(&tracks, size)
312    })
313    .await;
314    crate::share::jpeg(art, q.v.is_some())
315}