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>> {
let me = ident(platform, user_id);
let partner = self.storage.get(&key_pair(&me)).await?;
if let Some(p) = partner.as_deref() {
if self.storage.get(&key_pair(p)).await?.is_none() {
tracing::warn!(me = %me, partner = %p, "accounts: repairing one-sided link");
self.storage.set(&key_pair(p), &me).await?;
}
}
Ok(partner)
}
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 = entropy_seed();
let mut out = String::with_capacity(len);
for _ in 0..len {
seed = xorshift(seed);
out.push(ALPHABET[(seed as usize) % ALPHABET.len()] as char);
}
out
}
fn random_digits(len: usize) -> String {
let mut seed = entropy_seed();
let mut out = String::with_capacity(len);
for _ in 0..len {
seed = xorshift(seed);
out.push(char::from_digit((seed % 10) as u32, 10).unwrap_or('0'));
}
out
}
fn xorshift(mut seed: u64) -> u64 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
}
fn entropy_seed() -> u64 {
use std::hash::{Hash, Hasher};
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut seed: u64 = 0x9E37_79B9_7F4A_7C15;
for round in 0..3u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut h);
nanos.hash(&mut h);
std::process::id().hash(&mut h);
count.hash(&mut h);
round.hash(&mut h);
seed = h.finish();
}
seed | 1
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::MemoryStorage;
#[test]
fn codes_issued_back_to_back_differ() {
let a = random_chars(6);
let b = random_chars(6);
assert_ne!(a, b);
let c = random_digits(4);
let d = random_digits(4);
assert!(c.len() == 4 && d.len() == 4);
}
#[tokio::test]
async fn partner_for_repairs_one_sided_link() {
let storage = MemoryStorage::new();
let accounts = Accounts::new(storage.clone());
storage
.set(&key_pair("telegram:1"), "discord:2")
.await
.unwrap();
let partner = accounts
.partner_for(PlatformKind::Telegram, "1")
.await
.unwrap();
assert_eq!(partner.as_deref(), Some("discord:2"));
assert_eq!(
storage
.get(&key_pair("discord:2"))
.await
.unwrap()
.as_deref(),
Some("telegram:1")
);
}
}