use std::fmt::Write as _;
use axum::Extension;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::response::Response;
use axum::response::sse::Event;
use koan_core::auth::Role;
use koan_core::db::queries::api_keys::{self, ApiKeyRow};
use koan_core::db::queries::app_passwords::{self, AppPasswordRow};
use super::pages::{COPY_ERROR, COPY_INPUT, COPY_ROW, EMPTY, SUB, respond};
use super::{UiState, events, open, patch};
use crate::auth::AuthUser;
use crate::share::{blocking, escape};
const MAX_NAME: usize = 100;
const REVOKE_KEY: &str = "Revoke this key? Clients using it will stop working.";
const REVOKE_APP_PASSWORD: &str = "Revoke this app password? The app using it will stop working.";
fn day(secs: i64) -> String {
chrono::DateTime::from_timestamp(secs, 0)
.map(|t| t.format("%-d %b %Y").to_string())
.unwrap_or_default()
}
const ROW: &str = "flex min-w-0 items-center gap-3 border-b border-rule px-1 py-2.5";
fn row(out: &mut String, name: &str, created: i64, used: Option<i64>, revoke: &str, confirm: &str) {
let used = used.map_or_else(
|| "never used".to_owned(),
|t| format!("last used {}", day(t)),
);
let _ = write!(
out,
"<li class=\"{ROW}\"><span class=\"min-w-0 flex-1 overflow-hidden text-ellipsis wrap-anywhere\">{name}\
<small class=\"block text-meta text-muted\">Created {created} · {used}</small></span>\
<button class=\"quiet\" data-on:click=\"confirm('{confirm}') && @post('{revoke}')\">Revoke</button></li>",
name = escape(name),
created = day(created),
);
}
fn key_list(keys: &[ApiKeyRow]) -> String {
let mut out = String::from("<ul class=\"mt-4\" id=keys>");
if keys.is_empty() {
let _ = write!(out, "<li class=\"{ROW} {EMPTY}\">No keys yet.</li>");
}
for k in keys {
let revoke = format!("/account/keys/{}/revoke", k.id);
row(
&mut out,
&k.name,
k.created_at,
k.last_used_at,
&revoke,
REVOKE_KEY,
);
}
out.push_str("</ul>");
out
}
fn app_password_list(passwords: &[AppPasswordRow]) -> String {
let mut out = String::from("<ul class=\"mt-4\" id=app-passwords>");
if passwords.is_empty() {
let _ = write!(
out,
"<li class=\"{ROW} {EMPTY}\">No app passwords yet.</li>"
);
}
for p in passwords {
let revoke = format!("/account/app-passwords/{}/revoke", p.id);
row(
&mut out,
&p.name,
p.created_at,
p.last_used_at,
&revoke,
REVOKE_APP_PASSWORD,
);
}
out.push_str("</ul>");
out
}
fn create_form(
action: &str,
signal: &str,
placeholder: &str,
label: &str,
button: &str,
result: &str,
) -> String {
format!(
"<form class=\"mb-2 flex max-w-form gap-2\" data-on:submit__prevent=\"@post('{action}')\">\
<input class=\"flex-1\" name={signal} data-bind:{signal} placeholder=\"{placeholder}\" maxlength={MAX_NAME} required \
autocomplete=off aria-label=\"{label}\"><button class=\"primary\">{button}</button></form><div id={result}></div>"
)
}
fn more(user: &AuthUser) -> String {
let users = if user.role == Role::Admin {
"<li><a href=\"/users\" data-nav=users>Users</a>: make accounts and invite people.</li>"
} else {
""
};
format!(
"<h2>More</h2><ul class=\"my-2 flex flex-col gap-1.5\">{users}\
<li><a href=\"/connect\" data-nav=connect>Assistants</a>: connect Claude or another assistant to your music.</li>\
<li><a href=\"/scrobbling\" data-nav=scrobbling>Scrobbling</a>: send what you play to ListenBrainz.</li>\
<li><a href=\"https://github.com/radiosilence/koan/releases/tag/v{v}\">kōan {v}</a></li></ul>",
v = env!("CARGO_PKG_VERSION")
)
}
pub(super) async fn page(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
) -> Response {
let inner = if !s.auth_enabled {
format!(
"<h1>Account</h1><p class=\"{EMPTY}\">This server runs without sign-in, so there are no \
accounts, keys or app passwords.</p>{}",
more(&user)
)
} else {
let st = s.clone();
let user_id = user.user_id;
let (keys, passwords) = blocking(move || {
let db = open(&st.pool)?;
Some((
api_keys::list_api_keys(&db.conn, Some(user_id)).ok()?,
app_passwords::list_app_passwords(&db.conn, user_id).ok()?,
))
})
.await
.unwrap_or_default();
format!(
"<h1>Account</h1><form class=\"{SUB} flex flex-wrap items-center gap-x-3 gap-y-1\" method=post \
action=\"/auth/signout\"><span>Signed in as <strong class=\"text-ink\">{name}</strong></span>\
<button class=\"quiet px-0\">Sign out</button></form>\
<h2>API keys</h2><p class=\"{SUB}\">A Subsonic client that supports API keys can sign in with one instead of \
your password. It acts as you, with your permissions, until you revoke it.</p>{key_form}{keys}\
<h2>App passwords</h2><p class=\"{SUB}\">For a Subsonic app that only signs in with a username and password: \
give it your username and an app password in place of yours. Make one per app. kōan keeps it encrypted, \
never shows it again, and it stops working when you revoke it or change your password.</p>\
{password_form}{passwords}{more}",
name = escape(&user.username),
key_form = create_form(
"/account/keys",
"keyname",
"Name, e.g. phone",
"Key name",
"Create key",
"key-result"
),
keys = key_list(&keys),
password_form = create_form(
"/account/app-passwords",
"appname",
"Name, e.g. Arpeggi",
"App password name",
"Create app password",
"app-password-result"
),
passwords = app_password_list(&passwords),
more = more(&user),
)
};
respond(&s, &headers, &user, "Account", &inner)
}
fn posted_name(body: &[u8], signal: &str) -> Option<String> {
let name = serde_json::from_slice::<serde_json::Value>(body)
.ok()?
.get(signal)?
.as_str()?
.trim()
.to_owned();
(!name.is_empty() && name.chars().count() <= MAX_NAME).then_some(name)
}
fn refused(result: &str, message: &str) -> Response {
events(vec![patch(
&format!("<div id={result} class=\"{COPY_ERROR}\" role=alert>{message}</div>"),
None,
)])
}
fn made(result: &str, what: &str, secret: &str, list: String, signal: &str) -> Response {
events(vec![
patch(
&format!(
"<div id={result}><p class=\"mt-3 mb-0\">Copy the {what} now: it is not shown again.</p>\
<div class=\"{COPY_ROW}\"><input id={result}-value readonly value=\"{secret}\" \
aria-label=\"New {what}\" class=\"{COPY_INPUT}\">\
<button data-on:click=\"navigator.clipboard.writeText(document.getElementById('{result}-value').value)\">Copy</button>\
</div></div>",
secret = escape(secret)
),
None,
),
patch(&list, None),
Event::default()
.event("datastar-patch-signals")
.data(format!("signals {{\"{signal}\":\"\"}}")),
])
}
const NO_ACCOUNTS: &str =
"Keys and app passwords belong to accounts, and this server runs without sign-in.";
pub(super) async fn create_key(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
body: Bytes,
) -> Response {
if !s.auth_enabled {
return refused("key-result", NO_ACCOUNTS);
}
let Some(name) = posted_name(&body, "keyname") else {
return refused("key-result", "Give the key a name of up to 100 characters.");
};
let created = blocking(move || {
let db = open(&s.pool)?;
let (_, key) = api_keys::create_api_key(&db.conn, user.user_id, &name).ok()?;
let keys = api_keys::list_api_keys(&db.conn, Some(user.user_id)).ok()?;
Some((key, keys))
})
.await;
match created {
Some((key, keys)) => made("key-result", "key", &key, key_list(&keys), "keyname"),
None => refused("key-result", "The key could not be made."),
}
}
pub(super) async fn revoke_key(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Response {
let keys = blocking(move || {
let db = open(&s.pool)?;
api_keys::revoke_api_key(&db.conn, id, Some(user.user_id)).ok()?;
api_keys::list_api_keys(&db.conn, Some(user.user_id)).ok()
})
.await
.unwrap_or_default();
events(vec![
patch(&key_list(&keys), None),
patch("<div id=key-result></div>", None),
])
}
pub(super) async fn create_app_password(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
body: Bytes,
) -> Response {
if !s.auth_enabled {
return refused("app-password-result", NO_ACCOUNTS);
}
let Some(name) = posted_name(&body, "appname") else {
return refused(
"app-password-result",
"Give the app password a name of up to 100 characters.",
);
};
let key = koan_core::auth::app_password_key(&s.auth.private_pem);
let created = blocking(move || {
let db = open(&s.pool)?;
let (_, password) =
app_passwords::create_app_password(&db.conn, &key, user.user_id, &name).ok()?;
let passwords = app_passwords::list_app_passwords(&db.conn, user.user_id).ok()?;
Some((password, passwords))
})
.await;
match created {
Some((password, passwords)) => made(
"app-password-result",
"app password",
&password,
app_password_list(&passwords),
"appname",
),
None => refused("app-password-result", "The app password could not be made."),
}
}
pub(super) async fn revoke_app_password(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Response {
let passwords = blocking(move || {
let db = open(&s.pool)?;
app_passwords::revoke_app_password(&db.conn, id, user.user_id).ok()?;
app_passwords::list_app_passwords(&db.conn, user.user_id).ok()
})
.await
.unwrap_or_default();
events(vec![
patch(&app_password_list(&passwords), None),
patch("<div id=app-password-result></div>", None),
])
}