1mod browse;
16mod connect;
17mod keys;
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, 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");
57
58#[derive(Clone)]
59pub struct UiState {
60 pool: Arc<Pool>,
61 covers: Arc<Covers>,
62 options: Arc<std::sync::Mutex<Option<(std::time::Instant, pages::Options)>>>,
64 auth: AuthRouteState,
65 auth_enabled: bool,
66 public_url: Option<String>,
68 codes: oauth::Codes,
70 redirect_hosts: Arc<Vec<String>>,
72}
73
74pub fn router(
75 pool: Arc<Pool>,
76 auth: AuthRouteState,
77 auth_enabled: bool,
78 covers: Arc<Covers>,
79 public_url: Option<String>,
80 redirect_hosts: Vec<String>,
81) -> axum::Router {
82 let state = UiState {
83 pool,
84 covers,
85 options: Arc::default(),
86 auth,
87 auth_enabled,
88 public_url,
89 codes: oauth::Codes::default(),
90 redirect_hosts: Arc::new(redirect_hosts),
91 };
92 let gated = axum::Router::new()
93 .route("/", get(pages::albums))
94 .route("/albums", get(pages::albums))
95 .route("/albums/more", get(pages::albums_more))
96 .route("/album/{id}", get(pages::album))
97 .route("/album/{id}/share", post(pages::share_album))
98 .route("/artist/{id}/share", post(pages::share_artist))
99 .route("/artists", get(pages::artists))
100 .route("/artists/more", get(pages::artists_more))
101 .route("/artist/{id}", get(pages::artist))
102 .route("/playlists", get(pages::playlists))
103 .route("/playlist/{id}", get(pages::playlist))
104 .route("/search", get(pages::search))
105 .route("/search/results", get(pages::search_results))
106 .route("/queue", get(pages::queue))
107 .route("/connect", get(connect::page))
108 .route("/keys", get(keys::page).post(keys::create))
109 .route("/keys/{id}/revoke", post(keys::revoke))
110 .route("/users", get(users::page).post(users::create))
111 .route("/users/{id}/invite", post(users::invite))
112 .route("/users/{id}/role", post(users::set_role))
113 .route("/users/{id}/delete", post(users::delete))
114 .route("/ui/stream/{id}", get(stream))
115 .route("/ui/cover/{id}", get(cover))
116 .layer(from_fn(require_datastar_on_post))
117 .layer(from_fn_with_state(state.clone(), gate));
118 let consent = axum::Router::new()
121 .route(
122 "/oauth/authorize",
123 get(oauth::authorize).post(oauth::approve),
124 )
125 .layer(from_fn_with_state(state.clone(), gate));
126 let sign_in = get(session::login_form).merge(
129 post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
130 );
131 axum::Router::new()
132 .merge(gated)
133 .merge(consent)
134 .route(
135 "/.well-known/oauth-protected-resource",
136 get(oauth::protected_resource),
137 )
138 .route(oauth::RESOURCE_METADATA, get(oauth::protected_resource))
139 .route(
140 "/.well-known/oauth-authorization-server",
141 get(oauth::authorization_server),
142 )
143 .route(
146 "/oauth/register",
147 post(oauth::register)
148 .layer(axum::extract::DefaultBodyLimit::max(
149 oauth::MAX_REGISTRATION_BODY,
150 ))
151 .layer(from_fn_with_state(
152 Arc::new(RateLimiter::new(3600, 10)),
153 rate_limit,
154 )),
155 )
156 .route(
157 "/oauth/token",
158 post(oauth::token).layer(from_fn_with_state(
159 Arc::new(RateLimiter::new(60, 60)),
160 rate_limit,
161 )),
162 )
163 .route("/login", sign_in)
164 .route("/auth/resume", get(session::resume))
165 .route("/auth/renew", post(session::renew))
166 .route("/auth/signout", post(session::signout))
167 .route("/ui/assets/{name}", get(ui_asset))
168 .route(
172 "/favicon.ico",
173 get(|| ui_asset(Path("icon-192.png".into()))),
174 )
175 .route(
176 "/apple-touch-icon.png",
177 get(|| ui_asset(Path("apple-touch-icon.png".into()))),
178 )
179 .with_state(state)
180}
181
182async fn ui_asset(Path(name): Path<String>) -> Response {
183 const JS: &str = "text/javascript; charset=utf-8";
184 match name.as_str() {
185 "ui.css" => asset(UI_CSS, "text/css; charset=utf-8"),
186 "ui.js" => asset(UI_JS, JS),
187 "player.js" => asset(crate::share::ENGINE_JS, JS),
188 "datastar.js" => asset(DATASTAR_JS, JS),
189 other => crate::share::binary_asset(other).unwrap_or_else(not_found),
190 }
191}
192
193fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
194 headers
195 .get_all(header::COOKIE)
196 .iter()
197 .filter_map(|v| v.to_str().ok())
198 .flat_map(|v| v.split(';'))
199 .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
200}
201
202fn is_navigation(req: &Request) -> bool {
206 req.method() == Method::GET
207 && !req.headers().contains_key(PARTIAL)
208 && !req.headers().contains_key("datastar-request")
209 && !req.uri().path().starts_with("/ui/")
210}
211
212async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
215 let user = if s.auth_enabled {
216 match cookie(req.headers(), "koan_access")
217 .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok())
218 {
219 Some(claims) => crate::auth::current_user(&s.pool, claims).await,
220 None => None,
221 }
222 } else {
223 Some(AuthUser::anonymous_admin())
224 };
225 match user {
226 Some(user) => {
227 req.extensions_mut().insert(user);
228 next.run(req).await
229 }
230 None if is_navigation(&req) => {
231 let here = req
232 .uri()
233 .path_and_query()
234 .map_or("/", |p| p.as_str())
235 .to_owned();
236 see_other(&format!("/auth/resume?next={}", encode(&here)))
237 }
238 None => (
239 StatusCode::UNAUTHORIZED,
240 [(header::CACHE_CONTROL, "no-store")],
241 "signed out",
242 )
243 .into_response(),
244 }
245}
246
247async fn require_datastar_on_post(req: Request, next: Next) -> Response {
251 if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
252 return StatusCode::FORBIDDEN.into_response();
253 }
254 next.run(req).await
255}
256
257fn encode(s: &str) -> String {
258 form_urlencoded::byte_serialize(s.as_bytes()).collect()
259}
260
261fn see_other(location: &str) -> Response {
262 (
263 StatusCode::SEE_OTHER,
264 [
265 (header::LOCATION, location),
266 (header::CACHE_CONTROL, "no-store"),
267 ],
268 )
269 .into_response()
270}
271
272fn html(status: StatusCode, body: String) -> Response {
274 let mut resp = (status, body).into_response();
275 let h = resp.headers_mut();
276 h.insert(
277 header::CONTENT_TYPE,
278 HeaderValue::from_static("text/html; charset=utf-8"),
279 );
280 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
281 h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
282 h.insert(
283 header::CONTENT_SECURITY_POLICY,
284 HeaderValue::from_static(PAGE_CSP),
285 );
286 h.insert(
287 header::REFERRER_POLICY,
288 HeaderValue::from_static("same-origin"),
289 );
290 h.insert(
291 header::X_CONTENT_TYPE_OPTIONS,
292 HeaderValue::from_static("nosniff"),
293 );
294 h.insert(
295 "x-robots-tag",
296 HeaderValue::from_static("noindex, nofollow"),
297 );
298 resp
299}
300
301fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
304 let mut lines = Vec::new();
305 if let Some((selector, mode)) = target {
306 lines.push(format!("selector {selector}"));
307 lines.push(format!("mode {mode}"));
308 }
309 lines.extend(html.lines().map(|l| format!("elements {l}")));
310 Event::default()
311 .event("datastar-patch-elements")
312 .data(lines.join("\n"))
313}
314
315fn events(events: Vec<Event>) -> Response {
316 let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
317 let mut resp = Sse::new(stream).into_response();
318 resp.headers_mut()
319 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
320 resp
321}
322
323fn open(pool: &Pool) -> Option<Handle<'_>> {
324 pool.get()
325 .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
326 .ok()
327}
328
329async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
332 let path = blocking(move || {
333 let db = open(&s.pool)?;
334 let t = queries::tracks_by_ids(&db.conn, &[id]).ok()?.pop()?;
335 crate::subsonic::track_file_path(&t).map(PathBuf::from)
336 })
337 .await;
338 let Some(path) = path else {
339 return not_found();
340 };
341 match crate::subsonic::serve_local_file(&path, &headers).await {
342 Ok(mut resp) => {
343 resp.headers_mut().insert(
344 header::CACHE_CONTROL,
345 HeaderValue::from_static("private, max-age=3600"),
346 );
347 resp
348 }
349 Err(_) => not_found(),
350 }
351}
352
353#[derive(serde::Deserialize, Default)]
355#[serde(default)]
356struct CoverQuery {
357 size: Option<u32>,
358 v: Option<String>,
361}
362
363async fn cover(
366 State(s): State<UiState>,
367 Path(id): Path<i64>,
368 axum::extract::Query(q): axum::extract::Query<CoverQuery>,
369) -> Response {
370 let size = crate::covers::snap(q.size);
371 let art = blocking(move || {
372 let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).ok()?;
373 s.covers.cover(&tracks, size)
374 })
375 .await;
376 crate::share::jpeg(art, q.v.is_some())
377}