use askama::Template;
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, Failed, respond};
use super::{UiState, events, open, patch};
use crate::auth::AuthUser;
use crate::html::{Html, Render};
use crate::share::blocking;
pub(super) 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.";
struct Credential<'a> {
id: i64,
name: &'a str,
created: String,
used: Option<String>,
}
impl<'a> Credential<'a> {
fn new(id: i64, name: &'a str, created: i64, used: Option<i64>) -> Self {
Self {
id,
name,
created: super::scrobbling::day(created),
used: used.map(super::scrobbling::day),
}
}
}
#[derive(Template)]
#[template(path = "account/credentials.html")]
struct Credentials<'a> {
id: &'a str,
rows: Vec<Credential<'a>>,
empty: &'a str,
revoke: &'a str,
confirm: &'a str,
}
fn key_list(keys: &[ApiKeyRow]) -> Html {
Credentials {
id: "keys",
rows: keys
.iter()
.map(|k| Credential::new(k.id, &k.name, k.created_at, k.last_used_at))
.collect(),
empty: "No keys yet.",
revoke: "/account/keys",
confirm: REVOKE_KEY,
}
.html()
}
fn app_password_list(passwords: &[AppPasswordRow]) -> Html {
Credentials {
id: "app-passwords",
rows: passwords
.iter()
.map(|p| Credential::new(p.id, &p.name, p.created_at, p.last_used_at))
.collect(),
empty: "No app passwords yet.",
revoke: "/account/app-passwords",
confirm: REVOKE_APP_PASSWORD,
}
.html()
}
#[derive(Template)]
#[template(path = "pages/account.html")]
struct AccountPage<'a> {
username: &'a str,
admin: bool,
credentials: Option<(Html, Html)>,
}
pub(super) async fn page(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
) -> Response {
let credentials = if s.auth_enabled {
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();
Some((key_list(&keys), app_password_list(&passwords)))
} else {
None
};
let inner = AccountPage {
username: &user.username,
admin: user.role == Role::Admin,
credentials,
}
.html();
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 {
let html = Failed {
id: result,
class: COPY_ERROR,
message,
}
.html();
events(vec![patch(&html, None)])
}
#[derive(Template)]
#[template(path = "account/made.html")]
struct Made<'a> {
result: &'a str,
what: &'a str,
secret: &'a str,
}
fn made(result: &str, what: &str, secret: &str, list: Html, signal: &str) -> Response {
events(vec![
patch(
&Made {
result,
what,
secret,
}
.html(),
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").and_then(|n| app_passwords::app_password_name(&n))
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 = match app_passwords::create_app_password(&db.conn, &key, user.user_id, &name)
{
Ok((_, password)) => password,
Err(e @ app_passwords::CreateAppPasswordError::TooMany) => {
return Some(Err(e.to_string()));
}
Err(_) => return None,
};
let passwords = app_passwords::list_app_passwords(&db.conn, user.user_id).ok()?;
Some(Ok((password, passwords)))
})
.await;
match created {
Some(Err(reason)) => refused("app-password-result", &reason),
Some(Ok((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),
])
}