use crate::{platform::PlatformKind, storage::Storage, Error, Result};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct Accounts {
storage: Arc<dyn Storage>,
pub ttl_seconds: u64,
}
impl Accounts {
pub fn new<S: Storage>(storage: S) -> Self {
Self::with_arc(Arc::new(storage))
}
pub fn with_arc(storage: Arc<dyn Storage>) -> Self {
Self {
storage,
ttl_seconds: 5 * 60,
}
}
pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
self.ttl_seconds = ttl_seconds;
self
}
pub fn storage_ref(&self) -> &Arc<dyn Storage> {
&self.storage
}
pub async fn start_link(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
let link_code = random_chars(6);
let value = format!("{}|{}", ident(platform, user_id), now_secs());
self.storage.set(&key_link(&link_code), &value).await?;
Ok(link_code)
}
pub async fn redeem_link(
&self,
link_code: &str,
platform: PlatformKind,
user_id: &str,
) -> Result<LinkResult> {
let link_code = link_code.trim().to_ascii_uppercase();
let record = self
.storage
.get(&key_link(&link_code))
.await?
.ok_or_else(|| Error::Other("invalid or expired link code".into()))?;
let (initiator, issued_at) = parse_ident_ts(&record)?;
if self.is_expired(issued_at) {
let _ = self.storage.del(&key_link(&link_code)).await;
return Err(Error::Other("link code has expired".into()));
}
let me = ident(platform, user_id);
if me == initiator {
return Err(Error::Other(
"cannot link an account to itself - use the code on the OTHER platform".into(),
));
}
if self.storage.get(&key_pair(&me)).await?.is_some() {
return Err(Error::Other(
"this account is already linked - /unlink first".into(),
));
}
if self.storage.get(&key_pair(&initiator)).await?.is_some() {
return Err(Error::Other(
"the other account is already linked - ask them to /unlink first".into(),
));
}
let _ = self.storage.del(&key_link(&link_code)).await;
self.storage.set(&key_pair(&me), &initiator).await?;
self.storage.set(&key_pair(&initiator), &me).await?;
self.storage.set(&key_primary(&me), &me).await?;
self.storage.set(&key_primary(&initiator), &me).await?;
Ok(LinkResult {
primary: me,
partner: initiator,
})
}
pub async fn set_primary(
&self,
platform: PlatformKind,
user_id: &str,
chosen: &str,
) -> Result<()> {
let me = ident(platform, user_id);
let partner = self
.storage
.get(&key_pair(&me))
.await?
.ok_or_else(|| Error::Other("you have no linked account yet".into()))?;
if chosen != me && chosen != partner {
return Err(Error::Other(
"that identity is not part of your link".into(),
));
}
self.storage.set(&key_primary(&me), chosen).await?;
self.storage.set(&key_primary(&partner), chosen).await?;
Ok(())
}
pub async fn begin_confirm(
&self,
link_code: &str,
platform: PlatformKind,
user_id: &str,
) -> Result<String> {
let link_code = link_code.trim().to_ascii_uppercase();
let record = self
.storage
.get(&key_link(&link_code))
.await?
.ok_or_else(|| Error::Other("invalid or expired link code".into()))?;
let (initiator, issued_at) = parse_ident_ts(&record)?;
if self.is_expired(issued_at) {
let _ = self.storage.del(&key_link(&link_code)).await;
return Err(Error::Other("link code has expired".into()));
}
let me = ident(platform, user_id);
if me == initiator {
return Err(Error::Other(
"cannot link an account to itself - use the code on the OTHER platform".into(),
));
}
let _ = self.storage.del(&key_link(&link_code)).await;
let confirm_code = random_digits(4);
let value = format!("{initiator}|{me}|{}", now_secs());
self.storage
.set(&key_pending(&initiator, &confirm_code), &value)
.await?;
Ok(confirm_code)
}
pub async fn complete_confirm(
&self,
confirm_code: &str,
platform: PlatformKind,
user_id: &str,
) -> Result<LinkResult> {
let me = ident(platform, user_id);
let confirm_code = confirm_code.trim().to_owned();
let raw = self
.storage
.get(&key_pending(&me, &confirm_code))
.await?
.ok_or_else(|| {
Error::Other("wrong confirm code - ask the other platform for a fresh one".into())
})?;
let mut parts = raw.splitn(3, '|');
let initiator = parts.next().unwrap_or("").to_owned();
let partner = parts.next().unwrap_or("").to_owned();
let issued_at: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
if self.is_expired(issued_at) {
let _ = self.storage.del(&key_pending(&me, &confirm_code)).await;
return Err(Error::Other("confirm code has expired".into()));
}
if initiator != me {
return Err(Error::Other(
"this confirm code belongs to a different account".into(),
));
}
self.storage.set(&key_pair(&me), &partner).await?;
self.storage.set(&key_pair(&partner), &me).await?;
self.storage.set(&key_primary(&me), &me).await?;
self.storage.set(&key_primary(&partner), &me).await?;
let _ = self.storage.del(&key_pending(&me, &confirm_code)).await;
Ok(LinkResult {
primary: me.clone(),
partner: partner.clone(),
})
}
pub async fn propose_primary(
&self,
platform: PlatformKind,
user_id: &str,
chosen_identity: &str,
) -> Result<String> {
let me = ident(platform, user_id);
let partner = self
.storage
.get(&key_pair(&me))
.await?
.ok_or_else(|| Error::Other("you have no linked account yet".into()))?;
if chosen_identity != me && chosen_identity != partner {
return Err(Error::Other(
"that identity is not part of your link".into(),
));
}
let code = random_digits(4);
let value = format!("{me}|{chosen_identity}|{}", now_secs());
self.storage
.set(&key_primary_pending(&me, &code), &value)
.await?;
Ok(code)
}
pub async fn commit_primary(
&self,
platform: PlatformKind,
user_id: &str,
code: &str,
) -> Result<String> {
let me = ident(platform, user_id);
let code = code.trim().to_owned();
let raw = self
.storage
.get(&key_primary_pending(&me, &code))
.await?
.ok_or_else(|| Error::Other("wrong or expired primary-change code".into()))?;
let mut parts = raw.splitn(3, '|');
let owner = parts.next().unwrap_or("").to_owned();
let chosen = parts.next().unwrap_or("").to_owned();
let issued_at: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
if self.is_expired(issued_at) {
let _ = self.storage.del(&key_primary_pending(&me, &code)).await;
return Err(Error::Other("primary-change code expired".into()));
}
if owner != me {
return Err(Error::Other(
"that code was issued for another account".into(),
));
}
let partner = self
.storage
.get(&key_pair(&me))
.await?
.ok_or_else(|| Error::Other("link vanished while you were thinking".into()))?;
self.storage.set(&key_primary(&me), &chosen).await?;
self.storage.set(&key_primary(&partner), &chosen).await?;
let _ = self.storage.del(&key_primary_pending(&me, &code)).await;
Ok(chosen)
}
pub async fn partner_for(
&self,
platform: PlatformKind,
user_id: &str,
) -> Result<Option<String>> {
self.storage.get(&key_pair(&ident(platform, user_id))).await
}
pub async fn primary_for(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
let me = ident(platform, user_id);
match self.storage.get(&key_primary(&me)).await? {
Some(p) => Ok(p),
None => Ok(me),
}
}
pub async fn lang_for(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
let primary = self.primary_for(platform, user_id).await?;
Ok(self
.storage
.get(&key_lang(&primary))
.await?
.unwrap_or_else(|| "en".to_owned()))
}
pub async fn set_lang(&self, platform: PlatformKind, user_id: &str, lang: &str) -> Result<()> {
let primary = self.primary_for(platform, user_id).await?;
self.storage.set(&key_lang(&primary), lang).await
}
pub async fn unlink(&self, platform: PlatformKind, user_id: &str) -> Result<Option<String>> {
let me = ident(platform, user_id);
let partner = self.storage.get(&key_pair(&me)).await?;
if let Some(other) = partner.as_ref() {
let _ = self.storage.del(&key_pair(&me)).await;
let _ = self.storage.del(&key_pair(other)).await;
let _ = self.storage.del(&key_primary(&me)).await;
let _ = self.storage.del(&key_primary(other)).await;
}
Ok(partner)
}
fn is_expired(&self, issued_at: u64) -> bool {
now_secs().saturating_sub(issued_at) > self.ttl_seconds
}
}
#[derive(Debug, Clone)]
pub struct LinkResult {
pub primary: String,
pub partner: String,
}
fn ident(platform: PlatformKind, user_id: &str) -> String {
format!("{platform}:{user_id}")
}
fn key_link(code: &str) -> String {
format!("foukoapi:link:code:{code}")
}
fn key_pending(owner: &str, confirm_code: &str) -> String {
format!("foukoapi:link:pending:{owner}:{confirm_code}")
}
fn key_pair(ident: &str) -> String {
format!("foukoapi:link:pair:{ident}")
}
fn key_primary(ident: &str) -> String {
format!("foukoapi:link:primary:{ident}")
}
fn key_primary_pending(owner: &str, code: &str) -> String {
format!("foukoapi:link:primary_pending:{owner}:{code}")
}
fn key_lang(primary: &str) -> String {
format!("foukoapi:user:{primary}:lang")
}
fn parse_ident_ts(s: &str) -> Result<(String, u64)> {
s.split_once('|')
.and_then(|(a, b)| b.parse::<u64>().ok().map(|ts| (a.to_owned(), ts)))
.ok_or_else(|| Error::Other("corrupted link record".into()))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn random_chars(len: usize) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let mut seed: u64 = now_secs().wrapping_mul(0x9E3779B97F4A7C15) ^ 0xDEADBEEFCAFEBABE;
let mut out = String::with_capacity(len);
for _ in 0..len {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
out.push(ALPHABET[(seed as usize) % ALPHABET.len()] as char);
}
out
}
fn random_digits(len: usize) -> String {
let mut seed: u64 = now_secs().wrapping_mul(0xDEADBEEF_CAFEF00D) ^ 0x1234_5678_9ABC_DEF0;
let mut out = String::with_capacity(len);
for _ in 0..len {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
out.push(char::from_digit((seed % 10) as u32, 10).unwrap_or('0'));
}
out
}