use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH: usize = 8;
const CODE_TTL_SECS: f64 = 3600.0; const RATE_LIMIT_SECS: f64 = 600.0; const LOCKOUT_SECS: f64 = 3600.0;
const MAX_PENDING_PER_PLATFORM: usize = 3;
const MAX_FAILED_ATTEMPTS: u32 = 5;
fn pairing_dir() -> PathBuf {
lingshu_core::lingshu_home().join("pairing")
}
fn now_epoch() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingEntry {
user_id: String,
#[serde(default)]
user_name: String,
created_at: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ApprovedEntry {
#[serde(default)]
user_name: String,
approved_at: f64,
}
pub struct PairingStore;
impl Default for PairingStore {
fn default() -> Self {
Self::new()
}
}
impl PairingStore {
pub fn new() -> Self {
let dir = pairing_dir();
let _ = std::fs::create_dir_all(&dir);
Self
}
fn pending_path(platform: &str) -> PathBuf {
pairing_dir().join(format!("{platform}-pending.json"))
}
fn approved_path(platform: &str) -> PathBuf {
pairing_dir().join(format!("{platform}-approved.json"))
}
fn rate_limit_path() -> PathBuf {
pairing_dir().join("_rate_limits.json")
}
fn load_json<T: serde::de::DeserializeOwned + Default>(path: &PathBuf) -> T {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => T::default(),
}
}
fn save_json<T: serde::Serialize>(path: &PathBuf, data: &T) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(data) {
let _ = std::fs::write(path, json);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
}
pub fn is_approved(&self, platform: &str, user_id: &str) -> bool {
let approved: HashMap<String, ApprovedEntry> =
Self::load_json(&Self::approved_path(platform));
approved.contains_key(user_id)
}
pub fn list_approved(&self, platform: Option<&str>) -> Vec<(String, String, String)> {
let mut results = Vec::new();
let platforms = match platform {
Some(p) => vec![p.to_string()],
None => self.all_platforms("approved"),
};
for p in platforms {
let approved: HashMap<String, ApprovedEntry> =
Self::load_json(&Self::approved_path(&p));
for (uid, info) in approved {
results.push((p.clone(), uid, info.user_name));
}
}
results
}
fn approve_user(&self, platform: &str, user_id: &str, user_name: &str) {
let path = Self::approved_path(platform);
let mut approved: HashMap<String, ApprovedEntry> = Self::load_json(&path);
approved.insert(
user_id.to_string(),
ApprovedEntry {
user_name: user_name.to_string(),
approved_at: now_epoch(),
},
);
Self::save_json(&path, &approved);
}
pub fn revoke(&self, platform: &str, user_id: &str) -> bool {
let path = Self::approved_path(platform);
let mut approved: HashMap<String, ApprovedEntry> = Self::load_json(&path);
if approved.remove(user_id).is_some() {
Self::save_json(&path, &approved);
true
} else {
false
}
}
pub fn generate_code(&self, platform: &str, user_id: &str, user_name: &str) -> Option<String> {
self.cleanup_expired(platform);
if self.is_locked_out(platform) {
return None;
}
if self.is_rate_limited(platform, user_id) {
return None;
}
let path = Self::pending_path(platform);
let mut pending: HashMap<String, PendingEntry> = Self::load_json(&path);
if pending.len() >= MAX_PENDING_PER_PLATFORM {
return None;
}
let mut rng = rand::rng();
let code: String = (0..CODE_LENGTH)
.map(|_| {
let idx = rng.random_range(0..ALPHABET.len());
ALPHABET[idx] as char
})
.collect();
pending.insert(
code.clone(),
PendingEntry {
user_id: user_id.to_string(),
user_name: user_name.to_string(),
created_at: now_epoch(),
},
);
Self::save_json(&path, &pending);
self.record_rate_limit(platform, user_id);
Some(code)
}
pub fn approve_code(&self, platform: &str, code: &str) -> Option<(String, String)> {
self.cleanup_expired(platform);
let code = code.to_uppercase();
let code = code.trim();
let path = Self::pending_path(platform);
let mut pending: HashMap<String, PendingEntry> = Self::load_json(&path);
match pending.remove(code) {
Some(entry) => {
Self::save_json(&path, &pending);
self.approve_user(platform, &entry.user_id, &entry.user_name);
Some((entry.user_id, entry.user_name))
}
None => {
self.record_failed_attempt(platform);
None
}
}
}
pub fn list_pending(
&self,
platform: Option<&str>,
) -> Vec<(String, String, String, String, u64)> {
let mut results = Vec::new();
let platforms = match platform {
Some(p) => vec![p.to_string()],
None => self.all_platforms("pending"),
};
for p in platforms {
self.cleanup_expired(&p);
let pending: HashMap<String, PendingEntry> = Self::load_json(&Self::pending_path(&p));
for (code, info) in pending {
let age_min = ((now_epoch() - info.created_at) / 60.0) as u64;
results.push((p.clone(), code, info.user_id, info.user_name, age_min));
}
}
results
}
fn is_rate_limited(&self, platform: &str, user_id: &str) -> bool {
let limits: HashMap<String, f64> = Self::load_json(&Self::rate_limit_path());
let key = format!("{platform}:{user_id}");
match limits.get(&key) {
Some(&last_request) => (now_epoch() - last_request) < RATE_LIMIT_SECS,
None => false,
}
}
fn record_rate_limit(&self, platform: &str, user_id: &str) {
let path = Self::rate_limit_path();
let mut limits: HashMap<String, f64> = Self::load_json(&path);
limits.insert(format!("{platform}:{user_id}"), now_epoch());
Self::save_json(&path, &limits);
}
fn is_locked_out(&self, platform: &str) -> bool {
let limits: HashMap<String, f64> = Self::load_json(&Self::rate_limit_path());
let key = format!("_lockout:{platform}");
match limits.get(&key) {
Some(&lockout_until) => now_epoch() < lockout_until,
None => false,
}
}
fn record_failed_attempt(&self, platform: &str) {
let path = Self::rate_limit_path();
let mut limits: HashMap<String, f64> = Self::load_json(&path);
let fail_key = format!("_failures:{platform}");
let fails = limits.get(&fail_key).copied().unwrap_or(0.0) as u32 + 1;
limits.insert(fail_key.clone(), fails as f64);
if fails >= MAX_FAILED_ATTEMPTS {
let lockout_key = format!("_lockout:{platform}");
limits.insert(lockout_key, now_epoch() + LOCKOUT_SECS);
limits.insert(fail_key, 0.0);
tracing::warn!(
platform,
"platform locked out after {} failed pairing attempts",
MAX_FAILED_ATTEMPTS
);
}
Self::save_json(&path, &limits);
}
fn cleanup_expired(&self, platform: &str) {
let path = Self::pending_path(platform);
let mut pending: HashMap<String, PendingEntry> = Self::load_json(&path);
let now = now_epoch();
let before = pending.len();
pending.retain(|_, entry| (now - entry.created_at) <= CODE_TTL_SECS);
if pending.len() != before {
Self::save_json(&path, &pending);
}
}
fn all_platforms(&self, suffix: &str) -> Vec<String> {
let dir = pairing_dir();
let mut platforms = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(stripped) = name.strip_suffix(&format!("-{suffix}.json"))
&& !stripped.starts_with('_')
{
platforms.push(stripped.to_string());
}
}
}
platforms
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn code_generation_length() {
assert_eq!(ALPHABET.len(), 32);
assert_eq!(CODE_LENGTH, 8);
}
#[test]
fn code_excludes_ambiguous_chars() {
let alpha = std::str::from_utf8(ALPHABET).expect("pairing alphabet should be valid UTF-8");
assert!(!alpha.contains('0'));
assert!(!alpha.contains('O'));
assert!(!alpha.contains('1'));
assert!(!alpha.contains('I'));
}
#[test]
fn pairing_store_uses_lingshu_home() {
let dir = tempdir().expect("tempdir");
unsafe {
std::env::set_var("EDGECRAB_HOME", dir.path());
}
let store = PairingStore::new();
let code = store
.generate_code("telegram", "123456", "alice")
.expect("pairing code");
assert_eq!(code.len(), CODE_LENGTH);
assert!(
dir.path()
.join("pairing")
.join("telegram-pending.json")
.exists()
);
unsafe {
std::env::remove_var("EDGECRAB_HOME");
}
}
}