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