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