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