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