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