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
53const PAGE_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self'; font-src 'self'; \
56 img-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; \
57 frame-ancestors 'none'";
58
59const PARTIAL: &str = "x-koan-partial";
61
62const UI_CSS: &str = include_str!("../../assets/ui.css");
63const UI_JS: &str = include_str!("../../assets/ui.js");
64const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
65const NO_COVER_SVG: &str = include_str!("../../assets/no-cover.svg");
66
67pub(super) struct AssetUrls {
69 pub css: String,
70 pub css_hash: String,
71 pub ui_js: String,
72 pub player_js: String,
73 pub datastar_js: String,
74}
75
76pub(super) static ASSETS: std::sync::LazyLock<AssetUrls> = std::sync::LazyLock::new(|| {
77 use crate::share::versioned;
78 AssetUrls {
79 css: versioned("/ui/assets/ui.css", UI_CSS),
80 css_hash: crate::share::content_hash(UI_CSS),
81 ui_js: versioned("/ui/assets/ui.js", UI_JS),
82 player_js: versioned("/ui/assets/player.js", crate::share::ENGINE_JS),
83 datastar_js: versioned("/ui/assets/datastar.js", DATASTAR_JS),
84 }
85});
86
87#[derive(Clone)]
88pub struct UiState {
89 pool: Arc<Pool>,
90 covers: Arc<Covers>,
91 options: Arc<std::sync::Mutex<Option<(std::time::Instant, pages::Options)>>>,
93 auth: AuthRouteState,
94 auth_enabled: bool,
95 setup_wizard: bool,
97 public_url: Option<String>,
99 codes: oauth::Codes,
101 redirect_hosts: Arc<Vec<String>>,
103 proxy_auth: Option<ProxyAuth>,
105 jobs: crate::graphql::jobs::JobRegistry,
107}
108
109#[allow(clippy::too_many_arguments)]
110pub(crate) fn router(
111 pool: Arc<Pool>,
112 auth: AuthRouteState,
113 auth_enabled: bool,
114 setup_wizard: bool,
115 covers: Arc<Covers>,
116 public_url: Option<String>,
117 redirect_hosts: Vec<String>,
118 proxy_auth: Option<ProxyAuth>,
119 jobs: crate::graphql::jobs::JobRegistry,
120) -> axum::Router {
121 let state = UiState {
122 pool,
123 covers,
124 options: Arc::default(),
125 auth,
126 auth_enabled,
127 setup_wizard,
128 public_url,
129 codes: oauth::Codes::default(),
130 redirect_hosts: Arc::new(redirect_hosts),
131 proxy_auth,
132 jobs,
133 };
134 let gated = axum::Router::new()
135 .route("/", get(pages::albums))
136 .route("/albums", get(pages::albums))
137 .route("/album/{id}", get(pages::album))
138 .route("/album/{id}/share", post(pages::share_album))
139 .route("/artist/{id}/share", post(pages::share_artist))
140 .route("/artists", get(pages::artists))
141 .route("/artist/{id}", get(pages::artist))
142 .route("/tracks", get(pages::tracks))
143 .route("/track/{id}/properties", get(properties::page))
144 .route("/playlists", get(pages::playlists))
145 .route("/playlist/{id}", get(pages::playlist))
146 .route("/search", get(pages::search))
147 .route("/search/results", get(pages::search_results))
148 .route("/queue", get(pages::queue))
149 .route("/library", get(pages::library))
150 .route("/library/scan", get(scan::watch).post(scan::start))
151 .route("/favourites", get(pages::favourites))
152 .route("/recent", get(pages::recent))
153 .route("/history", get(history::page))
154 .route("/favourite/{kind}/{id}", post(favourite::toggle))
155 .route("/history/forget", post(history::forget))
156 .route("/connect", get(connect::page))
157 .route("/account", get(account::page))
158 .route("/account/keys", post(account::create_key))
159 .route("/account/keys/{id}/revoke", post(account::revoke_key))
160 .route("/account/app-passwords", post(account::create_app_password))
161 .route(
162 "/account/app-passwords/{id}/revoke",
163 post(account::revoke_app_password),
164 )
165 .route("/keys", get(|| async { see_other("/account") }))
167 .route("/scrobbling", get(scrobbling::page))
168 .route(
169 "/scrobbling/lastfm/connect",
170 get(scrobbling::lastfm_connect),
171 )
172 .route(
173 "/scrobbling/lastfm/return/{state}",
174 get(scrobbling::lastfm_return),
175 )
176 .route(
177 "/scrobbling/lastfm/disconnect",
178 post(scrobbling::lastfm_disconnect),
179 )
180 .route("/scrobbling/listenbrainz", post(scrobbling::connect))
181 .route(
182 "/scrobbling/listenbrainz/disconnect",
183 post(scrobbling::disconnect),
184 )
185 .route("/users", get(users::page).post(users::create))
186 .route("/users/{id}/invite", post(users::invite))
187 .route("/users/{id}/password", post(users::set_password))
188 .route("/users/{id}/password/form", post(users::password_form))
189 .route("/users/{id}/role", post(users::set_role))
190 .route("/users/{id}/delete", post(users::delete))
191 .route("/ui/stream/{id}", get(stream))
192 .route("/ui/cover/{id}", get(cover))
193 .layer(from_fn(require_datastar_on_post))
194 .layer(from_fn_with_state(state.clone(), gate))
195 .layer(axum::middleware::map_response(stamp_stylesheet));
196 let consent = axum::Router::new()
199 .route(
200 "/oauth/authorize",
201 get(oauth::authorize).post(oauth::approve),
202 )
203 .layer(from_fn_with_state(state.clone(), gate));
204 let pairing = axum::Router::new()
207 .route("/pair", get(pair::form))
208 .route("/pair/{pair}", get(pair::confirm))
209 .route("/pair/{pair}/approve", post(pair::approve))
210 .route("/pair/{pair}/decline", post(pair::decline))
211 .layer(from_fn_with_state(state.clone(), gate));
212 let sign_in = get(session::login_form).merge(
215 post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
216 );
217 let setup = get(setup::form)
219 .merge(post(setup::create).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)));
220 axum::Router::new()
221 .merge(gated)
222 .merge(consent)
223 .merge(pairing)
224 .route(
225 "/.well-known/oauth-protected-resource",
226 get(oauth::protected_resource),
227 )
228 .route(oauth::RESOURCE_METADATA, get(oauth::protected_resource))
229 .route(
230 "/.well-known/oauth-authorization-server",
231 get(oauth::authorization_server),
232 )
233 .route(
236 "/oauth/register",
237 post(oauth::register)
238 .layer(axum::extract::DefaultBodyLimit::max(
239 oauth::MAX_REGISTRATION_BODY,
240 ))
241 .layer(from_fn_with_state(
242 Arc::new(RateLimiter::new(3600, 10)),
243 rate_limit,
244 )),
245 )
246 .route(
247 "/oauth/token",
248 post(oauth::token).layer(from_fn_with_state(
249 Arc::new(RateLimiter::new(60, 60)),
250 rate_limit,
251 )),
252 )
253 .route("/login", sign_in)
254 .route("/setup", setup)
255 .route("/auth/resume", get(session::resume))
256 .route(session::PROXY_RESUME, get(session::proxy_resume))
257 .route("/ui/renew", post(session::proxy_renew))
258 .route("/auth/renew", post(session::renew))
259 .route("/auth/signout", post(session::signout))
260 .route("/ui/assets/{name}", get(ui_asset))
261 .route(
265 "/favicon.ico",
266 get(|| ui_asset(Path("icon-192.png".into()), RawQuery(None))),
267 )
268 .route(
269 "/apple-touch-icon.png",
270 get(|| ui_asset(Path("apple-touch-icon.png".into()), RawQuery(None))),
271 )
272 .with_state(state)
273}
274
275async fn stamp_stylesheet(mut res: Response) -> Response {
279 if let Ok(v) = HeaderValue::from_str(&ASSETS.css_hash) {
280 res.headers_mut().insert("x-koan-css", v);
281 }
282 res
283}
284
285async fn ui_asset(Path(name): Path<String>, query: RawQuery) -> Response {
286 const JS: &str = "text/javascript; charset=utf-8";
287 match name.as_str() {
288 "ui.css" => asset(UI_CSS, "text/css; charset=utf-8", query),
289 "ui.js" => asset(UI_JS, JS, query),
290 "player.js" => asset(crate::share::ENGINE_JS, JS, query),
291 "datastar.js" => asset(DATASTAR_JS, JS, query),
292 other => crate::share::binary_asset(other).unwrap_or_else(not_found),
293 }
294}
295
296fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
297 headers
298 .get_all(header::COOKIE)
299 .iter()
300 .filter_map(|v| v.to_str().ok())
301 .flat_map(|v| v.split(';'))
302 .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
303}
304
305fn is_navigation(req: &Request) -> bool {
309 req.method() == Method::GET
310 && !req.headers().contains_key(PARTIAL)
311 && !req.headers().contains_key("datastar-request")
312 && !req.uri().path().starts_with("/ui/")
313}
314
315async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
321 let user = if s.auth_enabled {
322 let vouched = session::vouched(&s, req.headers(), req.extensions());
323 if vouched == session::Vouch::Unusable {
324 return session::unusable_header(&s);
325 }
326 let claims = cookie(req.headers(), "koan_access")
327 .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok());
328 match (claims, vouched) {
329 (Some(claims), session::Vouch::Absent) => {
330 crate::auth::current_user(&s.pool, claims).await
331 }
332 (Some(claims), session::Vouch::Named(name)) if name == claims.username => {
333 crate::auth::current_user(&s.pool, claims).await
334 }
335 _ => None,
336 }
337 } else {
338 Some(AuthUser::anonymous_admin())
339 };
340 match user {
341 Some(user) => {
342 req.extensions_mut().insert(user);
343 next.run(req).await
344 }
345 None if is_navigation(&req) => {
346 let here = req
347 .uri()
348 .path_and_query()
349 .map_or("/", |p| p.as_str())
350 .to_owned();
351 let resume = if s.proxy_auth.is_some() {
352 session::PROXY_RESUME
353 } else {
354 "/auth/resume"
355 };
356 see_other(&format!("{resume}?next={}", encode(&here)))
357 }
358 None => (
359 StatusCode::UNAUTHORIZED,
360 [(header::CACHE_CONTROL, "no-store")],
361 "signed out",
362 )
363 .into_response(),
364 }
365}
366
367async fn require_datastar_on_post(req: Request, next: Next) -> Response {
371 if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
372 return StatusCode::FORBIDDEN.into_response();
373 }
374 next.run(req).await
375}
376
377fn encode(s: &str) -> String {
378 form_urlencoded::byte_serialize(s.as_bytes()).collect()
379}
380
381fn see_other(location: &str) -> Response {
382 (
383 StatusCode::SEE_OTHER,
384 [
385 (header::LOCATION, location),
386 (header::CACHE_CONTROL, "no-store"),
387 ],
388 )
389 .into_response()
390}
391
392fn html(status: StatusCode, body: String) -> Response {
394 let mut resp = (status, body).into_response();
395 let h = resp.headers_mut();
396 h.insert(
397 header::CONTENT_TYPE,
398 HeaderValue::from_static("text/html; charset=utf-8"),
399 );
400 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
401 h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
402 h.insert(
403 header::CONTENT_SECURITY_POLICY,
404 HeaderValue::from_static(PAGE_CSP),
405 );
406 h.insert(
407 header::REFERRER_POLICY,
408 HeaderValue::from_static("same-origin"),
409 );
410 h.insert(
411 header::X_CONTENT_TYPE_OPTIONS,
412 HeaderValue::from_static("nosniff"),
413 );
414 h.insert(
415 "x-robots-tag",
416 HeaderValue::from_static("noindex, nofollow"),
417 );
418 resp
419}
420
421fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
424 let mut lines = Vec::new();
425 if let Some((selector, mode)) = target {
426 lines.push(format!("selector {selector}"));
427 lines.push(format!("mode {mode}"));
428 }
429 lines.extend(html.lines().map(|l| format!("elements {l}")));
430 Event::default()
431 .event("datastar-patch-elements")
432 .data(lines.join("\n"))
433}
434
435fn events(events: Vec<Event>) -> Response {
436 let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
437 let mut resp = Sse::new(stream).into_response();
438 resp.headers_mut()
439 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
440 resp
441}
442
443fn open(pool: &Pool) -> Option<Handle<'_>> {
444 pool.get()
445 .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
446 .ok()
447}
448
449async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
452 let path = blocking(move || {
453 let db = open(&s.pool)?;
454 let t = queries::tracks_by_ids(&db.conn, &[id]).ok()?.pop()?;
455 crate::subsonic::track_file_path(&t).map(PathBuf::from)
456 })
457 .await;
458 let Some(path) = path else {
459 return not_found();
460 };
461 match crate::subsonic::serve_local_file(&path, &headers).await {
462 Ok(mut resp) => {
463 resp.headers_mut().insert(
464 header::CACHE_CONTROL,
465 HeaderValue::from_static("private, max-age=3600"),
466 );
467 resp
468 }
469 Err(_) => not_found(),
470 }
471}
472
473#[derive(serde::Deserialize, Default)]
475#[serde(default)]
476struct CoverQuery {
477 size: Option<u32>,
478 v: Option<String>,
481}
482
483async fn cover(
486 State(s): State<UiState>,
487 Path(id): Path<i64>,
488 axum::extract::Query(q): axum::extract::Query<CoverQuery>,
489) -> Response {
490 let size = crate::covers::snap(q.size);
491 let found = blocking(move || {
492 let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).ok()?;
493 (!tracks.is_empty()).then(|| s.covers.cover(&tracks, size))
494 })
495 .await;
496 match found {
497 Some(Some(art)) => crate::share::jpeg(Some(art), q.v.is_some()),
498 Some(None) => (
502 [
503 (header::CONTENT_TYPE, "image/svg+xml"),
504 (header::CACHE_CONTROL, "private, max-age=3600"),
505 ],
506 NO_COVER_SVG,
507 )
508 .into_response(),
509 None => not_found(),
510 }
511}