use std::sync::OnceLock;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
pub(crate) const USERNAME: &str = "demo";
pub(crate) const PASSWORD: &str = "demo";
static ON: OnceLock<bool> = OnceLock::new();
pub(crate) fn enabled() -> bool {
ON.get().copied().unwrap_or(false)
}
pub(crate) fn start(conn: &rusqlite::Connection) -> Result<(), String> {
use koan_core::db::queries::auth as users;
let _ = ON.set(true);
if users::get_user_by_username(conn, USERNAME)
.map_err(|e| e.to_string())?
.is_none()
{
users::create_user(conn, USERNAME, PASSWORD, koan_core::auth::Role::User)
.map_err(|e| e.to_string())?;
log::info!("Demo server: made the account {USERNAME}");
}
Ok(())
}
const MESSAGE: &str = "This is a demo server: its account, keys and scrobbling cannot be changed.";
const REST: &[&str] = &[
"koanSetUserPassword",
"koanRevokeApiKey",
"koanRevokeAppPassword",
"koanScrobblingConnect",
"koanSetUserRole",
"koanDeleteUser",
"koanCreateUser",
];
fn refused(path: &str) -> bool {
if let Some(method) = path.strip_prefix("/rest/") {
return REST.contains(&method.trim_end_matches(".view"));
}
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
matches!(
parts.as_slice(),
["account", "keys" | "app-passwords", _, "revoke"]
| ["scrobbling", "listenbrainz"]
| ["scrobbling", "lastfm", "connect"]
| ["users", _, "password" | "role" | "delete"]
| ["users", _, "password", "form"]
)
}
pub(crate) async fn guard(request: Request, next: Next) -> Response {
let path = request.uri().path();
if !refused(path) {
return next.run(request).await;
}
if path.starts_with("/rest/") {
let json = request.uri().query().is_some_and(|q| {
form_urlencoded::parse(q.as_bytes()).any(|(k, v)| k == "f" && v == "json")
});
return crate::subsonic::not_authorized(json, MESSAGE);
}
(StatusCode::FORBIDDEN, MESSAGE).into_response()
}
#[cfg(test)]
mod tests {
use super::refused;
#[test]
fn what_could_take_the_account_from_other_visitors_is_refused() {
for path in [
"/rest/koanSetUserPassword",
"/rest/koanRevokeApiKey.view",
"/rest/koanRevokeAppPassword",
"/rest/koanScrobblingConnect",
"/rest/koanDeleteUser",
"/account/keys/3/revoke",
"/account/app-passwords/3/revoke",
"/scrobbling/listenbrainz",
"/scrobbling/lastfm/connect",
"/users/2/password",
"/users/2/password/form",
"/users/2/role",
"/users/2/delete",
] {
assert!(refused(path), "{path}");
}
}
#[test]
fn everything_else_goes_through() {
for path in [
"/rest/koanInvite",
"/rest/koanPairApprove",
"/rest/createPlaylist",
"/rest/koanRevokeKey",
"/rest/koanScrobbling",
"/account",
"/account/keys",
"/scrobbling",
"/pair/abc/approve",
"/graphql",
] {
assert!(!refused(path), "{path}");
}
}
}