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