diff options
author | mia <mia@mia.jetzt> | 2024-04-16 19:05:41 -0700 |
---|---|---|
committer | mia <mia@mia.jetzt> | 2024-04-16 19:05:41 -0700 |
commit | 796b2cafc798a7faa80a007002831a4c40635fe8 (patch) | |
tree | d8e68590524f4adab7ff8ff6e2cb3dfbb0c64b37 /src | |
download | dissociate-796b2cafc798a7faa80a007002831a4c40635fe8.tar.gz dissociate-796b2cafc798a7faa80a007002831a4c40635fe8.zip |
initial commit v0.1.0
Diffstat (limited to 'src')
-rw-r--r-- | src/ipc.rs | 83 | ||||
-rw-r--r-- | src/main.rs | 12 | ||||
-rw-r--r-- | src/server/admin.rs | 93 | ||||
-rw-r--r-- | src/server/config.rs | 29 | ||||
-rw-r--r-- | src/server/login.rs | 208 | ||||
-rw-r--r-- | src/server/mod.rs | 160 | ||||
-rw-r--r-- | src/server/nginx_check.rs | 41 | ||||
-rw-r--r-- | src/server/panel.rs | 30 | ||||
-rw-r--r-- | src/server/store.rs | 330 | ||||
-rw-r--r-- | src/tui/accounts.rs | 169 | ||||
-rw-r--r-- | src/tui/mod.rs | 159 |
11 files changed, 1314 insertions, 0 deletions
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<Result<Self, Self::Error>>; + 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<Result<Self, Self::Error>> { + 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 Response>]), paste!([<$name Error>])>), + )* + } + }; +} + +define! { + @ListAccounts {} => { names: Vec<String> } | + @GetAccount { name: String } => { scopes: Vec<String> } | NotFound + @DeleteAccount { name: String } => {} | + @CreateInvite {} => { link: String } | + @UpdateScopes { account: String, scopes: Vec<String> } => {} | 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<impl Future<Output = 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<String>, + pub admin_socket: PathBuf, + pub data: PathBuf, +} + +impl Config { + pub fn load(path: &Path) -> eyre::Result<Self> { + 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<ApiState>) -> Router<ApiState> { + 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<Store>, + State(CookieDomain(cookie_domain)): State<CookieDomain>, + Form(form): Form<FormData>, +) -> Result<Response, Response> { + // 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<String>, State(store): State<Store>) -> 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<String>, + State(store): State<Store>, + State(CookieDomain(cookie_domain)): State<CookieDomain>, + Form(form): Form<FormData>, +) -> Result<Response, Response> { + 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', "<br>")), + "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<String>); + +#[derive(Clone)] +struct WebBase(String); + +fn render_html(head: PreEscaped<impl AsRef<str>>, body: PreEscaped<impl AsRef<str>>) -> Response { + let html = html! { + (PreEscaped("<!doctype html>")) + html { + head {(head)} + body {(body)} + } + } + .into_string(); + Response::builder() + .header(CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::new(html)) + .unwrap() +} + +trait MakeErrorMessage<T> { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response>; +} + +impl<T, E: ToString> MakeErrorMessage<T> for Result<T, E> { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> { + 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<T> MakeErrorMessage<T> for Option<T> { + fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> { + 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<T> { + fn make_error(self) -> Result<T, Response>; +} + +impl<T> MakeError<T> for std::io::Result<T> { + fn make_error(self) -> Result<T, Response> { + self.error_message(StatusCode::INTERNAL_SERVER_ERROR, "internal io error") + } +} + +trait Nevermind<T> { + fn prompt_login(self) -> Result<T, Response>; + fn prompt_logout(self) -> Result<T, Response>; +} + +impl<T> Nevermind<T> for Option<T> { + fn prompt_login(self) -> Result<T, Response> { + self.ok_or_else(|| Redirect::to("/login").into_response()) + } + + fn prompt_logout(self) -> Result<T, Response> { + self.ok_or_else(|| Redirect::to("/login").into_response()) + } +} + +async fn account_auth(jar: &CookieJar, store: &Store) -> Option<String> { + 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<ApiState>) -> Router<ApiState> { + app.route("/nginx_check/:scope", get(nginx_check)) +} + +#[axum::debug_handler(state = ApiState)] +async fn nginx_check( + jar: CookieJar, + Path(scope): Path<String>, + State(store): State<Store>, + State(WebBase(web_base)): State<WebBase>, +) -> 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<ApiState>) -> Router<ApiState> { + app.route("/", get(get_panel)) +} + +#[axum::debug_handler(state = ApiState)] +async fn get_panel(jar: CookieJar, State(store): State<Store>) -> Result<Response, Response> { + 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<RwLock<StoreInner>>); + +impl Store { + pub async fn load(path: PathBuf) -> eyre::Result<Self> { + let mut inner = StoreInner::new(path.clone()); + if path.try_exists()? { + let mut map: Map<String, Value> = + { std::fs::File::open(&path)?.pipe(serde_json::from_reader)? }; + let accounts: Vec<Account> = + serde_json::from_value(map.remove("accounts").unwrap_or_default())?; + let apps: Vec<App> = 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<String, Value> = Map::new(); + map.insert( + "accounts".into(), + serde_json::to_value(inner.accounts.values().collect::<Vec<_>>()).unwrap(), + ); + map.insert( + "apps".into(), + serde_json::to_value(inner.apps.values().collect::<Vec<_>>()).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<RwLockReadGuard<'_, Account>> { + 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<bool> { + 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<bool> { + 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<String> { + self.0 + .read() + .await + .accounts + .values() + .map(|account| account.name.clone()) + .collect() + } + + pub async fn create_token(&self, name: &str) -> std::io::Result<Option<String>> { + 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<String> { + 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<String> { + 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<bool> { + 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<String, Account>, + apps: HashMap<String, App>, + invites: Vec<ExpiringValue>, + token_map: HashMap<String, (String, SystemTime)>, +} + +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<ExpiringValue>, + pub scopes: Vec<String>, +} + +#[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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + self.with_owned(|owned| owned.as_str().serialize(serializer)) + } +} + +impl<'de> Deserialize<'de> for OwnedPasswordHash { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + Ok(String::deserialize(deserializer)? + .pipe(|hash| { + PasswordHashString::parse(&hash, Encoding::B64).map_err(|_| { + <D::Error as serde::de::Error>::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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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<D>(deserializer: D) -> Result<Self, D::Error> + 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<RefCell<Vec<String>>>) { + siv.add_layer( + LinearLayout::vertical() + .child(TextArea::new().with_name("scope")) + .child(Button::new("add", { + move |siv| { + let text_area = siv.find_name::<TextArea>("scope").unwrap(); + let scope = text_area.get_content(); + if scope.is_empty() { + siv.pop_layer(); + return; + } + let mut scopes = scopes.borrow_mut(); + scopes.push(scope.to_string()); + ipc_update_scopes(siv, &name, &scopes); + siv.pop_layer(); + siv.pop_layer(); + edit_scopes(siv, &name); + } + })) + .float("add scope"), + ) +} + +fn ipc_update_scopes(siv: &mut Cursive, name: &str, scopes: &Vec<String>) { + siv.ipc(UpdateScopesRequest { + account: name.to_string(), + scopes: scopes.clone(), + }); +} + +fn delete_account(siv: &mut Cursive, name: &str) { + let name = name.to_string(); + siv.add_layer( + LinearLayout::vertical() + .child(format!("delete {name}?").text_view()) + .child(TextArea::new().with_name("confirm")) + .child(Button::new("submit", move |siv| { + if let Some(confirm) = siv.find_name::<TextArea>("confirm") { + if confirm.get_content() == "yes" { + if siv + .ipc(DeleteAccountRequest { name: name.clone() }) + .is_some() + { + siv.add_layer(Dialog::around("success".text_view()).button( + "ok", + |siv| { + // all the way back to the root + siv.pop_layer(); + siv.pop_layer(); + siv.pop_layer(); + siv.pop_layer(); + }, + )) + } + } else { + siv.pop_layer(); + } + } + })) + .float("are you sure"), + ); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..7c748c1 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,159 @@ +mod accounts; + +use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, +}; + +use cursive::{ + event::Key, + theme::{BorderStyle, Palette, Theme}, + utils::markup::StyledString, + view::Margins, + views::{Dialog, PaddedView, Panel, ScrollView, SelectView, TextView}, + Cursive, CursiveExt, +}; +use eyre::ContextCompat; + +use crate::ipc::{CreateInviteRequest, IPCResponse, RequestDefinition, ResponseDefinition}; + +pub fn run() -> eyre::Result<()> { + let Some(socket_path) = std::env::args().skip(2).next() else { + eyre::bail!("must specify socket path"); + }; + let stream = UnixStream::connect(socket_path)?; + let ipc = Ipc(stream); + + let mut siv = Cursive::new(); + siv.set_user_data(ipc); + + siv.set_theme(Theme { + shadow: false, + borders: BorderStyle::Simple, + palette: Palette::terminal_default(), + }); + + enum SelectItem { + Accounts, + Invite, + } + siv.add_layer( + SelectView::new() + .item("accounts", SelectItem::Accounts) + .item("invite", SelectItem::Invite) + .on_submit(move |siv, item| match item { + SelectItem::Accounts => accounts::show(siv), + SelectItem::Invite => invite(siv), + }) + .float("dissociate"), + ); + + siv.add_global_callback(Key::Esc, |siv| { + if siv.pop_layer().is_none() || siv.screen().is_empty() { + siv.quit(); + } + }); + + siv.run(); + + Ok(()) +} + +fn invite(siv: &mut Cursive) { + if let Some(invite) = siv.ipc(CreateInviteRequest {}) { + siv.add_layer( + Dialog::around(invite.link.text_view().hpad()) + .button("ok", |siv| { + siv.pop_layer(); + }) + .title("invite") + .pad(), + ); + } +} + +struct Ipc(UnixStream); + +impl Ipc { + fn exec<T: RequestDefinition>( + &mut self, + request: T, + ) -> eyre::Result<Result<T::Response, <T::Response as ResponseDefinition>::Error>> { + let request_buffer = + bincode::encode_to_vec(request.into_request(), bincode::config::standard())?; + self.0 + .write_all(&(request_buffer.len() as u16).to_be_bytes())?; + self.0.write_all(&request_buffer)?; + let mut response_len_buffer = [0u8; 2]; + self.0.read_exact(&mut response_len_buffer)?; + let response_len = u16::from_be_bytes(response_len_buffer) as usize; + let mut response_buffer = vec![0u8; response_len]; + self.0.read_exact(&mut response_buffer)?; + let (response, _): (IPCResponse, _) = + bincode::decode_from_slice(&response_buffer, bincode::config::standard())?; + T::Response::from_response(response).wrap_err("got wrong response type") + } +} + +trait CursiveIpc { + fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response>; +} + +impl CursiveIpc for Cursive { + fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response> { + match self.user_data::<Ipc>().unwrap().exec(request) { + Ok(Ok(resp)) => Some(resp), + Err(err) => { + self.add_layer( + Dialog::new() + .content(format!("ipc error: {err:?}").text_view()) + .button("quit", |siv| siv.quit()), + ); + None + } + Ok(Err(err)) => { + self.add_layer( + Dialog::new() + .content(format!("error: {err}").text_view()) + .button("ok", |siv| { + siv.pop_layer(); + }), + ); + None + } + } + } +} + +trait ViewExt: Sized { + fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>>; + fn pad(self) -> PaddedView<Self>; + fn hpad(self) -> PaddedView<Self>; +} + +impl<V: Sized> ViewExt for V { + fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>> { + PaddedView::new( + Margins::lrtb(1, 1, 1, 1), + Panel::new(ScrollView::new(PaddedView::new(Margins::lr(1, 1), self))).title(title), + ) + } + + fn pad(self) -> PaddedView<Self> { + PaddedView::new(Margins::lrtb(1, 1, 1, 1), self) + } + + fn hpad(self) -> PaddedView<Self> { + PaddedView::new(Margins::lr(1, 1), self) + } +} + +trait ToTextView { + fn text_view(self) -> TextView; +} + +impl<S: Into<StyledString>> ToTextView for S { + fn text_view(self) -> TextView { + TextView::new(self) + } +} |