use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
pub fn normalize_pubkey(input: &str) -> Option<String> {
let input = input.trim();
if input.starts_with("npub1") {
let (hrp, data) = bech32::decode(input).ok()?;
if hrp.as_str() != "npub" || data.len() != 32 {
return None;
}
Some(hex::encode(data))
} else if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) {
Some(input.to_string())
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Upload,
Download,
Delete,
List,
Mirror,
Admin,
Lock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Admin,
Member,
Denied,
}
pub trait AccessControl: Send + Sync {
fn is_allowed(&self, pubkey: &str, action: Action) -> bool;
fn role(&self, pubkey: &str) -> Role {
if self.is_allowed(pubkey, Action::Admin) {
Role::Admin
} else if self.is_allowed(pubkey, Action::Upload) {
Role::Member
} else {
Role::Denied
}
}
}
pub struct OpenAccess;
impl AccessControl for OpenAccess {
fn is_allowed(&self, _pubkey: &str, _action: Action) -> bool {
true
}
fn role(&self, _pubkey: &str) -> Role {
Role::Member
}
}
pub struct Whitelist {
pubkeys: Arc<RwLock<HashSet<String>>>,
}
impl Whitelist {
pub fn new(pubkeys: HashSet<String>) -> Self {
Self {
pubkeys: Arc::new(RwLock::new(pubkeys)),
}
}
pub fn from_file(path: &Path) -> std::io::Result<Self> {
let content = std::fs::read_to_string(path)?;
let pubkeys = Self::parse_pubkeys(&content);
Ok(Self::new(pubkeys))
}
pub async fn reload(&self, path: &Path) -> std::io::Result<()> {
let content = tokio::fs::read_to_string(path).await?;
let new_keys = Self::parse_pubkeys(&content);
let mut keys = self.pubkeys.write().await;
*keys = new_keys;
tracing::info!(
access.backend = "whitelist",
access.pubkey_count = keys.len(),
"whitelist reloaded"
);
Ok(())
}
pub async fn add(&self, pubkey: String) {
let normalized = normalize_pubkey(&pubkey).unwrap_or(pubkey);
self.pubkeys.write().await.insert(normalized);
}
pub async fn remove(&self, pubkey: &str) {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
self.pubkeys.write().await.remove(&normalized);
}
pub async fn contains(&self, pubkey: &str) -> bool {
self.pubkeys.read().await.contains(pubkey)
}
pub async fn len(&self) -> usize {
self.pubkeys.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.pubkeys.read().await.is_empty()
}
pub async fn list(&self) -> Vec<String> {
self.pubkeys.read().await.iter().cloned().collect()
}
fn parse_pubkeys(content: &str) -> HashSet<String> {
content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(normalize_pubkey)
.collect()
}
}
impl AccessControl for Whitelist {
fn is_allowed(&self, pubkey: &str, _action: Action) -> bool {
match self.pubkeys.try_read() {
Ok(keys) => keys.contains(pubkey),
Err(_) => false,
}
}
fn role(&self, pubkey: &str) -> Role {
if self.is_allowed(pubkey, Action::Upload) {
Role::Member
} else {
Role::Denied
}
}
}
impl AccessControl for Arc<Whitelist> {
fn is_allowed(&self, pubkey: &str, action: Action) -> bool {
(**self).is_allowed(pubkey, action)
}
fn role(&self, pubkey: &str) -> Role {
(**self).role(pubkey)
}
}
pub struct RoleBasedAccess {
admins: Arc<RwLock<HashSet<String>>>,
members: Arc<RwLock<HashSet<String>>>,
}
impl RoleBasedAccess {
pub fn new(admins: HashSet<String>, members: HashSet<String>) -> Self {
Self {
admins: Arc::new(RwLock::new(admins)),
members: Arc::new(RwLock::new(members)),
}
}
pub fn from_files(admin_path: &Path, member_path: &Path) -> std::io::Result<Self> {
let admins = Self::parse_file(admin_path)?;
let members = Self::parse_file(member_path)?;
Ok(Self::new(admins, members))
}
pub async fn reload(&self, admin_path: &Path, member_path: &Path) -> std::io::Result<()> {
let new_admins = Self::parse_file(admin_path)?;
let new_members = Self::parse_file(member_path)?;
*self.admins.write().await = new_admins;
*self.members.write().await = new_members;
tracing::info!(access.backend = "role_based", "role-based access reloaded");
Ok(())
}
pub async fn add_admin(&self, pubkey: &str) {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
self.admins.write().await.insert(normalized);
}
pub async fn add_member(&self, pubkey: &str) {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
self.members.write().await.insert(normalized);
}
pub async fn remove(&self, pubkey: &str) {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
self.admins.write().await.remove(&normalized);
self.members.write().await.remove(&normalized);
}
pub async fn list_admins(&self) -> Vec<String> {
self.admins.read().await.iter().cloned().collect()
}
pub async fn list_members(&self) -> Vec<String> {
self.members.read().await.iter().cloned().collect()
}
fn parse_file(path: &Path) -> std::io::Result<HashSet<String>> {
let content = std::fs::read_to_string(path)?;
Ok(content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(normalize_pubkey)
.collect())
}
}
impl AccessControl for RoleBasedAccess {
fn is_allowed(&self, pubkey: &str, action: Action) -> bool {
match self.role(pubkey) {
Role::Admin => true,
Role::Member => !matches!(action, Action::Admin),
Role::Denied => false,
}
}
fn role(&self, pubkey: &str) -> Role {
if let Ok(admins) = self.admins.try_read() {
if admins.contains(pubkey) {
return Role::Admin;
}
}
if let Ok(members) = self.members.try_read() {
if members.contains(pubkey) {
return Role::Member;
}
}
Role::Denied
}
}
impl AccessControl for Arc<RoleBasedAccess> {
fn is_allowed(&self, pubkey: &str, action: Action) -> bool {
(**self).is_allowed(pubkey, action)
}
fn role(&self, pubkey: &str) -> Role {
(**self).role(pubkey)
}
}
impl RoleBasedAccess {
pub async fn load_from_database(db: &mut dyn crate::db::BlobDatabase) -> Self {
let admins: HashSet<String> = db
.list_users_by_role("admin")
.unwrap_or_default()
.into_iter()
.map(|u| u.pubkey)
.collect();
let members: HashSet<String> = db
.list_users_by_role("member")
.unwrap_or_default()
.into_iter()
.map(|u| u.pubkey)
.collect();
tracing::info!(
admins = admins.len(),
members = members.len(),
"loaded roles from database"
);
Self::new(admins, members)
}
pub async fn promote_admin(
&self,
pubkey: &str,
db: &mut dyn crate::db::BlobDatabase,
) -> Result<(), crate::db::DbError> {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
db.set_role(&normalized, "admin")?;
self.admins.write().await.insert(normalized.clone());
self.members.write().await.remove(&normalized);
Ok(())
}
pub async fn demote_to_member(
&self,
pubkey: &str,
db: &mut dyn crate::db::BlobDatabase,
) -> Result<(), crate::db::DbError> {
let normalized = normalize_pubkey(pubkey).unwrap_or_else(|| pubkey.to_string());
db.set_role(&normalized, "member")?;
self.admins.write().await.remove(&normalized);
self.members.write().await.insert(normalized);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_access_allows_all() {
let ac = OpenAccess;
assert!(ac.is_allowed("anything", Action::Upload));
assert!(ac.is_allowed("anything", Action::Delete));
assert!(ac.is_allowed("anything", Action::Admin));
}
#[test]
fn test_whitelist_allows_listed() {
let pubkey = "a".repeat(64);
let mut keys = HashSet::new();
keys.insert(pubkey.clone());
let wl = Whitelist::new(keys);
assert!(wl.is_allowed(&pubkey, Action::Upload));
assert!(wl.is_allowed(&pubkey, Action::Download));
}
#[test]
fn test_whitelist_denies_unlisted() {
let wl = Whitelist::new(HashSet::new());
let pubkey = "b".repeat(64);
assert!(!wl.is_allowed(&pubkey, Action::Upload));
}
#[test]
fn test_parse_pubkeys_from_content() {
let content = format!(
"# This is a comment\n\n{}\n{}\ninvalid-short\n \n{}",
"a".repeat(64),
"b".repeat(64),
"c".repeat(64),
);
let keys = Whitelist::parse_pubkeys(&content);
assert_eq!(keys.len(), 3);
assert!(keys.contains(&"a".repeat(64)));
assert!(!keys.contains("invalid-short"));
}
#[tokio::test]
async fn test_whitelist_add_remove() {
let wl = Whitelist::new(HashSet::new());
let pk = "d".repeat(64);
assert!(!wl.contains(&pk).await);
wl.add(pk.clone()).await;
assert!(wl.contains(&pk).await);
assert_eq!(wl.len().await, 1);
wl.remove(&pk).await;
assert!(!wl.contains(&pk).await);
assert!(wl.is_empty().await);
}
#[test]
fn test_whitelist_from_file() {
let dir = std::env::temp_dir().join(format!("blossom_wl_{}", rand::random::<u32>()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("whitelist.txt");
let content = format!("# allowed users\n{}\n{}\n", "e".repeat(64), "f".repeat(64),);
std::fs::write(&file, &content).unwrap();
let wl = Whitelist::from_file(&file).unwrap();
assert!(wl.is_allowed(&"e".repeat(64), Action::Upload));
assert!(wl.is_allowed(&"f".repeat(64), Action::Download));
assert!(!wl.is_allowed(&"0".repeat(64), Action::Upload));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_normalize_pubkey_hex() {
let hex = "a".repeat(64);
assert_eq!(normalize_pubkey(&hex), Some(hex));
}
#[test]
fn test_normalize_pubkey_npub() {
let hex_key = "a".repeat(64);
let bytes = hex::decode(&hex_key).unwrap();
let hrp = bech32::Hrp::parse("npub").unwrap();
let npub = bech32::encode::<bech32::Bech32>(hrp, &bytes).unwrap();
assert!(npub.starts_with("npub1"));
let normalized = normalize_pubkey(&npub).unwrap();
assert_eq!(normalized, hex_key);
}
#[test]
fn test_normalize_pubkey_invalid() {
assert_eq!(normalize_pubkey("too_short"), None);
assert_eq!(normalize_pubkey("g".repeat(64).as_str()), None);
assert_eq!(normalize_pubkey("npub1invalid"), None);
assert_eq!(normalize_pubkey(""), None);
}
#[test]
fn test_whitelist_file_with_npub() {
let dir = std::env::temp_dir().join(format!("blossom_npub_{}", rand::random::<u32>()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("whitelist.txt");
let hex_key = "b".repeat(64);
let bytes = hex::decode(&hex_key).unwrap();
let hrp = bech32::Hrp::parse("npub").unwrap();
let npub = bech32::encode::<bech32::Bech32>(hrp, &bytes).unwrap();
let content = format!("# mixed formats\n{}\n{}\n", "a".repeat(64), npub);
std::fs::write(&file, &content).unwrap();
let wl = Whitelist::from_file(&file).unwrap();
assert!(wl.is_allowed(&"a".repeat(64), Action::Upload));
assert!(wl.is_allowed(&hex_key, Action::Upload)); assert!(!wl.is_allowed(&"c".repeat(64), Action::Upload));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn test_whitelist_list() {
let mut keys = HashSet::new();
keys.insert("a".repeat(64));
keys.insert("b".repeat(64));
let wl = Whitelist::new(keys);
let list = wl.list().await;
assert_eq!(list.len(), 2);
assert!(list.contains(&"a".repeat(64)));
assert!(list.contains(&"b".repeat(64)));
}
#[test]
fn test_open_access_role_is_member() {
let ac = OpenAccess;
assert_eq!(ac.role("anything"), Role::Member);
}
#[test]
fn test_whitelist_role_is_member() {
let pk = "a".repeat(64);
let mut keys = HashSet::new();
keys.insert(pk.clone());
let wl = Whitelist::new(keys);
assert_eq!(wl.role(&pk), Role::Member);
assert_eq!(wl.role(&"b".repeat(64)), Role::Denied);
}
#[test]
fn test_role_based_access_admin() {
let admin = "a".repeat(64);
let member = "b".repeat(64);
let nobody = "c".repeat(64);
let mut admins = HashSet::new();
admins.insert(admin.clone());
let mut members = HashSet::new();
members.insert(member.clone());
let rba = RoleBasedAccess::new(admins, members);
assert_eq!(rba.role(&admin), Role::Admin);
assert!(rba.is_allowed(&admin, Action::Admin));
assert!(rba.is_allowed(&admin, Action::Upload));
assert!(rba.is_allowed(&admin, Action::Delete));
assert_eq!(rba.role(&member), Role::Member);
assert!(rba.is_allowed(&member, Action::Upload));
assert!(rba.is_allowed(&member, Action::Delete));
assert!(rba.is_allowed(&member, Action::Download));
assert!(rba.is_allowed(&member, Action::List));
assert!(rba.is_allowed(&member, Action::Mirror));
assert!(!rba.is_allowed(&member, Action::Admin));
assert_eq!(rba.role(&nobody), Role::Denied);
assert!(!rba.is_allowed(&nobody, Action::Upload));
assert!(!rba.is_allowed(&nobody, Action::Admin));
}
#[tokio::test]
async fn test_role_based_access_add_remove() {
let rba = RoleBasedAccess::new(HashSet::new(), HashSet::new());
let pk = "d".repeat(64);
assert_eq!(rba.role(&pk), Role::Denied);
rba.add_member(&pk).await;
assert_eq!(rba.role(&pk), Role::Member);
rba.add_admin(&pk).await;
assert_eq!(rba.role(&pk), Role::Admin);
rba.remove(&pk).await;
assert_eq!(rba.role(&pk), Role::Denied);
}
#[test]
fn test_role_based_access_from_files() {
let dir = std::env::temp_dir().join(format!("blossom_rba_{}", rand::random::<u32>()));
std::fs::create_dir_all(&dir).unwrap();
let admin_file = dir.join("admins.txt");
let member_file = dir.join("members.txt");
std::fs::write(&admin_file, format!("{}\n", "a".repeat(64))).unwrap();
std::fs::write(
&member_file,
format!("{}\n{}\n", "b".repeat(64), "c".repeat(64)),
)
.unwrap();
let rba = RoleBasedAccess::from_files(&admin_file, &member_file).unwrap();
assert_eq!(rba.role(&"a".repeat(64)), Role::Admin);
assert_eq!(rba.role(&"b".repeat(64)), Role::Member);
assert_eq!(rba.role(&"c".repeat(64)), Role::Member);
assert_eq!(rba.role(&"d".repeat(64)), Role::Denied);
let _ = std::fs::remove_dir_all(&dir);
}
}