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 'none'; 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
123#[allow(clippy::too_many_arguments)]
124pub(crate) fn router(
125 pool: Arc<Pool>,
126 auth: AuthRouteState,
127 auth_enabled: bool,
128 setup_wizard: bool,
129 covers: Arc<Covers>,
130 server_url: Option<String>,
131 redirect_hosts: Vec<String>,
132 proxy_auth: Option<ProxyAuth>,
133 jobs: crate::graphql::jobs::JobRegistry,
134) -> axum::Router {
135 let state = UiState {
136 pool,
137 covers,
138 options: Arc::default(),
139 auth,
140 auth_enabled,
141 setup_wizard,
142 server_url,
143 codes: oauth::Codes::default(),
144 redirect_hosts: Arc::new(redirect_hosts),
145 proxy_auth,
146 jobs,
147 };
148 let gated = axum::Router::new()
149 .route("/", get(pages::albums))
150 .route("/albums", get(pages::albums))
151 .route("/album/{id}", get(pages::album))
152 .route("/album/{id}/share", post(pages::share_album))
153 .route("/artist/{id}/share", post(pages::share_artist))
154 .route("/artists", get(pages::artists))
155 .route("/artist/{id}", get(pages::artist))
156 .route("/tracks", get(pages::tracks))
157 .route("/track/{id}/properties", get(properties::page))
158 .route("/playlists", get(pages::playlists))
159 .route("/playlist/{id}", get(pages::playlist))
160 .route("/search", get(pages::search))
161 .route("/search/results", get(pages::search_results))
162 .route("/queue", get(pages::queue))
163 .route("/library", get(pages::library))
164 .route("/library/scan", get(scan::watch).post(scan::start))
165 .route("/favourites", get(pages::favourites))
166 .route("/recent", get(pages::recent))
167 .route("/history", get(history::page))
168 .route("/favourite/{kind}/{id}", post(favourite::toggle))
169 .route("/history/forget", post(history::forget))
170 .route("/connect", get(connect::page))
171 .route("/account", get(account::page))
172 .route("/account/keys", post(account::create_key))
173 .route("/account/keys/{id}/revoke", post(account::revoke_key))
174 .route("/account/app-passwords", post(account::create_app_password))
175 .route(
176 "/account/app-passwords/{id}/revoke",
177 post(account::revoke_app_password),
178 )
179 .route("/keys", get(|| async { see_other("/account") }))
181 .route("/scrobbling", get(scrobbling::page))
182 .route(
183 "/scrobbling/lastfm/connect",
184 get(scrobbling::lastfm_connect),
185 )
186 .route(
187 "/scrobbling/lastfm/return/{state}",
188 get(scrobbling::lastfm_return),
189 )
190 .route(
191 "/scrobbling/lastfm/disconnect",
192 post(scrobbling::lastfm_disconnect),
193 )
194 .route("/scrobbling/listenbrainz", post(scrobbling::connect))
195 .route(
196 "/scrobbling/listenbrainz/disconnect",
197 post(scrobbling::disconnect),
198 )
199 .route("/users", get(users::page).post(users::create))
200 .route("/users/{id}/invite", post(users::invite))
201 .route("/users/{id}/password", post(users::set_password))
202 .route("/users/{id}/password/form", post(users::password_form))
203 .route("/users/{id}/role", post(users::set_role))
204 .route("/users/{id}/delete", post(users::delete))
205 .route("/ui/stream/{id}", get(stream))
206 .route("/ui/cover/{id}", get(cover))
207 .layer(from_fn(require_datastar_on_post))
208 .layer(from_fn_with_state(state.clone(), gate))
209 .layer(axum::middleware::map_response(stamp_stylesheet));
210 let consent = axum::Router::new()
213 .route(
214 "/oauth/authorize",
215 get(oauth::authorize).post(oauth::approve),
216 )
217 .layer(from_fn_with_state(state.clone(), gate));
218 let pairing = axum::Router::new()
221 .route("/pair", get(pair::form))
222 .route("/pair/{pair}", get(pair::confirm))
223 .route("/pair/{pair}/approve", post(pair::approve))
224 .route("/pair/{pair}/decline", post(pair::decline))
225 .layer(from_fn_with_state(state.clone(), gate));
226 let sign_in = get(session::login_form).merge(
229 post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
230 );
231 let setup = get(setup::form)
233 .merge(post(setup::create).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)));
234 axum::Router::new()
235 .merge(gated)
236 .merge(consent)
237 .merge(pairing)
238 .route(
239 "/.well-known/oauth-protected-resource",
240 get(oauth::protected_resource),
241 )
242 .route(oauth::RESOURCE_METADATA, get(oauth::protected_resource))
243 .route(
244 "/.well-known/oauth-authorization-server",
245 get(oauth::authorization_server),
246 )
247 .route(
250 "/oauth/register",
251 post(oauth::register)
252 .layer(axum::extract::DefaultBodyLimit::max(
253 oauth::MAX_REGISTRATION_BODY,
254 ))
255 .layer(from_fn_with_state(
256 Arc::new(RateLimiter::new(3600, 10)),
257 rate_limit,
258 )),
259 )
260 .route(
261 "/oauth/token",
262 post(oauth::token).layer(from_fn_with_state(
263 Arc::new(RateLimiter::new(60, 60)),
264 rate_limit,
265 )),
266 )
267 .route("/login", sign_in)
268 .route("/setup", setup)
269 .route("/auth/resume", get(session::resume))
270 .route(session::PROXY_RESUME, get(session::proxy_resume))
271 .route("/ui/renew", post(session::proxy_renew))
272 .route("/auth/renew", post(session::renew))
273 .route("/auth/signout", post(session::signout))
274 .route("/ui/assets/{name}", get(ui_asset))
275 .route(
279 "/favicon.ico",
280 get(|| ui_asset(Path("icon-192.png".into()), RawQuery(None))),
281 )
282 .route(
283 "/apple-touch-icon.png",
284 get(|| ui_asset(Path("apple-touch-icon.png".into()), RawQuery(None))),
285 )
286 .with_state(state)
287}
288
289async fn stamp_stylesheet(mut res: Response) -> Response {
293 if let Ok(v) = HeaderValue::from_str(&ASSETS.css_hash) {
294 res.headers_mut().insert("x-koan-css", v);
295 }
296 res
297}
298
299async fn ui_asset(Path(name): Path<String>, query: RawQuery) -> Response {
300 const JS: &str = "text/javascript; charset=utf-8";
301 match name.as_str() {
302 "ui.css" => asset(UI_CSS, "text/css; charset=utf-8", query),
303 "ui.js" => asset(UI_JS, JS, query),
304 "player.js" => asset(crate::share::ENGINE_JS, JS, query),
305 "datastar.js" => asset(DATASTAR_JS, JS, query),
306 other => crate::share::binary_asset(other).unwrap_or_else(not_found),
307 }
308}
309
310fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
311 headers
312 .get_all(header::COOKIE)
313 .iter()
314 .filter_map(|v| v.to_str().ok())
315 .flat_map(|v| v.split(';'))
316 .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
317}
318
319fn is_navigation(req: &Request) -> bool {
323 req.method() == Method::GET
324 && !req.headers().contains_key(PARTIAL)
325 && !req.headers().contains_key("datastar-request")
326 && !req.uri().path().starts_with("/ui/")
327}
328
329async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
335 let user = if s.auth_enabled {
336 let vouched = session::vouched(&s, req.headers(), req.extensions());
337 if vouched == session::Vouch::Unusable {
338 return session::unusable_header(&s);
339 }
340 let claims = cookie(req.headers(), "koan_access")
341 .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok());
342 match (claims, vouched) {
343 (Some(claims), session::Vouch::Absent) => {
344 crate::auth::current_user(&s.pool, claims).await
345 }
346 (Some(claims), session::Vouch::Named(name)) if name == claims.username => {
347 crate::auth::current_user(&s.pool, claims).await
348 }
349 _ => None,
350 }
351 } else {
352 Some(AuthUser::anonymous_admin())
353 };
354 match user {
355 Some(user) => {
356 req.extensions_mut().insert(user);
357 next.run(req).await
358 }
359 None if is_navigation(&req) => {
360 let here = req
361 .uri()
362 .path_and_query()
363 .map_or("/", |p| p.as_str())
364 .to_owned();
365 let resume = if s.proxy_auth.is_some() {
366 session::PROXY_RESUME
367 } else {
368 "/auth/resume"
369 };
370 see_other(&format!("{resume}?next={}", encode(&here)))
371 }
372 None => (
373 StatusCode::UNAUTHORIZED,
374 [(header::CACHE_CONTROL, "no-store")],
375 "signed out",
376 )
377 .into_response(),
378 }
379}
380
381async fn require_datastar_on_post(req: Request, next: Next) -> Response {
385 if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
386 return StatusCode::FORBIDDEN.into_response();
387 }
388 next.run(req).await
389}
390
391fn encode(s: &str) -> String {
392 form_urlencoded::byte_serialize(s.as_bytes()).collect()
393}
394
395fn see_other(location: &str) -> Response {
396 (
397 StatusCode::SEE_OTHER,
398 [
399 (header::LOCATION, location),
400 (header::CACHE_CONTROL, "no-store"),
401 ],
402 )
403 .into_response()
404}
405
406fn html(status: StatusCode, body: impl Into<String>) -> Response {
408 let mut resp = (status, body.into()).into_response();
409 let h = resp.headers_mut();
410 h.insert(
411 header::CONTENT_TYPE,
412 HeaderValue::from_static("text/html; charset=utf-8"),
413 );
414 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
415 h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
416 h.insert(header::CONTENT_SECURITY_POLICY, PAGE_CSP_HEADER.clone());
417 h.insert(
418 header::REFERRER_POLICY,
419 HeaderValue::from_static("same-origin"),
420 );
421 h.insert(
422 header::X_CONTENT_TYPE_OPTIONS,
423 HeaderValue::from_static("nosniff"),
424 );
425 h.insert(
426 "x-robots-tag",
427 HeaderValue::from_static("noindex, nofollow"),
428 );
429 resp
430}
431
432fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
435 let mut lines = Vec::new();
436 if let Some((selector, mode)) = target {
437 lines.push(format!("selector {selector}"));
438 lines.push(format!("mode {mode}"));
439 }
440 lines.extend(html.lines().map(|l| format!("elements {l}")));
441 Event::default()
442 .event("datastar-patch-elements")
443 .data(lines.join("\n"))
444}
445
446fn events(events: Vec<Event>) -> Response {
447 let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
448 let mut resp = Sse::new(stream).into_response();
449 resp.headers_mut()
450 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
451 resp
452}
453
454fn open(pool: &Pool) -> Option<Handle<'_>> {
455 pool.get()
456 .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
457 .ok()
458}
459
460pub(crate) trait Logged<T> {
463 fn logged(self) -> Option<T>;
464}
465
466impl<T, E: std::fmt::Display> Logged<T> for Result<T, E> {
467 #[track_caller]
468 fn logged(self) -> Option<T> {
469 let at = std::panic::Location::caller();
470 self.inspect_err(|e| {
471 log::error!("web UI: a read failed at {}:{}: {e}", at.file(), at.line())
472 })
473 .ok()
474 }
475}
476
477async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
480 let path = blocking(move || {
481 let db = open(&s.pool)?;
482 let t = queries::tracks_by_ids(&db.conn, &[id]).logged()?.pop()?;
483 crate::subsonic::track_file_path(&t).map(PathBuf::from)
484 })
485 .await;
486 let Some(path) = path else {
487 return not_found();
488 };
489 match crate::subsonic::serve_local_file(&path, &headers).await {
490 Ok(mut resp) => {
491 resp.headers_mut().insert(
492 header::CACHE_CONTROL,
493 HeaderValue::from_static("private, max-age=3600"),
494 );
495 resp
496 }
497 Err(_) => not_found(),
498 }
499}
500
501#[derive(serde::Deserialize, Default)]
503#[serde(default)]
504struct CoverQuery {
505 size: Option<u32>,
506 v: Option<String>,
509}
510
511async fn cover(
514 State(s): State<UiState>,
515 Path(id): Path<i64>,
516 axum::extract::Query(q): axum::extract::Query<CoverQuery>,
517) -> Response {
518 let size = crate::covers::snap(q.size);
519 let found = blocking(move || {
520 let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).logged()?;
521 (!tracks.is_empty()).then(|| s.covers.cover(&tracks, size))
522 })
523 .await;
524 match found {
525 Some(Some(art)) => crate::share::jpeg(Some(art), q.v.is_some()),
526 Some(None) => (
530 [
531 (header::CONTENT_TYPE, "image/svg+xml"),
532 (header::CACHE_CONTROL, "private, max-age=3600"),
533 ],
534 NO_COVER_SVG,
535 )
536 .into_response(),
537 None => not_found(),
538 }
539}