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