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/server | |
download | dissociate-796b2cafc798a7faa80a007002831a4c40635fe8.tar.gz dissociate-796b2cafc798a7faa80a007002831a4c40635fe8.zip |
initial commit v0.1.0
Diffstat (limited to 'src/server')
-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 |
7 files changed, 891 insertions, 0 deletions
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, +} |