1mod account;
16mod browse;
17mod connect;
18mod favourite;
19mod history;
20mod oauth;
21mod pages;
22mod pair;
23mod properties;
24mod scan;
25mod scrobbling;
26mod session;
27mod setup;
28#[cfg(test)]
29mod tests;
30mod users;
31
32pub use oauth::RESOURCE_METADATA;
33pub use session::ProxyAuth;
34
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use axum::extract::{Path, RawQuery, Request, State};
39use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
40use axum::middleware::{Next, from_fn, from_fn_with_state};
41use axum::response::sse::{Event, Sse};
42use axum::response::{IntoResponse, Response};
43use axum::routing::{get, post};
44use koan_core::auth;
45use koan_core::db::pool::{Handle, Pool};
46use koan_core::db::queries;
47
48use crate::auth::AuthUser;
49use crate::auth::routes::{AuthRouteState, RateLimiter, login_rate_limit, rate_limit};
50use crate::covers::Covers;
51use crate::share::{asset, blocking, not_found};
52
53static PAGE_CSP: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
57 format!(
58 "default-src 'none'; script-src 'self' 'unsafe-eval' {}; style-src 'self'; font-src 'self'; \
59 img-src 'self'; media-src 'self' blob:; connect-src 'self'; base-uri 'self'; form-action 'self'; \
60 frame-ancestors 'none'",
61 ASSETS.imports.csp
62 )
63});
64static PAGE_CSP_HEADER: std::sync::LazyLock<HeaderValue> =
67 std::sync::LazyLock::new(|| HeaderValue::from_str(&PAGE_CSP).expect("the CSP is ASCII"));
68
69const PARTIAL: &str = "x-koan-partial";
71
72const UI_CSS: &str = include_str!("../../assets/ui.css");
73const UI_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/ui.js"));
74const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
75const NO_COVER_SVG: &str = include_str!("../../assets/no-cover.svg");
76
77pub(super) struct AssetUrls {
79 pub css: String,
80 pub css_hash: String,
81 pub ui_js: String,
82 pub player_js: String,
83 pub imports: crate::share::ImportMap,
84 pub datastar_js: String,
85}
86
87pub(super) static ASSETS: std::sync::LazyLock<AssetUrls> = std::sync::LazyLock::new(|| {
88 use crate::share::versioned;
89 let player_js = versioned("./ui/assets/player.js", crate::share::ENGINE_JS);
90 AssetUrls {
91 css: versioned("./ui/assets/ui.css", UI_CSS),
92 css_hash: crate::share::content_hash(UI_CSS),
93 ui_js: versioned("./ui/assets/ui.js", UI_JS),
94 imports: crate::share::ImportMap::player(&player_js),
95 player_js,
96 datastar_js: versioned("./ui/assets/datastar.js", DATASTAR_JS),
97 }
98});
99
100#[derive(Clone)]
101pub struct UiState {
102 pool: Arc<Pool>,
103 covers: Arc<Covers>,
104 options: Arc<std::sync::Mutex<Option<(u64, pages::Options)>>>,
107 auth: AuthRouteState,
108 auth_enabled: bool,
109 setup_wizard: bool,
111 server_url: Option<String>,
113 codes: oauth::Codes,
115 redirect_hosts: Arc<Vec<String>>,
117 proxy_auth: Option<ProxyAuth>,
119 jobs: crate::graphql::jobs::JobRegistry,
121}
122
123impl UiState {
124 fn base(&self) -> String {
126 crate::base::href(self.server_url.as_deref())
127 }
128}
129
130#[allow(clippy::too_many_arguments)]
131pub(crate) fn router(
132 pool: Arc<Pool>,
133 auth: AuthRouteState,
134 auth_enabled: bool,
135 setup_wizard: bool,
136 covers: Arc<Covers>,
137 server_url: Option<String>,
138 redirect_hosts: Vec<String>,
139 proxy_auth: Option<ProxyAuth>,
140 jobs: crate::graphql::jobs::JobRegistry,
141) -> axum::Router {
142 let state = UiState {
143 pool,
144 covers,
145 options: Arc::default(),
146 auth,
147 auth_enabled,
148 setup_wizard,
149 server_url,
150 codes: oauth::Codes::default(),
151 redirect_hosts: Arc::new(redirect_hosts),
152 proxy_auth,
153 jobs,
154 };
155 let base = crate::base::path_of(state.server_url.as_deref());
156 let gated = axum::Router::new()
157 .route("/", get(pages::albums))
158 .route("/albums", get(pages::albums))
159 .route("/album/{id}", get(pages::album))
160 .route("/album/{id}/share", post(pages::share_album))
161 .route("/artist/{id}/share", post(pages::share_artist))
162 .route("/artists", get(pages::artists))
163 .route("/artist/{id}", get(pages::artist))
164 .route("/tracks", get(pages::tracks))
165 .route("/track/{id}/properties", get(properties::page))
166 .route("/playlists", get(pages::playlists))
167 .route("/playlist/{id}", get(pages::playlist))
168 .route("/search", get(pages::search))
169 .route("/search/results", get(pages::search_results))
170 .route("/queue", get(pages::queue))
171 .route("/library", get(pages::library))
172 .route("/library/scan", get(scan::watch).post(scan::start))
173 .route("/favourites", get(pages::favourites))
174 .route("/recent", get(pages::recent))
175 .route("/history", get(history::page))
176 .route("/favourite/{kind}/{id}", post(favourite::toggle))
177 .route("/history/forget", post(history::forget))
178 .route("/connect", get(connect::page))
179 .route("/account", get(account::page))
180 .route("/account/keys", post(account::create_key))
181 .route("/account/keys/{id}/revoke", post(account::revoke_key))
182 .route("/account/app-passwords", post(account::create_app_password))
183 .route(
184 "/account/app-passwords/{id}/revoke",
185 post(account::revoke_app_password),
186 )
187 .route("/keys", get(|| async { see_other("/account") }))
189 .route("/scrobbling", get(scrobbling::page))
190 .route(
191 "/scrobbling/lastfm/connect",
192 get(scrobbling::lastfm_connect),
193 )
194 .route(
195 "/scrobbling/lastfm/return/{state}",
196 get(scrobbling::lastfm_return),
197 )
198 .route(
199 "/scrobbling/lastfm/disconnect",
200 post(scrobbling::lastfm_disconnect),
201 )
202 .route("/scrobbling/listenbrainz", post(scrobbling::connect))
203 .route(
204 "/scrobbling/listenbrainz/disconnect",
205 post(scrobbling::disconnect),
206 )
207 .route("/users", get(users::page).post(users::create))
208 .route("/users/{id}/invite", post(users::invite))
209 .route("/users/{id}/password", post(users::set_password))
210 .route("/users/{id}/password/form", post(users::password_form))
211 .route("/users/{id}/role", post(users::set_role))
212 .route("/users/{id}/delete", post(users::delete))
213 .route("/ui/stream/{id}", get(stream))
214 .route("/ui/cover/{id}", get(cover))
215 .layer(from_fn(require_datastar_on_post))
216 .layer(from_fn_with_state(state.clone(), gate))
217 .layer(axum::middleware::map_response(stamp_stylesheet));
218 let consent = axum::Router::new()
221 .route(
222 "/oauth/authorize",
223 get(oauth::authorize).post(oauth::approve),
224 )
225 .layer(from_fn_with_state(state.clone(), gate));
226 let pairing = axum::Router::new()
229 .route("/pair", get(pair::form))
230 .route("/pair/{pair}", get(pair::confirm))
231 .route("/pair/{pair}/approve", post(pair::approve))
232 .route("/pair/{pair}/decline", post(pair::decline))
233 .layer(from_fn_with_state(state.clone(), gate));
234 let sign_in = get(session::login_form).merge(
237 post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
238 );
239 let setup = get(setup::form)
241 .merge(post(setup::create).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)));
242 axum::Router::new()
243 .merge(gated)
244 .merge(consent)
245 .merge(pairing)
246 .route(
247 "/.well-known/oauth-protected-resource",
248 get(oauth::protected_resource),
249 )
250 .route(oauth::RESOURCE_METADATA, get(oauth::protected_resource))
251 .route(
252 "/.well-known/oauth-authorization-server",
253 get(oauth::authorization_server),
254 )
255 .route(
258 "/oauth/register",
259 post(oauth::register)
260 .layer(axum::extract::DefaultBodyLimit::max(
261 oauth::MAX_REGISTRATION_BODY,
262 ))
263 .layer(from_fn_with_state(
264 Arc::new(RateLimiter::new(3600, 10)),
265 rate_limit,
266 )),
267 )
268 .route(
269 "/oauth/token",
270 post(oauth::token).layer(from_fn_with_state(
271 Arc::new(RateLimiter::new(60, 60)),
272 rate_limit,
273 )),
274 )
275 .route("/login", sign_in)
276 .route("/setup", setup)
277 .route("/auth/resume", get(session::resume))
278 .route(session::PROXY_RESUME, get(session::proxy_resume))
279 .route("/ui/renew", post(session::proxy_renew))
280 .route("/auth/renew", post(session::renew))
281 .route("/auth/signout", post(session::signout))
282 .route("/ui/assets/{name}", get(ui_asset))
283 .route(
287 "/favicon.ico",
288 get(|| ui_asset(Path("icon-192.png".into()), RawQuery(None))),
289 )
290 .route(
291 "/apple-touch-icon.png",
292 get(|| ui_asset(Path("apple-touch-icon.png".into()), RawQuery(None))),
293 )
294 .merge(path_inserted_discovery(&base))
295 .with_state(state)
296}
297
298fn path_inserted_discovery(base: &str) -> axum::Router<UiState> {
303 if base.is_empty() || crate::base::problem(base).is_some() {
304 return axum::Router::new();
305 }
306 axum::Router::new()
307 .route(
308 &format!("/.well-known/oauth-authorization-server{base}"),
309 get(oauth::authorization_server),
310 )
311 .route(
312 &format!("/.well-known/oauth-protected-resource{base}/mcp"),
313 get(oauth::protected_resource),
314 )
315}
316
317async fn stamp_stylesheet(mut res: Response) -> Response {
321 if let Ok(v) = HeaderValue::from_str(&ASSETS.css_hash) {
322 res.headers_mut().insert("x-koan-css", v);
323 }
324 res
325}
326
327async fn ui_asset(Path(name): Path<String>, query: RawQuery) -> Response {
328 const JS: &str = "text/javascript; charset=utf-8";
329 match name.as_str() {
330 "ui.css" => asset(UI_CSS, "text/css; charset=utf-8", query),
331 "ui.js" => asset(UI_JS, JS, query),
332 "player.js" => asset(crate::share::ENGINE_JS, JS, query),
333 "datastar.js" => asset(DATASTAR_JS, JS, query),
334 other => crate::share::binary_asset(other).unwrap_or_else(not_found),
335 }
336}
337
338fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
339 headers
340 .get_all(header::COOKIE)
341 .iter()
342 .filter_map(|v| v.to_str().ok())
343 .flat_map(|v| v.split(';'))
344 .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
345}
346
347fn is_navigation(req: &Request) -> bool {
351 req.method() == Method::GET
352 && !req.headers().contains_key(PARTIAL)
353 && !req.headers().contains_key("datastar-request")
354 && !req.uri().path().starts_with("/ui/")
355}
356
357async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
363 let user = if s.auth_enabled {
364 let vouched = session::vouched(&s, req.headers(), req.extensions());
365 if vouched == session::Vouch::Unusable {
366 return session::unusable_header(&s);
367 }
368 let claims = cookie(req.headers(), "koan_access")
369 .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok());
370 match (claims, vouched) {
371 (Some(claims), session::Vouch::Absent) => {
372 crate::auth::current_user(&s.pool, claims).await
373 }
374 (Some(claims), session::Vouch::Named(name)) if name == claims.username => {
375 crate::auth::current_user(&s.pool, claims).await
376 }
377 _ => None,
378 }
379 } else {
380 Some(AuthUser::anonymous_admin())
381 };
382 match user {
383 Some(user) => {
384 req.extensions_mut().insert(user);
385 next.run(req).await
386 }
387 None if is_navigation(&req) => {
388 let here = req
389 .uri()
390 .path_and_query()
391 .map_or("/", |p| p.as_str())
392 .to_owned();
393 let resume = if s.proxy_auth.is_some() {
394 session::PROXY_RESUME
395 } else {
396 "/auth/resume"
397 };
398 see_other(&format!("{resume}?next={}", encode(&here)))
399 }
400 None => (
401 StatusCode::UNAUTHORIZED,
402 [(header::CACHE_CONTROL, "no-store")],
403 "signed out",
404 )
405 .into_response(),
406 }
407}
408
409async fn require_datastar_on_post(req: Request, next: Next) -> Response {
413 if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
414 return StatusCode::FORBIDDEN.into_response();
415 }
416 next.run(req).await
417}
418
419fn encode(s: &str) -> String {
420 form_urlencoded::byte_serialize(s.as_bytes()).collect()
421}
422
423fn see_other(location: &str) -> Response {
424 (
425 StatusCode::SEE_OTHER,
426 [
427 (header::LOCATION, location),
428 (header::CACHE_CONTROL, "no-store"),
429 ],
430 )
431 .into_response()
432}
433
434fn html(status: StatusCode, body: impl Into<String>) -> Response {
436 let mut resp = (status, body.into()).into_response();
437 let h = resp.headers_mut();
438 h.insert(
439 header::CONTENT_TYPE,
440 HeaderValue::from_static("text/html; charset=utf-8"),
441 );
442 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
443 h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
444 h.insert(header::CONTENT_SECURITY_POLICY, PAGE_CSP_HEADER.clone());
445 h.insert(
446 header::REFERRER_POLICY,
447 HeaderValue::from_static("same-origin"),
448 );
449 h.insert(
450 header::X_CONTENT_TYPE_OPTIONS,
451 HeaderValue::from_static("nosniff"),
452 );
453 h.insert(
454 "x-robots-tag",
455 HeaderValue::from_static("noindex, nofollow"),
456 );
457 resp
458}
459
460fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
463 let mut lines = Vec::new();
464 if let Some((selector, mode)) = target {
465 lines.push(format!("selector {selector}"));
466 lines.push(format!("mode {mode}"));
467 }
468 lines.extend(html.lines().map(|l| format!("elements {l}")));
469 Event::default()
470 .event("datastar-patch-elements")
471 .data(lines.join("\n"))
472}
473
474fn events(events: Vec<Event>) -> Response {
475 let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
476 let mut resp = Sse::new(stream).into_response();
477 resp.headers_mut()
478 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
479 resp
480}
481
482fn open(pool: &Pool) -> Option<Handle<'_>> {
483 pool.get()
484 .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
485 .ok()
486}
487
488pub(crate) trait Logged<T> {
491 fn logged(self) -> Option<T>;
492}
493
494impl<T, E: std::fmt::Display> Logged<T> for Result<T, E> {
495 #[track_caller]
496 fn logged(self) -> Option<T> {
497 let at = std::panic::Location::caller();
498 self.inspect_err(|e| {
499 log::error!("web UI: a read failed at {}:{}: {e}", at.file(), at.line())
500 })
501 .ok()
502 }
503}
504
505async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
508 let path = blocking(move || {
509 let db = open(&s.pool)?;
510 let t = queries::tracks_by_ids(&db.conn, &[id]).logged()?.pop()?;
511 crate::subsonic::track_file_path(&t).map(PathBuf::from)
512 })
513 .await;
514 let Some(path) = path else {
515 return not_found();
516 };
517 match crate::subsonic::serve_local_file(&path, &headers).await {
518 Ok(mut resp) => {
519 resp.headers_mut().insert(
520 header::CACHE_CONTROL,
521 HeaderValue::from_static("private, max-age=3600"),
522 );
523 resp
524 }
525 Err(_) => not_found(),
526 }
527}
528
529#[derive(serde::Deserialize, Default)]
531#[serde(default)]
532struct CoverQuery {
533 size: Option<u32>,
534 v: Option<String>,
537}
538
539async fn cover(
542 State(s): State<UiState>,
543 Path(id): Path<i64>,
544 axum::extract::Query(q): axum::extract::Query<CoverQuery>,
545) -> Response {
546 let size = crate::covers::snap(q.size);
547 let found = blocking(move || {
548 let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).logged()?;
549 (!tracks.is_empty()).then(|| s.covers.cover(&tracks, size))
550 })
551 .await;
552 match found {
553 Some(Some(art)) => crate::share::jpeg(Some(art), q.v.is_some()),
554 Some(None) => (
558 [
559 (header::CONTENT_TYPE, "image/svg+xml"),
560 (header::CACHE_CONTROL, "private, max-age=3600"),
561 ],
562 NO_COVER_SVG,
563 )
564 .into_response(),
565 None => not_found(),
566 }
567}