1mod browse;
16mod keys;
17mod pages;
18mod session;
19#[cfg(test)]
20mod tests;
21
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use axum::extract::{Path, Request, State};
26use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
27use axum::middleware::{Next, from_fn, from_fn_with_state};
28use axum::response::sse::{Event, Sse};
29use axum::response::{IntoResponse, Response};
30use axum::routing::{get, post};
31use koan_core::auth::{self, Role};
32use koan_core::db::pool::{Handle, Pool};
33use koan_core::db::queries;
34
35use crate::auth::AuthUser;
36use crate::auth::routes::{AuthRouteState, login_rate_limit};
37use crate::covers::Covers;
38use crate::share::{asset, blocking, not_found};
39
40const PAGE_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-eval'; style-src 'self'; \
43 img-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; \
44 frame-ancestors 'none'";
45
46const PARTIAL: &str = "x-koan-partial";
48
49const UI_CSS: &str = include_str!("../../assets/ui.css");
50const UI_JS: &str = include_str!("../../assets/ui.js");
51const DATASTAR_JS: &str = include_str!("../../assets/datastar.js");
52
53#[derive(Clone)]
54pub struct UiState {
55 pool: Arc<Pool>,
56 covers: Arc<Covers>,
57 options: Arc<std::sync::Mutex<Option<(std::time::Instant, pages::Options)>>>,
59 auth: AuthRouteState,
60 auth_enabled: bool,
61}
62
63pub fn router(
64 db_path: PathBuf,
65 auth: AuthRouteState,
66 auth_enabled: bool,
67 covers: Arc<Covers>,
68) -> axum::Router {
69 let state = UiState {
70 pool: Arc::new(Pool::new(db_path)),
71 covers,
72 options: Arc::default(),
73 auth,
74 auth_enabled,
75 };
76 let gated = axum::Router::new()
77 .route("/", get(pages::albums))
78 .route("/albums", get(pages::albums))
79 .route("/albums/more", get(pages::albums_more))
80 .route("/album/{id}", get(pages::album))
81 .route("/album/{id}/share", post(pages::share_album))
82 .route("/artist/{id}/share", post(pages::share_artist))
83 .route("/artists", get(pages::artists))
84 .route("/artists/more", get(pages::artists_more))
85 .route("/artist/{id}", get(pages::artist))
86 .route("/search", get(pages::search))
87 .route("/search/results", get(pages::search_results))
88 .route("/queue", get(pages::queue))
89 .route("/keys", get(keys::page).post(keys::create))
90 .route("/keys/{id}/revoke", post(keys::revoke))
91 .route("/ui/stream/{id}", get(stream))
92 .route("/ui/cover/{id}", get(cover))
93 .layer(from_fn(require_datastar_on_post))
94 .layer(from_fn_with_state(state.clone(), gate));
95 let sign_in = get(session::login_form).merge(
98 post(session::login).layer(from_fn_with_state(state.auth.clone(), login_rate_limit)),
99 );
100 axum::Router::new()
101 .merge(gated)
102 .route("/login", sign_in)
103 .route("/auth/resume", get(session::resume))
104 .route("/auth/renew", post(session::renew))
105 .route("/auth/signout", post(session::signout))
106 .route("/ui/assets/{name}", get(ui_asset))
107 .with_state(state)
108}
109
110async fn ui_asset(Path(name): Path<String>) -> Response {
111 const JS: &str = "text/javascript; charset=utf-8";
112 match name.as_str() {
113 "ui.css" => asset(UI_CSS, "text/css; charset=utf-8"),
114 "ui.js" => asset(UI_JS, JS),
115 "player.js" => asset(crate::share::ENGINE_JS, JS),
116 "datastar.js" => asset(DATASTAR_JS, JS),
117 _ => not_found(),
118 }
119}
120
121fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
122 headers
123 .get_all(header::COOKIE)
124 .iter()
125 .filter_map(|v| v.to_str().ok())
126 .flat_map(|v| v.split(';'))
127 .find_map(|c| c.trim().strip_prefix(name)?.strip_prefix('='))
128}
129
130fn is_navigation(req: &Request) -> bool {
134 req.method() == Method::GET
135 && !req.headers().contains_key(PARTIAL)
136 && !req.headers().contains_key("datastar-request")
137 && !req.uri().path().starts_with("/ui/")
138}
139
140async fn gate(State(s): State<UiState>, mut req: Request, next: Next) -> Response {
143 let user = if s.auth_enabled {
144 cookie(req.headers(), "koan_access")
145 .and_then(|t| auth::validate_access_token(&s.auth.public_pem, t).ok())
146 .map(|c| AuthUser {
147 user_id: c.sub,
148 role: c.role.parse().unwrap_or(Role::Readonly),
149 username: c.username,
150 })
151 } else {
152 Some(AuthUser::anonymous_admin())
153 };
154 match user {
155 Some(user) => {
156 req.extensions_mut().insert(user);
157 next.run(req).await
158 }
159 None if is_navigation(&req) => {
160 let here = req
161 .uri()
162 .path_and_query()
163 .map_or("/", |p| p.as_str())
164 .to_owned();
165 see_other(&format!("/auth/resume?next={}", encode(&here)))
166 }
167 None => (
168 StatusCode::UNAUTHORIZED,
169 [(header::CACHE_CONTROL, "no-store")],
170 "signed out",
171 )
172 .into_response(),
173 }
174}
175
176async fn require_datastar_on_post(req: Request, next: Next) -> Response {
180 if req.method() == Method::POST && !req.headers().contains_key("datastar-request") {
181 return StatusCode::FORBIDDEN.into_response();
182 }
183 next.run(req).await
184}
185
186fn encode(s: &str) -> String {
187 form_urlencoded::byte_serialize(s.as_bytes()).collect()
188}
189
190fn see_other(location: &str) -> Response {
191 (
192 StatusCode::SEE_OTHER,
193 [
194 (header::LOCATION, location),
195 (header::CACHE_CONTROL, "no-store"),
196 ],
197 )
198 .into_response()
199}
200
201fn html(status: StatusCode, body: String) -> Response {
203 let mut resp = (status, body).into_response();
204 let h = resp.headers_mut();
205 h.insert(
206 header::CONTENT_TYPE,
207 HeaderValue::from_static("text/html; charset=utf-8"),
208 );
209 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
210 h.insert(header::VARY, HeaderValue::from_static("x-koan-partial"));
211 h.insert(
212 header::CONTENT_SECURITY_POLICY,
213 HeaderValue::from_static(PAGE_CSP),
214 );
215 h.insert(
216 header::REFERRER_POLICY,
217 HeaderValue::from_static("same-origin"),
218 );
219 h.insert(
220 header::X_CONTENT_TYPE_OPTIONS,
221 HeaderValue::from_static("nosniff"),
222 );
223 h.insert(
224 "x-robots-tag",
225 HeaderValue::from_static("noindex, nofollow"),
226 );
227 resp
228}
229
230fn patch(html: &str, target: Option<(&str, &str)>) -> Event {
233 let mut lines = Vec::new();
234 if let Some((selector, mode)) = target {
235 lines.push(format!("selector {selector}"));
236 lines.push(format!("mode {mode}"));
237 }
238 lines.extend(html.lines().map(|l| format!("elements {l}")));
239 Event::default()
240 .event("datastar-patch-elements")
241 .data(lines.join("\n"))
242}
243
244fn events(events: Vec<Event>) -> Response {
245 let stream = tokio_stream::iter(events.into_iter().map(Ok::<_, std::convert::Infallible>));
246 let mut resp = Sse::new(stream).into_response();
247 resp.headers_mut()
248 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
249 resp
250}
251
252fn open(pool: &Pool) -> Option<Handle<'_>> {
253 pool.get()
254 .inspect_err(|e| log::error!("web UI: cannot open the database: {e}"))
255 .ok()
256}
257
258async fn stream(State(s): State<UiState>, Path(id): Path<i64>, headers: HeaderMap) -> Response {
261 let path = blocking(move || {
262 let db = open(&s.pool)?;
263 let t = queries::tracks_by_ids(&db.conn, &[id]).ok()?.pop()?;
264 crate::subsonic::track_file_path(&t).map(PathBuf::from)
265 })
266 .await;
267 let Some(path) = path else {
268 return not_found();
269 };
270 match crate::subsonic::serve_local_file(&path, &headers).await {
271 Ok(mut resp) => {
272 resp.headers_mut().insert(
273 header::CACHE_CONTROL,
274 HeaderValue::from_static("private, max-age=3600"),
275 );
276 resp
277 }
278 Err(_) => not_found(),
279 }
280}
281
282#[derive(serde::Deserialize, Default)]
284#[serde(default)]
285struct CoverQuery {
286 size: Option<u32>,
287 v: Option<String>,
290}
291
292async fn cover(
295 State(s): State<UiState>,
296 Path(id): Path<i64>,
297 axum::extract::Query(q): axum::extract::Query<CoverQuery>,
298) -> Response {
299 let size = crate::covers::snap(q.size);
300 let art = blocking(move || {
301 let tracks = queries::tracks_for_album(&open(&s.pool)?.conn, id).ok()?;
302 s.covers.cover(&tracks, size)
303 })
304 .await;
305 crate::share::jpeg(art, q.v.is_some())
306}