From 796b2cafc798a7faa80a007002831a4c40635fe8 Mon Sep 17 00:00:00 2001 From: mia Date: Tue, 16 Apr 2024 19:05:41 -0700 Subject: initial commit --- src/ipc.rs | 83 ++++++++++++ src/main.rs | 12 ++ src/server/admin.rs | 93 +++++++++++++ src/server/config.rs | 29 ++++ src/server/login.rs | 208 +++++++++++++++++++++++++++++ src/server/mod.rs | 160 ++++++++++++++++++++++ src/server/nginx_check.rs | 41 ++++++ src/server/panel.rs | 30 +++++ src/server/store.rs | 330 ++++++++++++++++++++++++++++++++++++++++++++++ src/tui/accounts.rs | 169 ++++++++++++++++++++++++ src/tui/mod.rs | 159 ++++++++++++++++++++++ 11 files changed, 1314 insertions(+) create mode 100644 src/ipc.rs create mode 100644 src/main.rs create mode 100644 src/server/admin.rs create mode 100644 src/server/config.rs create mode 100644 src/server/login.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/nginx_check.rs create mode 100644 src/server/panel.rs create mode 100644 src/server/store.rs create mode 100644 src/tui/accounts.rs create mode 100644 src/tui/mod.rs (limited to 'src') diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..f3e7d0a --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,83 @@ +use std::fmt::{Display, Formatter}; + +use bincode::{Decode, Encode}; +use paste::paste; + +pub trait RequestDefinition: Encode { + type Response: ResponseDefinition; + fn into_request(self) -> IPCRequest; +} + +pub trait ResponseDefinition: Sized + Decode { + type Error: std::error::Error; + fn from_response(response: IPCResponse) -> Option>; + fn into_response(self) -> IPCResponse; +} + +macro_rules! define { + ($(@$name:ident { $($req_n:ident: $req_t:ty),* } => { $($resp_n:ident: $resp_t:ty)* } | $($error:ident),*)*) => { + $( + paste! { + #[derive(Encode, Decode)] + pub struct [<$name Request>] { $(pub $req_n: $req_t),* } + impl RequestDefinition for [<$name Request>] { + type Response = [<$name Response>]; + fn into_request(self) -> IPCRequest { + IPCRequest::$name(self) + } + } + + #[derive(Encode, Decode)] + pub struct [<$name Response>] { $(pub $resp_n: $resp_t),* } + impl ResponseDefinition for [<$name Response>] { + type Error = [<$name Error>]; + fn from_response(response: IPCResponse) -> Option> { + match response { + IPCResponse::$name(inner) => Some(inner), + _ => None, + } + } + fn into_response(self) -> IPCResponse { + IPCResponse::$name(Ok(self)) + } + } + + #[derive(Encode, Decode, Debug)] + pub enum [<$name Error>] { + $($error,)* + } + + impl Display for [<$name Error>] { + #[allow(unused)] + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match *self { + $(Self::$error => f.write_str(stringify!($error)),)* + } + } + } + + impl std::error::Error for [<$name Error>] {} + } + )* + #[derive(Encode, Decode)] + pub enum IPCRequest { + $( + $name(paste!([<$name Request>])), + )* + } + #[derive(Encode, Decode)] + pub enum IPCResponse { + $( + $name(Result]), paste!([<$name Error>])>), + )* + } + }; +} + +define! { + @ListAccounts {} => { names: Vec } | + @GetAccount { name: String } => { scopes: Vec } | NotFound + @DeleteAccount { name: String } => {} | + @CreateInvite {} => { link: String } | + @UpdateScopes { account: String, scopes: Vec } => {} | NotFound +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..936f37c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,12 @@ +mod ipc; +mod server; +mod tui; + +#[tokio::main] +async fn main() -> eyre::Result<()> { + match std::env::args().skip(1).next().as_deref() { + Some("serve") | None => server::serve().await, + Some("ctl") => tui::run(), + _ => Err(eyre::eyre!("unknown command")), + } +} diff --git a/src/server/admin.rs b/src/server/admin.rs new file mode 100644 index 0000000..54bdf12 --- /dev/null +++ b/src/server/admin.rs @@ -0,0 +1,93 @@ +use std::{future::Future, path::Path}; + +use tap::Pipe; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{UnixListener, UnixStream}, +}; + +use crate::ipc::*; + +use super::store::Store; + +pub fn serve( + bind: &Path, + web_base: String, + store: Store, +) -> eyre::Result>> { + if bind.exists() { + std::fs::remove_file(bind)?; + } + let listener = UnixListener::bind(bind)?; + + Ok(async move { + loop { + match listener.accept().await { + Ok((stream, _)) => tokio::spawn({ + let store = store.clone(); + let web_base = web_base.clone(); + async move { + if let Err(err) = handle(stream, store, &web_base).await { + eprintln!("error handling admin connection: {err:?}"); + } + } + }), + Err(err) => { + eprintln!("error accepting admin connection: {err:?}"); + continue; + } + }; + } + }) +} + +async fn handle(mut stream: UnixStream, store: Store, web_base: &str) -> eyre::Result<()> { + while let Ok(request_length) = stream.read_u16().await { + let mut request_buffer = vec![0u8; request_length as usize]; + stream.read_exact(&mut request_buffer).await?; + let (request, _): (IPCRequest, _) = + bincode::decode_from_slice(&request_buffer, bincode::config::standard())?; + let response = match request { + IPCRequest::ListAccounts(_) => ListAccountsResponse { + names: store.list_accounts().await, + } + .into_response(), + IPCRequest::GetAccount(GetAccountRequest { name }) => { + IPCResponse::GetAccount(match store.get_account(&name).await { + Some(account) => Ok(GetAccountResponse { + scopes: account.scopes.clone(), + }), + None => Err(GetAccountError::NotFound), + }) + } + IPCRequest::DeleteAccount(DeleteAccountRequest { name }) => { + store.delete_account(&name).await?; + DeleteAccountResponse {}.into_response() + } + IPCRequest::CreateInvite(_) => store + .create_invite() + .await? + .pipe(|invite| CreateInviteResponse { + link: format!("{web_base}/invite/{invite}"), + }) + .into_response(), + IPCRequest::UpdateScopes(UpdateScopesRequest { account, scopes }) => { + IPCResponse::UpdateScopes( + match store + .update_account(&account, |account| { + account.scopes = scopes; + }) + .await? + { + true => Ok(UpdateScopesResponse {}), + false => Err(UpdateScopesError::NotFound), + }, + ) + } + }; + let response_buffer = bincode::encode_to_vec(response, bincode::config::standard())?; + stream.write_u16(response_buffer.len() as u16).await?; + stream.write_all(&response_buffer).await?; + } + Ok(()) +} diff --git a/src/server/config.rs b/src/server/config.rs new file mode 100644 index 0000000..4563f34 --- /dev/null +++ b/src/server/config.rs @@ -0,0 +1,29 @@ +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use eyre::Context; +use tap::Pipe; + +#[derive(serde::Deserialize)] +pub struct Config { + pub web_socket: SocketAddr, + pub web_base: String, + pub cookie_domain: Option, + pub admin_socket: PathBuf, + pub data: PathBuf, +} + +impl Config { + pub fn load(path: &Path) -> eyre::Result { + let mut config: Config = path + .pipe(std::fs::read_to_string) + .wrap_err("reading config file")? + .as_str() + .pipe(toml::from_str) + .wrap_err("parsing config file")?; + if config.web_base.ends_with('/') { + config.web_base = config.web_base.trim_end_matches('/').to_string(); + } + Ok(config) + } +} diff --git a/src/server/login.rs b/src/server/login.rs new file mode 100644 index 0000000..f9cee70 --- /dev/null +++ b/src/server/login.rs @@ -0,0 +1,208 @@ +use std::time::Duration; + +use argon2::{Argon2, PasswordVerifier}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, + routing::get, + Form, Router, +}; +use axum_extra::extract::{ + cookie::{Cookie, SameSite}, + CookieJar, +}; +use maud::{html, PreEscaped}; +use tap::Tap; + +use crate::server::store::Store; + +use super::{render_html, ApiState, CookieDomain, MakeError, MakeErrorMessage}; + +pub fn bind(app: Router) -> Router { + app.route("/login", get(handle_login_get).post(handle_login_post)) + .route("/logout", get(logout)) + .route( + "/invite/:invite", + get(handle_invite_get).post(handle_invite_post), + ) +} + +fn make_form(status: StatusCode, error: Option<&str>, submit: &str) -> Response { + render_html( + html!(title {"login"}), + html! { + @if let Some(error) = error { + p { "error: " (PreEscaped(error)) } + } + form action="" method="post" { + label for="name" { "name:" } + input type="text" id="name" name="name" {} + label for="password" { "password:" } + input type="password" id="password" name="password" {} + input type="submit" value=(submit) {} + } + }, + ) + .tap_mut(|response| *response.status_mut() = status) +} + +#[axum::debug_handler] +async fn handle_login_get() -> Response { + make_form(StatusCode::OK, None, "login") +} + +#[derive(serde::Deserialize)] +struct FormData { + name: String, + password: String, +} + +#[axum::debug_handler(state = ApiState)] +async fn handle_login_post( + jar: CookieJar, + State(store): State, + State(CookieDomain(cookie_domain)): State, + Form(form): Form, +) -> Result { + // read lock scope + { + let account = store.get_account(&form.name).await.ok_or_else(|| { + make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login") + })?; + Argon2::default() + .verify_password(form.password.as_bytes(), &account.password.parsed()) + .ok() + .ok_or_else(|| { + make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login") + })?; + } + let token = store + .create_token(&form.name) + .await + .make_error()? + .error_message( + StatusCode::INTERNAL_SERVER_ERROR, + "account disappeared while generating token", + )?; + + let mut cookie = Cookie::new("dissociate-token", token); + cookie.set_http_only(true); + cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok()); + cookie.set_path("/"); + cookie.set_same_site(SameSite::Strict); + cookie.set_secure(true); + if let Some(cookie_domain) = cookie_domain { + cookie.set_domain(cookie_domain); + } + + Ok((jar.add(cookie), Redirect::to("/")).into_response()) +} + +#[axum::debug_handler] +async fn logout(jar: CookieJar) -> Response { + ( + jar.remove(Cookie::from("dissociate-token")), + Redirect::to("/login"), + ) + .into_response() +} + +fn invite_error() -> Response { + render_html( + html!(), + html! { + h1 { "unknown invite" } + a href="/" { "return home" } + }, + ) + .tap_mut(|response| *response.status_mut() = StatusCode::NOT_FOUND) +} + +#[axum::debug_handler] +async fn handle_invite_get(Path(invite): Path, State(store): State) -> Response { + if !store.check_invite(&invite).await { + return invite_error(); + } + make_form(StatusCode::OK, None, "create account") +} + +const NAME_ERROR: &str = "invalid name +requirements: +must not be empty +must not exceed 32 characters +must not contain control characters"; +#[axum::debug_handler(state = ApiState)] +async fn handle_invite_post( + jar: CookieJar, + Path(invite): Path, + State(store): State, + State(CookieDomain(cookie_domain)): State, + Form(form): Form, +) -> Result { + if form.name.to_ascii_lowercase() == "empty" || form.password.to_ascii_lowercase() == "empty" { + return Err(make_form( + StatusCode::BAD_REQUEST, + Some("are you jokester?"), + "create account", + )); + } + if form.name.is_empty() || form.name.len() > 32 || form.name.chars().any(|ch| ch.is_control()) { + return Err(make_form( + StatusCode::BAD_REQUEST, + Some(&NAME_ERROR.replace('\n', "
")), + "create account", + )); + } + if form.password.is_empty() { + return Err(make_form( + StatusCode::BAD_REQUEST, + Some("password cannot be empty"), + "create account", + )); + } + + if store.get_account(&form.name).await.is_some() { + return Err(make_form( + StatusCode::CONFLICT, + Some("name taken"), + "create account", + )); + } + + if !store.use_invite(&invite).await.make_error()? { + return Err(invite_error()); + } + + if !store + .create_account(&form.name, &form.password) + .await + .make_error()? + { + return Err(make_form( + StatusCode::CONFLICT, + Some("name taken"), + "create account", + )); + } + let token = store + .create_token(&form.name) + .await + .make_error()? + .error_message( + StatusCode::INTERNAL_SERVER_ERROR, + "account disappeared while generating token", + )?; + + let mut cookie = Cookie::new("dissociate-token", token); + cookie.set_http_only(true); + cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok()); + cookie.set_path("/"); + cookie.set_same_site(SameSite::Strict); + cookie.set_secure(true); + if let Some(cookie_domain) = cookie_domain { + cookie.set_domain(cookie_domain); + } + + Ok((jar.add(cookie), Redirect::to("/")).into_response()) +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..d9b3beb --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,160 @@ +mod admin; +mod config; +mod login; +mod nginx_check; +mod panel; +mod store; + +use std::{future::IntoFuture, path::PathBuf}; + +use axum::{ + body::Body, + extract::FromRef, + http::{header::CONTENT_TYPE, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::get, + Router, +}; +use axum_extra::extract::CookieJar; +use eyre::Context; +use maud::{html, PreEscaped}; +use tap::Pipe; +use tokio::{net::TcpListener, select}; + +use crate::server::store::Store; + +use self::config::Config; + +pub async fn serve() -> eyre::Result<()> { + let config_path = std::env::args() + .skip(2) + .next() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap().join("dissociate.toml")); + let config = Config::load(&config_path)?; + + let store = Store::load(config.data).await?; + println!("admin listening on {:?}", config.admin_socket); + let admin_serve = admin::serve(&config.admin_socket, config.web_base.clone(), store.clone())?; + + let listener = TcpListener::bind(config.web_socket).await?; + + let app = Router::new() + .pipe(login::bind) + .pipe(nginx_check::bind) + .pipe(panel::bind) + .with_state(ApiState { + store, + cookie_domain: CookieDomain(config.cookie_domain), + web_base: WebBase(config.web_base), + }) + .fallback(get(|| async { + render_html( + html!(title { "not found" }), + html! { + h1 { "404 not found" } + p {"sowwy :("} + }, + ) + })); + + println!("web listening on {:?}", config.web_socket); + let web_serve = axum::serve(listener, app).into_future(); + + select! { + res = admin_serve => res.wrap_err("in admin"), + res = web_serve => res.wrap_err("in web"), + } +} + +#[derive(Clone, FromRef)] +struct ApiState { + pub store: Store, + pub cookie_domain: CookieDomain, + pub web_base: WebBase, +} + +#[derive(Clone)] +struct CookieDomain(Option); + +#[derive(Clone)] +struct WebBase(String); + +fn render_html(head: PreEscaped>, body: PreEscaped>) -> Response { + let html = html! { + (PreEscaped("")) + html { + head {(head)} + body {(body)} + } + } + .into_string(); + Response::builder() + .header(CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::new(html)) + .unwrap() +} + +trait MakeErrorMessage { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result; +} + +impl MakeErrorMessage for Result { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result { + self.map_err(|err| { + render_html( + html!(title { "error" }), + html! { + h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) } + pre { (err.to_string()) ": " (message.to_string()) } + }, + ) + }) + } +} + +impl MakeErrorMessage for Option { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result { + self.ok_or_else(|| { + render_html( + html!(title { "error" }), + html! { + h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) } + pre { (message.to_string()) } + }, + ) + }) + } +} + +trait MakeError { + fn make_error(self) -> Result; +} + +impl MakeError for std::io::Result { + fn make_error(self) -> Result { + self.error_message(StatusCode::INTERNAL_SERVER_ERROR, "internal io error") + } +} + +trait Nevermind { + fn prompt_login(self) -> Result; + fn prompt_logout(self) -> Result; +} + +impl Nevermind for Option { + fn prompt_login(self) -> Result { + self.ok_or_else(|| Redirect::to("/login").into_response()) + } + + fn prompt_logout(self) -> Result { + self.ok_or_else(|| Redirect::to("/login").into_response()) + } +} + +async fn account_auth(jar: &CookieJar, store: &Store) -> Option { + let cookie = jar.get("dissociate-token")?; + let token = cookie.value(); + let name = store.check_token(token).await?; + Some(name) +} diff --git a/src/server/nginx_check.rs b/src/server/nginx_check.rs new file mode 100644 index 0000000..7b67f26 --- /dev/null +++ b/src/server/nginx_check.rs @@ -0,0 +1,41 @@ +// for ngx_http_auth_request_module authentication +// make sure you have cookie_domain set properly +// depends on https://git.mia.jetzt/sysconf/tree/patches/nginx_auth_redirect.patch + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, + routing::get, + Router, +}; +use axum_extra::extract::CookieJar; + +use crate::server::{account_auth, store::Store}; + +use super::{ApiState, WebBase}; + +pub fn bind(app: Router) -> Router { + app.route("/nginx_check/:scope", get(nginx_check)) +} + +#[axum::debug_handler(state = ApiState)] +async fn nginx_check( + jar: CookieJar, + Path(scope): Path, + State(store): State, + State(WebBase(web_base)): State, +) -> Response { + let nevermind = || Redirect::to(&format!("{web_base}/logout")).into_response(); + let Some(name) = account_auth(&jar, &store).await else { + return nevermind(); + }; + let Some(account) = store.get_account(&name).await else { + return nevermind(); + }; + if account.scopes.contains(&scope) { + StatusCode::OK.into_response() + } else { + StatusCode::FORBIDDEN.into_response() + } +} diff --git a/src/server/panel.rs b/src/server/panel.rs new file mode 100644 index 0000000..addb0d8 --- /dev/null +++ b/src/server/panel.rs @@ -0,0 +1,30 @@ +use axum::{extract::State, response::Response, routing::get, Router}; +use axum_extra::extract::CookieJar; +use maud::html; +use tap::Pipe; + +use crate::server::{store::Store, Nevermind}; + +use super::{account_auth, render_html, ApiState}; + +pub fn bind(app: Router) -> Router { + app.route("/", get(get_panel)) +} + +#[axum::debug_handler(state = ApiState)] +async fn get_panel(jar: CookieJar, State(store): State) -> Result { + Ok(account_auth(&jar, &store) + .await + .prompt_login()? + .pipe(render_normal_panel)) +} + +fn render_normal_panel(name: String) -> Response { + render_html( + html!(title { "dissociate" }), + html! { + p { "currently logged in as " (name) } + a href="/logout" { button { "log out" } } + }, + ) +} diff --git a/src/server/store.rs b/src/server/store.rs new file mode 100644 index 0000000..98c1bcc --- /dev/null +++ b/src/server/store.rs @@ -0,0 +1,330 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use argon2::{ + password_hash::{rand_core::OsRng, Encoding, PasswordHashString, SaltString}, + Argon2, PasswordHash, PasswordHasher, +}; +use nanoid::nanoid; +use serde::{de::Unexpected, Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tap::{Pipe, Tap}; +use tokio::sync::{RwLock, RwLockReadGuard}; + +#[derive(Clone)] +pub struct Store(Arc>); + +impl Store { + pub async fn load(path: PathBuf) -> eyre::Result { + let mut inner = StoreInner::new(path.clone()); + if path.try_exists()? { + let mut map: Map = + { std::fs::File::open(&path)?.pipe(serde_json::from_reader)? }; + let accounts: Vec = + serde_json::from_value(map.remove("accounts").unwrap_or_default())?; + let apps: Vec = serde_json::from_value(map.remove("apps").unwrap_or_default())?; + for mut account in accounts { + account.tokens = account + .tokens + .into_iter() + .filter(|token| token.expires > SystemTime::now()) + .collect(); + for token in &account.tokens { + inner.token_map.insert( + token.value.clone(), + (account.name.clone(), token.expires.clone()), + ); + } + inner.accounts.insert(account.name.clone(), account); + } + for app in apps { + inner.apps.insert(app.name.clone(), app); + } + inner.invites = serde_json::from_value(map.remove("invites").unwrap_or_default())?; + inner.invites = inner + .invites + .into_iter() + .filter(|invite| invite.expires > SystemTime::now()) + .collect(); + } else { + Self::save(&mut inner).await?; + } + Ok(Store(Arc::new(RwLock::new(inner)))) + } + + async fn save(inner: &mut StoreInner) -> std::io::Result<()> { + let mut map: Map = Map::new(); + map.insert( + "accounts".into(), + serde_json::to_value(inner.accounts.values().collect::>()).unwrap(), + ); + map.insert( + "apps".into(), + serde_json::to_value(inner.apps.values().collect::>()).unwrap(), + ); + map.insert( + "invites".into(), + serde_json::to_value(&inner.invites).unwrap(), + ); + let data = serde_json::to_vec_pretty(&map).unwrap(); + let mut temp = inner.path.clone(); + temp.set_file_name( + temp.file_name() + .unwrap_or_default() + .to_os_string() + .tap_mut(|name| name.push(".tmp")), + ); + tokio::fs::write(&temp, data).await?; + tokio::fs::rename(temp, &inner.path).await?; + Ok(()) + } + + pub async fn get_account(&self, name: &str) -> Option> { + let guard = self.0.read().await; + RwLockReadGuard::try_map(guard, |guard| guard.accounts.get(name)).ok() + } + + pub async fn create_account(&self, name: &str, password: &str) -> std::io::Result { + let hash = Argon2::default() + .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) + .unwrap() + .pipe(|hash| hash.serialize()) + .pipe(OwnedPasswordHash::from); + let mut guard = self.0.write().await; + if guard.accounts.get(name).is_some() { + return Ok(false); + } + guard.accounts.insert( + name.to_string(), + Account { + name: name.to_string(), + password: hash, + tokens: Default::default(), + scopes: Default::default(), + }, + ); + Self::save(&mut guard).await?; + Ok(true) + } + + pub async fn delete_account(&self, name: &str) -> std::io::Result<()> { + let mut guard = self.0.write().await; + if let Some(account) = guard.accounts.remove(name) { + for token in account.tokens { + guard.token_map.remove(&token.value); + } + Self::save(&mut guard).await + } else { + Ok(()) + } + } + + pub async fn update_account( + &self, + name: &str, + with: impl FnOnce(&mut Account), + ) -> std::io::Result { + let mut guard = self.0.write().await; + if let Some(account) = guard.accounts.get_mut(name) { + with(account); + Self::save(&mut guard).await.map(|_| true) + } else { + Ok(false) + } + } + + pub async fn list_accounts(&self) -> Vec { + self.0 + .read() + .await + .accounts + .values() + .map(|account| account.name.clone()) + .collect() + } + + pub async fn create_token(&self, name: &str) -> std::io::Result> { + let mut guard = self.0.write().await; + let token = nanoid!(32); + if let Some(account) = guard.accounts.get_mut(name) { + let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 30); + account.tokens.push(ExpiringValue { + value: token.clone(), + expires: expires.clone(), + }); + guard + .token_map + .insert(token.clone(), (name.to_string(), expires)); + Self::save(&mut guard).await?; + Ok(Some(token)) + } else { + Ok(None) + } + } + + pub async fn check_token(&self, token: &str) -> Option { + let guard = self.0.read().await; + let Some((name, expires)) = guard.token_map.get(token) else { + return None; + }; + if *expires < SystemTime::now() { + return None; + } + Some(name.clone()) + } + + pub async fn create_invite(&self) -> std::io::Result { + let mut guard = self.0.write().await; + let invite = nanoid!(32); + let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 14); + guard.invites.push(ExpiringValue { + value: invite.clone(), + expires, + }); + Self::save(&mut guard).await?; + Ok(invite) + } + + pub async fn check_invite(&self, invite: &str) -> bool { + let guard = self.0.read().await; + let now = SystemTime::now(); + guard + .invites + .iter() + .any(|check| check.expires > now && check.value == invite) + } + + pub async fn use_invite(&self, invite: &str) -> std::io::Result { + let mut guard = self.0.write().await; + let now = SystemTime::now(); + if let Some((index, _)) = guard + .invites + .iter() + .enumerate() + .find(|(_, check)| check.expires > now && check.value == invite) + { + guard.invites.swap_remove(index); + Self::save(&mut guard).await?; + Ok(true) + } else { + Ok(false) + } + } +} + +pub struct StoreInner { + path: PathBuf, + accounts: HashMap, + apps: HashMap, + invites: Vec, + token_map: HashMap, +} + +impl StoreInner { + fn new(at: PathBuf) -> Self { + Self { + path: at, + accounts: Default::default(), + apps: Default::default(), + invites: Default::default(), + token_map: Default::default(), + } + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Account { + pub name: String, + pub password: OwnedPasswordHash, + pub tokens: Vec, + pub scopes: Vec, +} + +#[ouroboros::self_referencing] +pub struct OwnedPasswordHash { + owned: PasswordHashString, + #[borrows(owned)] + #[not_covariant] + parsed: PasswordHash<'this>, +} + +impl OwnedPasswordHash { + pub fn from(inner: PasswordHashString) -> Self { + OwnedPasswordHash::new(inner, |inner| inner.password_hash()) + } + + pub fn parsed(&self) -> PasswordHash { + self.with_parsed(|x| x.clone()) + } +} + +impl Serialize for OwnedPasswordHash { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.with_owned(|owned| owned.as_str().serialize(serializer)) + } +} + +impl<'de> Deserialize<'de> for OwnedPasswordHash { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(String::deserialize(deserializer)? + .pipe(|hash| { + PasswordHashString::parse(&hash, Encoding::B64).map_err(|_| { + ::invalid_value( + Unexpected::Str(&hash), + &"valid password hash", + ) + }) + })? + .pipe(OwnedPasswordHash::from)) + } +} + +pub struct ExpiringValue { + pub value: String, + pub expires: SystemTime, +} + +impl Serialize for ExpiringValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + <(&str, u64)>::serialize( + &( + &self.value, + (self.expires.duration_since(SystemTime::UNIX_EPOCH)) + .unwrap() + .as_secs(), + ), + serializer, + ) + } +} + +impl<'de> Deserialize<'de> for ExpiringValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + <(String, u64)>::deserialize(deserializer).map(|(token, unix)| ExpiringValue { + value: token, + expires: UNIX_EPOCH + Duration::from_secs(unix), + }) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct App { + name: String, + secret: String, +} diff --git a/src/tui/accounts.rs b/src/tui/accounts.rs new file mode 100644 index 0000000..a43028c --- /dev/null +++ b/src/tui/accounts.rs @@ -0,0 +1,169 @@ +use std::{cell::RefCell, rc::Rc}; + +use cursive::{ + view::Nameable, + views::{Button, Dialog, LinearLayout, SelectView, TextArea}, + Cursive, +}; + +use crate::ipc::{ + DeleteAccountRequest, GetAccountRequest, ListAccountsRequest, UpdateScopesRequest, +}; + +use super::{CursiveIpc, ToTextView, ViewExt}; + +pub fn show(siv: &mut Cursive) { + if let Some(accounts) = siv.ipc(ListAccountsRequest {}) { + siv.add_layer( + SelectView::new() + .autojump() + .with_all_str(accounts.names) + .on_submit(|siv, name: &str| { + manage_account(siv, name); + }) + .float("accounts"), + ); + } +} + +fn manage_account(siv: &mut Cursive, name: &str) { + let name = name.to_string(); + if let Some(_) = siv.ipc(GetAccountRequest { name: name.clone() }) { + siv.add_layer( + LinearLayout::vertical() + .child({ + let name = name.clone(); + enum SelectOption { + Back, + Scopes, + Delete, + } + SelectView::new() + .item("back", SelectOption::Back) + .item("scopes", SelectOption::Scopes) + .item("delete", SelectOption::Delete) + .on_submit(move |siv, opt| match opt { + SelectOption::Back => { + siv.pop_layer(); + } + SelectOption::Scopes => edit_scopes(siv, &name), + SelectOption::Delete => delete_account(siv, &name), + }) + }) + .float(&name), + ) + } +} + +fn edit_scopes(siv: &mut Cursive, name: &str) { + let name = name.to_string(); + let Some(account) = siv.ipc(GetAccountRequest { name: name.clone() }) else { + siv.pop_layer(); + return; // it was just there! + }; + let scopes = Rc::new(RefCell::new(account.scopes)); + enum SelectOption { + Scope(String), + Back, + Add, + } + siv.add_layer( + SelectView::new() + .autojump() + .item("back", SelectOption::Back) + .item("add", SelectOption::Add) + .with_all( + scopes + .clone() + .borrow() + .iter() + .map(|scope| (scope.to_string(), SelectOption::Scope(scope.clone()))), + ) + .on_submit(move |siv, opt| { + match opt { + SelectOption::Scope(scope) => { + let mut scopes = scopes.borrow_mut(); + let index = scopes + .iter() + .enumerate() + .find(|(_, check)| *check == scope) + .unwrap() + .0; + scopes.swap_remove(index); + ipc_update_scopes(siv, &name, &scopes); + siv.pop_layer(); + edit_scopes(siv, &name); + } + SelectOption::Back => { + siv.pop_layer(); + } + SelectOption::Add => add_scope(siv, name.clone(), scopes.clone()), + }; + }) + .float("edit scopes"), + ); +} + +fn add_scope(siv: &mut Cursive, name: String, scopes: Rc>>) { + siv.add_layer( + LinearLayout::vertical() + .child(TextArea::new().with_name("scope")) + .child(Button::new("add", { + move |siv| { + let text_area = siv.find_name::