...
This commit is contained in:
2017
acldb/Cargo.lock
generated
Normal file
2017
acldb/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,14 @@ serde_json = "1.0"
|
||||
actix-web = "4"
|
||||
actix-rt = "2"
|
||||
actix-cors = "0.6"
|
||||
openapi = "0.6"
|
||||
utoipa = { version = "3.3", features = ["actix_extras"] }
|
||||
utoipa-swagger-ui = { version = "3.1", features = ["actix-web"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
thiserror = "1.0"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
base64 = "0.13"
|
||||
dirs = "4.0"
|
||||
async-trait = "0.1"
|
||||
|
97
acldb/src/acl.rs
Normal file
97
acldb/src/acl.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Represents permission levels in the ACL system
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ACLRight {
|
||||
/// Read permission
|
||||
Read,
|
||||
/// Write permission (includes Read)
|
||||
Write,
|
||||
/// Delete permission (includes Write and Read)
|
||||
Delete,
|
||||
/// Execute permission (includes Delete, Write, and Read)
|
||||
Execute,
|
||||
/// Admin permission (includes all other permissions)
|
||||
Admin,
|
||||
}
|
||||
|
||||
/// Access Control List for managing permissions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ACL {
|
||||
/// Unique name for the ACL within a circle
|
||||
pub name: String,
|
||||
/// Map of public keys to their permission levels
|
||||
permissions: HashMap<String, ACLRight>,
|
||||
}
|
||||
|
||||
impl ACL {
|
||||
/// Creates a new ACL with the given name
|
||||
pub fn new(name: &str) -> Self {
|
||||
ACL {
|
||||
name: name.to_string(),
|
||||
permissions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a permission for a public key
|
||||
pub fn set_permission(&mut self, pubkey: &str, right: ACLRight) {
|
||||
self.permissions.insert(pubkey.to_string(), right);
|
||||
}
|
||||
|
||||
/// Removes a permission for a public key
|
||||
pub fn remove_permission(&mut self, pubkey: &str) {
|
||||
self.permissions.remove(pubkey);
|
||||
}
|
||||
|
||||
/// Checks if a public key has at least the specified permission level
|
||||
pub fn has_permission(&self, pubkey: &str, right: ACLRight) -> bool {
|
||||
if let Some(assigned_right) = self.permissions.get(pubkey) {
|
||||
return *assigned_right >= right;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Gets all public keys with their associated permissions
|
||||
pub fn get_all_permissions(&self) -> &HashMap<String, ACLRight> {
|
||||
&self.permissions
|
||||
}
|
||||
|
||||
/// Gets the permission level for a specific public key
|
||||
pub fn get_permission(&self, pubkey: &str) -> Option<ACLRight> {
|
||||
self.permissions.get(pubkey).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_acl_permissions() {
|
||||
let mut acl = ACL::new("test_acl");
|
||||
|
||||
// Set permissions
|
||||
acl.set_permission("user1", ACLRight::Read);
|
||||
acl.set_permission("user2", ACLRight::Write);
|
||||
acl.set_permission("user3", ACLRight::Admin);
|
||||
|
||||
// Check permissions
|
||||
assert!(acl.has_permission("user1", ACLRight::Read));
|
||||
assert!(!acl.has_permission("user1", ACLRight::Write));
|
||||
|
||||
assert!(acl.has_permission("user2", ACLRight::Read));
|
||||
assert!(acl.has_permission("user2", ACLRight::Write));
|
||||
assert!(!acl.has_permission("user2", ACLRight::Delete));
|
||||
|
||||
assert!(acl.has_permission("user3", ACLRight::Read));
|
||||
assert!(acl.has_permission("user3", ACLRight::Write));
|
||||
assert!(acl.has_permission("user3", ACLRight::Delete));
|
||||
assert!(acl.has_permission("user3", ACLRight::Execute));
|
||||
assert!(acl.has_permission("user3", ACLRight::Admin));
|
||||
|
||||
// Remove permission
|
||||
acl.remove_permission("user2");
|
||||
assert!(!acl.has_permission("user2", ACLRight::Read));
|
||||
}
|
||||
}
|
47
acldb/src/error.rs
Normal file
47
acldb/src/error.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error types for the ACLDB module
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
/// Permission denied error
|
||||
#[error("Permission denied")]
|
||||
PermissionDenied,
|
||||
|
||||
/// Record not found error
|
||||
#[error("Record not found")]
|
||||
NotFound,
|
||||
|
||||
/// Invalid operation error
|
||||
#[error("Invalid operation: {0}")]
|
||||
InvalidOperation(String),
|
||||
|
||||
/// Path error
|
||||
#[error("Path error: {0}")]
|
||||
PathError(String),
|
||||
|
||||
/// OurDB error
|
||||
#[error("OurDB error: {0}")]
|
||||
OurDBError(#[from] ourdb::Error),
|
||||
|
||||
/// TST error
|
||||
#[error("TST error: {0}")]
|
||||
TSTError(#[from] tst::Error),
|
||||
|
||||
/// IO error
|
||||
#[error("IO error: {0}")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
/// Serialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
|
||||
/// Signature verification error
|
||||
#[error("Signature verification error: {0}")]
|
||||
SignatureError(String),
|
||||
|
||||
/// Invalid request error
|
||||
#[error("Invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
}
|
||||
|
||||
|
267
acldb/src/lib.rs
Normal file
267
acldb/src/lib.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
mod acl;
|
||||
mod error;
|
||||
mod topic;
|
||||
mod rpc;
|
||||
mod server;
|
||||
mod utils;
|
||||
|
||||
pub use acl::{ACL, ACLRight};
|
||||
pub use error::Error;
|
||||
pub use topic::ACLDBTopic;
|
||||
pub use rpc::RpcInterface;
|
||||
pub use server::Server;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use ourdb::OurDB;
|
||||
use tst::TST;
|
||||
|
||||
/// ACLDB represents an access-controlled database instance for a specific circle
|
||||
pub struct ACLDB {
|
||||
/// Circle ID
|
||||
circle_id: String,
|
||||
/// Base directory path
|
||||
base_path: String,
|
||||
/// OurDB instance for the circle
|
||||
db: OurDB,
|
||||
/// TST instance for key-to-id mapping
|
||||
tst: TST,
|
||||
/// Cache of loaded ACLs
|
||||
acl_cache: HashMap<String, ACL>,
|
||||
/// Topic instances
|
||||
topics: HashMap<String, Arc<RwLock<ACLDBTopic>>>,
|
||||
}
|
||||
|
||||
impl ACLDB {
|
||||
/// Creates a new ACLDB instance for the specified circle
|
||||
pub fn new(circle_id: &str) -> Result<Self, Error> {
|
||||
let home_dir = dirs::home_dir().ok_or_else(|| Error::PathError("Home directory not found".to_string()))?;
|
||||
let base_path = home_dir.join("hero/var/ourdb").join(circle_id);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&base_path)?;
|
||||
|
||||
// Initialize OurDB for the circle
|
||||
let ourdb_path = base_path.join("data");
|
||||
std::fs::create_dir_all(&ourdb_path)?;
|
||||
|
||||
let db_config = ourdb::OurDBConfig {
|
||||
path: ourdb_path,
|
||||
incremental_mode: true,
|
||||
file_size: None,
|
||||
keysize: None,
|
||||
reset: Some(false),
|
||||
};
|
||||
|
||||
let db = OurDB::new(db_config)?;
|
||||
|
||||
// Initialize TST for key-to-id mapping
|
||||
let tst_path = base_path.join("tst").to_string_lossy().to_string();
|
||||
let tst = TST::new(&tst_path, false)?;
|
||||
|
||||
Ok(ACLDB {
|
||||
circle_id: circle_id.to_string(),
|
||||
base_path: base_path.to_string_lossy().to_string(),
|
||||
db,
|
||||
tst,
|
||||
acl_cache: HashMap::new(),
|
||||
topics: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets a topic instance, creating it if it doesn't exist
|
||||
pub fn topic(&mut self, topic_name: &str) -> Arc<RwLock<ACLDBTopic>> {
|
||||
if let Some(topic) = self.topics.get(topic_name) {
|
||||
return Arc::clone(topic);
|
||||
}
|
||||
|
||||
// Since OurDB and TST don't implement Clone, we'll create new instances
|
||||
// In a real implementation, we would use a connection pool or similar
|
||||
let topic = Arc::new(RwLock::new(ACLDBTopic::new(
|
||||
self.circle_id.clone(),
|
||||
topic_name.to_string(),
|
||||
Arc::new(RwLock::new(OurDB::new(ourdb::OurDBConfig {
|
||||
path: Path::new(&self.base_path).join("data").join(topic_name),
|
||||
incremental_mode: true,
|
||||
file_size: None,
|
||||
keysize: None,
|
||||
reset: Some(false),
|
||||
}).unwrap())),
|
||||
Arc::new(RwLock::new(TST::new(
|
||||
&Path::new(&self.base_path).join("tst").join(topic_name).to_string_lossy(),
|
||||
false
|
||||
).unwrap())),
|
||||
)));
|
||||
|
||||
self.topics.insert(topic_name.to_string(), Arc::clone(&topic));
|
||||
topic
|
||||
}
|
||||
|
||||
/// Updates or creates an ACL with specified permissions
|
||||
pub async fn acl_update(&mut self, caller_pubkey: &str, name: &str, pubkeys: &[String], right: ACLRight) -> Result<(), Error> {
|
||||
// Check if caller has admin rights
|
||||
self.check_admin_rights(caller_pubkey).await?;
|
||||
|
||||
// Get or create the ACL
|
||||
let mut acl = self.get_or_create_acl(name).await?;
|
||||
|
||||
// Update permissions for each public key
|
||||
for pubkey in pubkeys {
|
||||
acl.set_permission(pubkey, right);
|
||||
}
|
||||
|
||||
// Save the updated ACL
|
||||
self.save_acl(&acl).await?;
|
||||
|
||||
// Update cache
|
||||
self.acl_cache.insert(name.to_string(), acl);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes specific public keys from an existing ACL
|
||||
pub async fn acl_remove(&mut self, caller_pubkey: &str, name: &str, pubkeys: &[String]) -> Result<(), Error> {
|
||||
// Check if caller has admin rights
|
||||
self.check_admin_rights(caller_pubkey).await?;
|
||||
|
||||
// Get the ACL
|
||||
let mut acl = self.get_acl(name).await?;
|
||||
|
||||
// Remove permissions for each public key
|
||||
for pubkey in pubkeys {
|
||||
acl.remove_permission(pubkey);
|
||||
}
|
||||
|
||||
// Save the updated ACL
|
||||
self.save_acl(&acl).await?;
|
||||
|
||||
// Update cache
|
||||
self.acl_cache.insert(name.to_string(), acl);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes an entire ACL
|
||||
pub async fn acl_del(&mut self, caller_pubkey: &str, name: &str) -> Result<(), Error> {
|
||||
// Check if caller has admin rights
|
||||
self.check_admin_rights(caller_pubkey).await?;
|
||||
|
||||
// Get the ACL to ensure it exists
|
||||
let _acl = self.get_acl(name).await?;
|
||||
|
||||
// Get the ACL topic
|
||||
let topic = self.topic("acl");
|
||||
let topic = topic.write().await;
|
||||
|
||||
// Delete the ACL
|
||||
topic.delete(name).await?;
|
||||
|
||||
// Remove from cache
|
||||
self.acl_cache.remove(name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets an ACL by name
|
||||
pub async fn get_acl(&mut self, name: &str) -> Result<ACL, Error> {
|
||||
// Check cache first
|
||||
if let Some(acl) = self.acl_cache.get(name) {
|
||||
return Ok(acl.clone());
|
||||
}
|
||||
|
||||
// Get the ACL topic
|
||||
let topic = self.topic("acl");
|
||||
let topic = topic.read().await;
|
||||
|
||||
// Get the ACL data
|
||||
let acl_data = topic.get(name).await?;
|
||||
|
||||
// Deserialize the ACL
|
||||
let acl: ACL = serde_json::from_slice(&acl_data)?;
|
||||
|
||||
// Update cache
|
||||
self.acl_cache.insert(name.to_string(), acl.clone());
|
||||
|
||||
Ok(acl)
|
||||
}
|
||||
|
||||
/// Gets or creates an ACL
|
||||
async fn get_or_create_acl(&mut self, name: &str) -> Result<ACL, Error> {
|
||||
match self.get_acl(name).await {
|
||||
Ok(acl) => Ok(acl),
|
||||
Err(_) => {
|
||||
// Create a new ACL
|
||||
let acl = ACL::new(name);
|
||||
|
||||
// Save the ACL
|
||||
self.save_acl(&acl).await?;
|
||||
|
||||
Ok(acl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves an ACL
|
||||
async fn save_acl(&mut self, acl: &ACL) -> Result<(), Error> {
|
||||
// Serialize the ACL
|
||||
let acl_data = serde_json::to_vec(acl)?;
|
||||
|
||||
// Get the ACL topic
|
||||
let topic = self.topic("acl");
|
||||
let topic = topic.write().await;
|
||||
|
||||
// Save the ACL
|
||||
topic.set(&acl.name, &acl_data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if a caller has admin rights
|
||||
async fn check_admin_rights(&mut self, caller_pubkey: &str) -> Result<(), Error> {
|
||||
// For the circle creator/owner, always grant admin rights
|
||||
if self.is_circle_owner(caller_pubkey) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if there's an admin ACL
|
||||
match self.get_acl("admin").await {
|
||||
Ok(acl) => {
|
||||
// Check if the caller has admin rights
|
||||
if acl.has_permission(caller_pubkey, ACLRight::Admin) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::PermissionDenied)
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// If no admin ACL exists, only the circle owner can perform admin operations
|
||||
Err(Error::PermissionDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a caller is the circle owner
|
||||
fn is_circle_owner(&self, caller_pubkey: &str) -> bool {
|
||||
// In a real implementation, this would check against the circle's owner
|
||||
// For now, we'll use a simple check based on the circle ID
|
||||
// This should be replaced with proper circle ownership verification
|
||||
let circle_owner_file = Path::new(&self.base_path).join("owner");
|
||||
if circle_owner_file.exists() {
|
||||
if let Ok(owner) = std::fs::read_to_string(circle_owner_file) {
|
||||
return owner.trim() == caller_pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
// If no owner file exists, check if this is the first admin operation
|
||||
self.acl_cache.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Tests will be added here
|
||||
}
|
42
acldb/src/main.rs
Normal file
42
acldb/src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use acldb::{Server, Error};
|
||||
use std::env;
|
||||
use log::{info, error, LevelFilter};
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
// Initialize logger
|
||||
env_logger::Builder::new()
|
||||
.filter_level(LevelFilter::Info)
|
||||
.init();
|
||||
|
||||
info!("Starting ACLDB server...");
|
||||
|
||||
// Parse command line arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let host = args.get(1).map_or("127.0.0.1".to_string(), |s| s.clone());
|
||||
let port = args.get(2)
|
||||
.map_or(8080, |s| s.parse::<u16>().unwrap_or(8080));
|
||||
|
||||
// Create server configuration
|
||||
let config = acldb::server::ServerConfig {
|
||||
host,
|
||||
port,
|
||||
};
|
||||
|
||||
// Create and start server
|
||||
let server = Server::new(config);
|
||||
info!("Server listening on {}:{}", config.host, config.port);
|
||||
info!("Swagger UI available at http://{}:{}/swagger", config.host, config.port);
|
||||
|
||||
// Start the server
|
||||
match server.start().await {
|
||||
Ok(_) => {
|
||||
info!("Server stopped");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Server error: {}", e);
|
||||
Err(Error::IOError(e))
|
||||
}
|
||||
}
|
||||
}
|
231
acldb/src/rpc.rs
Normal file
231
acldb/src/rpc.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::error::Error;
|
||||
use crate::acl::ACLRight;
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// RPC request structure
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct RpcRequest {
|
||||
/// Method name
|
||||
pub method: String,
|
||||
/// JSON-encoded arguments
|
||||
pub params: serde_json::Value,
|
||||
/// Signature of the JSON data
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// RPC response structure
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct RpcResponse {
|
||||
/// Result of the operation
|
||||
pub result: Option<serde_json::Value>,
|
||||
/// Error message if any
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// ACL update request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AclUpdateParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the ACL exists
|
||||
pub circle_id: String,
|
||||
/// Unique name for the ACL within the circle
|
||||
pub name: String,
|
||||
/// Array of public keys to grant permissions to
|
||||
pub pubkeys: Vec<String>,
|
||||
/// Permission level
|
||||
pub right: String,
|
||||
}
|
||||
|
||||
/// ACL remove request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AclRemoveParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the ACL exists
|
||||
pub circle_id: String,
|
||||
/// Name of the ACL to modify
|
||||
pub name: String,
|
||||
/// Array of public keys to remove from the ACL
|
||||
pub pubkeys: Vec<String>,
|
||||
}
|
||||
|
||||
/// ACL delete request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AclDelParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the ACL exists
|
||||
pub circle_id: String,
|
||||
/// Name of the ACL to delete
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Set request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the data belongs
|
||||
pub circle_id: String,
|
||||
/// String identifier for the database category
|
||||
pub topic: String,
|
||||
/// Optional string key for the record
|
||||
pub key: Option<String>,
|
||||
/// Optional numeric ID for direct access
|
||||
pub id: Option<u32>,
|
||||
/// Base64-encoded data to store
|
||||
pub value: String,
|
||||
/// ID of the ACL to protect this record (0 for public access)
|
||||
pub acl_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Delete request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DelParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the data belongs
|
||||
pub circle_id: String,
|
||||
/// String identifier for the database category
|
||||
pub topic: String,
|
||||
/// Optional string key for the record
|
||||
pub key: Option<String>,
|
||||
/// Optional numeric ID for direct access
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Get request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the data belongs
|
||||
pub circle_id: String,
|
||||
/// String identifier for the database category
|
||||
pub topic: String,
|
||||
/// Optional string key for the record
|
||||
pub key: Option<String>,
|
||||
/// Optional numeric ID for direct access
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
|
||||
/// Prefix request parameters
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PrefixParams {
|
||||
/// Public key of the requesting user
|
||||
pub caller_pubkey: String,
|
||||
/// ID of the circle where the data belongs
|
||||
pub circle_id: String,
|
||||
/// String identifier for the database category
|
||||
pub topic: String,
|
||||
/// Prefix to search for
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
/// RPC interface for handling client requests
|
||||
pub struct RpcInterface {
|
||||
/// Map of method names to handler functions
|
||||
handlers: HashMap<String, Box<dyn Fn(serde_json::Value) -> Result<serde_json::Value, Error> + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl RpcInterface {
|
||||
/// Creates a new RPC interface
|
||||
pub fn new() -> Self {
|
||||
RpcInterface {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a handler for a method
|
||||
pub fn register<F>(&mut self, method: &str, handler: F)
|
||||
where
|
||||
F: Fn(serde_json::Value) -> Result<serde_json::Value, Error> + Send + Sync + 'static,
|
||||
{
|
||||
self.handlers.insert(method.to_string(), Box::new(handler));
|
||||
}
|
||||
|
||||
/// Handles an RPC request
|
||||
pub fn handle(&self, request: RpcRequest) -> RpcResponse {
|
||||
// Verify the signature
|
||||
if let Err(err) = self.verify_signature(&request) {
|
||||
return RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract the caller's public key from the signature
|
||||
let caller_pubkey = self.extract_pubkey(&request.signature).unwrap_or_default();
|
||||
|
||||
// Call the appropriate handler
|
||||
match self.handlers.get(&request.method) {
|
||||
Some(handler) => {
|
||||
match handler(request.params) {
|
||||
Ok(result) => RpcResponse {
|
||||
result: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Method not found: {}", request.method)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the signature of an RPC request
|
||||
fn verify_signature(&self, request: &RpcRequest) -> Result<(), Error> {
|
||||
// In a real implementation, this would verify the cryptographic signature
|
||||
// For now, we'll just check that the signature is not empty
|
||||
if request.signature.is_empty() {
|
||||
return Err(Error::SignatureError("Empty signature".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts the public key from a signature
|
||||
fn extract_pubkey(&self, signature: &str) -> Result<String, Error> {
|
||||
// In a real implementation, this would extract the public key from the signature
|
||||
// For now, we'll just return a placeholder
|
||||
Ok("extracted_pubkey".to_string())
|
||||
}
|
||||
|
||||
/// Parses a string to an ACLRight enum
|
||||
pub fn parse_acl_right(right_str: &str) -> Result<ACLRight, Error> {
|
||||
match right_str.to_lowercase().as_str() {
|
||||
"read" => Ok(ACLRight::Read),
|
||||
"write" => Ok(ACLRight::Write),
|
||||
"delete" => Ok(ACLRight::Delete),
|
||||
"execute" => Ok(ACLRight::Execute),
|
||||
"admin" => Ok(ACLRight::Admin),
|
||||
_ => Err(Error::InvalidRequest(format!("Invalid ACL right: {}", right_str))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_acl_right() {
|
||||
let rpc = RpcInterface::new();
|
||||
|
||||
assert_eq!(RpcInterface::parse_acl_right("read").unwrap(), ACLRight::Read);
|
||||
assert_eq!(RpcInterface::parse_acl_right("write").unwrap(), ACLRight::Write);
|
||||
assert_eq!(RpcInterface::parse_acl_right("delete").unwrap(), ACLRight::Delete);
|
||||
assert_eq!(RpcInterface::parse_acl_right("execute").unwrap(), ACLRight::Execute);
|
||||
assert_eq!(RpcInterface::parse_acl_right("admin").unwrap(), ACLRight::Admin);
|
||||
|
||||
assert!(RpcInterface::parse_acl_right("invalid").is_err());
|
||||
}
|
||||
}
|
486
acldb/src/server.rs
Normal file
486
acldb/src/server.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_cors::Cors;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::RwLock;
|
||||
use serde_json::json;
|
||||
use log::{error};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::ACLDB;
|
||||
use crate::rpc::{RpcInterface, RpcRequest, RpcResponse, AclUpdateParams, AclRemoveParams, AclDelParams, SetParams, DelParams, GetParams, PrefixParams};
|
||||
use crate::error::Error;
|
||||
use crate::utils::base64_decode;
|
||||
use std::collections::VecDeque;
|
||||
use tokio::task;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
/// Server configuration
|
||||
pub struct ServerConfig {
|
||||
/// Host address
|
||||
pub host: String,
|
||||
/// Port number
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request queue for a circle
|
||||
struct CircleQueue {
|
||||
/// Queue of pending requests
|
||||
queue: VecDeque<(RpcRequest, mpsc::Sender<RpcResponse>)>,
|
||||
/// Flag to indicate if a worker is currently processing this queue
|
||||
is_processing: bool,
|
||||
}
|
||||
|
||||
impl CircleQueue {
|
||||
/// Creates a new circle queue
|
||||
fn new() -> Self {
|
||||
CircleQueue {
|
||||
queue: VecDeque::new(),
|
||||
is_processing: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a request to the queue and starts processing if needed
|
||||
async fn add_request(
|
||||
&mut self,
|
||||
request: RpcRequest,
|
||||
response_sender: mpsc::Sender<RpcResponse>,
|
||||
rpc_interface: Arc<RpcInterface>,
|
||||
acldb_factory: Arc<ACLDBFactory>,
|
||||
) {
|
||||
// Add the request to the queue
|
||||
self.queue.push_back((request.clone(), response_sender));
|
||||
|
||||
// If no worker is processing this queue, start one
|
||||
if !self.is_processing {
|
||||
self.is_processing = true;
|
||||
|
||||
// Clone what we need for the worker
|
||||
let rpc = Arc::clone(&rpc_interface);
|
||||
let factory = Arc::clone(&acldb_factory);
|
||||
let mut queue = self.queue.clone();
|
||||
|
||||
// Spawn a worker task
|
||||
task::spawn(async move {
|
||||
// Process all requests in the queue
|
||||
while let Some((req, sender)) = queue.pop_front() {
|
||||
// Process the request
|
||||
let response = process_request(&req, &rpc, &factory).await;
|
||||
|
||||
// Send the response
|
||||
if let Err(err) = sender.send(response).await {
|
||||
error!("Failed to send response: {}", err);
|
||||
}
|
||||
|
||||
// Small delay to prevent CPU hogging
|
||||
sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating ACLDB instances
|
||||
pub struct ACLDBFactory {
|
||||
/// Map of circle IDs to ACLDB instances
|
||||
dbs: RwLock<HashMap<String, Arc<RwLock<ACLDB>>>>,
|
||||
}
|
||||
|
||||
impl ACLDBFactory {
|
||||
/// Creates a new ACLDBFactory
|
||||
pub fn new() -> Self {
|
||||
ACLDBFactory {
|
||||
dbs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets or creates an ACLDB instance for a circle
|
||||
pub async fn get_or_create(&self, circle_id: &str) -> Result<Arc<RwLock<ACLDB>>, Error> {
|
||||
// Try to get an existing instance
|
||||
let dbs = self.dbs.read().await;
|
||||
if let Some(db) = dbs.get(circle_id) {
|
||||
return Ok(Arc::clone(db));
|
||||
}
|
||||
drop(dbs); // Release the read lock
|
||||
|
||||
// Create a new instance
|
||||
let db = Arc::new(RwLock::new(ACLDB::new(circle_id)?));
|
||||
|
||||
// Store it in the map
|
||||
let mut dbs = self.dbs.write().await;
|
||||
dbs.insert(circle_id.to_string(), Arc::clone(&db));
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server for handling RPC requests
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
/// Server configuration
|
||||
config: ServerConfig,
|
||||
/// RPC interface
|
||||
rpc: Arc<RpcInterface>,
|
||||
/// Map of circle IDs to request queues
|
||||
queues: RwLock<HashMap<String, CircleQueue>>,
|
||||
/// Factory for creating ACLDB instances
|
||||
acldb_factory: Arc<ACLDBFactory>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Creates a new server
|
||||
pub fn new(config: ServerConfig) -> Self {
|
||||
let rpc = Arc::new(RpcInterface::new());
|
||||
let queues = RwLock::new(HashMap::new());
|
||||
let acldb_factory = Arc::new(ACLDBFactory::new());
|
||||
|
||||
Server {
|
||||
config,
|
||||
rpc,
|
||||
queues,
|
||||
acldb_factory,
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the server
|
||||
pub async fn start(&self) -> std::io::Result<()> {
|
||||
let server_data = web::Data::new(self.clone());
|
||||
|
||||
// Start the HTTP server
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.wrap(
|
||||
Cors::default()
|
||||
.allow_any_origin()
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.max_age(3600)
|
||||
)
|
||||
.app_data(web::Data::clone(&server_data))
|
||||
.route("/rpc", web::post().to(handle_rpc))
|
||||
.route("/health", web::get().to(health_check))
|
||||
.service(
|
||||
SwaggerUi::new("/swagger-ui/{_:.*}")
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi())
|
||||
)
|
||||
})
|
||||
.bind(format!("{}:{}", self.config.host, self.config.port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Registers RPC handlers
|
||||
fn register_handlers(&self) {
|
||||
// Nothing to do here - handlers are now processed dynamically
|
||||
}
|
||||
|
||||
/// Adds a request to the queue for a circle
|
||||
async fn add_to_queue(&self, circle_id: &str, request: RpcRequest) -> mpsc::Receiver<RpcResponse> {
|
||||
let (response_sender, response_receiver) = mpsc::channel(1);
|
||||
|
||||
// Get or create the queue for this circle
|
||||
let mut queues = self.queues.write().await;
|
||||
|
||||
if !queues.contains_key(circle_id) {
|
||||
queues.insert(circle_id.to_string(), CircleQueue::new());
|
||||
}
|
||||
|
||||
// Get a mutable reference to the queue
|
||||
if let Some(queue) = queues.get_mut(circle_id) {
|
||||
// Add the request to the queue
|
||||
queue.add_request(
|
||||
request,
|
||||
response_sender,
|
||||
Arc::clone(&self.rpc),
|
||||
Arc::clone(&self.acldb_factory)
|
||||
).await;
|
||||
}
|
||||
|
||||
response_receiver
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Extracts the circle ID from an RPC request
|
||||
fn extract_circle_id(request: &web::Json<RpcRequest>) -> Result<String, Error> {
|
||||
// Extract from different parameter types based on the method
|
||||
match request.method.as_str() {
|
||||
"aclupdate" => {
|
||||
let params: AclUpdateParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"aclremove" => {
|
||||
let params: AclRemoveParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"acldel" => {
|
||||
let params: AclDelParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"set" => {
|
||||
let params: SetParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"del" => {
|
||||
let params: DelParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"get" => {
|
||||
let params: GetParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
"prefix" => {
|
||||
let params: PrefixParams = serde_json::from_value(request.params.clone())?;
|
||||
Ok(params.circle_id)
|
||||
}
|
||||
_ => Err(Error::InvalidRequest(format!("Unknown method: {}", request.method))),
|
||||
}
|
||||
}
|
||||
|
||||
/// API documentation schema
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
health_check,
|
||||
handle_rpc
|
||||
),
|
||||
components(
|
||||
schemas(RpcRequest, RpcResponse)
|
||||
),
|
||||
tags(
|
||||
(name = "acldb", description = "ACLDB API")
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
/// Handler for RPC requests with OpenAPI documentation
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/rpc",
|
||||
request_body = RpcRequest,
|
||||
responses(
|
||||
(status = 200, description = "RPC request processed successfully", body = RpcResponse),
|
||||
(status = 400, description = "Bad request", body = RpcResponse),
|
||||
(status = 500, description = "Internal server error", body = RpcResponse)
|
||||
),
|
||||
tag = "acldb"
|
||||
)]
|
||||
async fn handle_rpc(
|
||||
server: web::Data<Server>,
|
||||
request: web::Json<RpcRequest>,
|
||||
) -> impl Responder {
|
||||
// Extract the circle ID from the request
|
||||
let circle_id = match extract_circle_id(&request) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
return HttpResponse::BadRequest().json(RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add the request to the queue for this circle
|
||||
let mut response_receiver = server.add_to_queue(&circle_id, request.0.clone()).await;
|
||||
|
||||
// Wait for the response
|
||||
match response_receiver.recv().await {
|
||||
Some(response) => HttpResponse::Ok().json(response),
|
||||
None => HttpResponse::InternalServerError().json(RpcResponse {
|
||||
result: None,
|
||||
error: Some("Failed to get response".to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process an RPC request
|
||||
async fn process_request(
|
||||
request: &RpcRequest,
|
||||
rpc_interface: &Arc<RpcInterface>,
|
||||
acldb_factory: &Arc<ACLDBFactory>
|
||||
) -> RpcResponse {
|
||||
match request.method.as_str() {
|
||||
"aclupdate" => {
|
||||
match serde_json::from_value::<AclUpdateParams>(request.params.clone()) {
|
||||
Ok(params) => {
|
||||
match RpcInterface::parse_acl_right(¶ms.right) {
|
||||
Ok(right) => {
|
||||
match acldb_factory.get_or_create(¶ms.circle_id).await {
|
||||
Ok(db) => {
|
||||
let mut db = db.write().await;
|
||||
match db.acl_update(¶ms.caller_pubkey, ¶ms.name, ¶ms.pubkeys, right) {
|
||||
Ok(_) => RpcResponse {
|
||||
result: Some(json!({"success": true})),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Invalid parameters: {}", err)),
|
||||
},
|
||||
}
|
||||
},
|
||||
"aclremove" => {
|
||||
match serde_json::from_value::<AclRemoveParams>(request.params.clone()) {
|
||||
Ok(params) => {
|
||||
match acldb_factory.get_or_create(¶ms.circle_id).await {
|
||||
Ok(db) => {
|
||||
let mut db = db.write().await;
|
||||
match db.acl_remove(¶ms.caller_pubkey, ¶ms.name, ¶ms.pubkeys) {
|
||||
Ok(_) => RpcResponse {
|
||||
result: Some(json!({"success": true})),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Invalid parameters: {}", err)),
|
||||
},
|
||||
}
|
||||
},
|
||||
"acldel" => {
|
||||
match serde_json::from_value::<AclDelParams>(request.params.clone()) {
|
||||
Ok(params) => {
|
||||
match acldb_factory.get_or_create(¶ms.circle_id).await {
|
||||
Ok(db) => {
|
||||
let mut db = db.write().await;
|
||||
match db.acl_del(¶ms.caller_pubkey, ¶ms.name) {
|
||||
Ok(_) => RpcResponse {
|
||||
result: Some(json!({"success": true})),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Invalid parameters: {}", err)),
|
||||
},
|
||||
}
|
||||
},
|
||||
"set" => {
|
||||
match serde_json::from_value::<SetParams>(request.params.clone()) {
|
||||
Ok(params) => {
|
||||
match acldb_factory.get_or_create(¶ms.circle_id).await {
|
||||
Ok(db) => {
|
||||
let mut db = db.write().await;
|
||||
let topic = db.topic(¶ms.topic);
|
||||
|
||||
match base64_decode(¶ms.value) {
|
||||
Ok(value) => {
|
||||
let acl_id = params.acl_id.unwrap_or(0);
|
||||
|
||||
let result = if let Some(key) = params.key {
|
||||
let topic = topic.write().await;
|
||||
topic.set_with_acl(&key, &value, acl_id)
|
||||
} else if let Some(id) = params.id {
|
||||
let topic = topic.write().await;
|
||||
topic.set_with_acl(&id.to_string(), &value, acl_id)
|
||||
} else {
|
||||
Err(Error::InvalidRequest("Either key or id must be provided".to_string()))
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(id) => RpcResponse {
|
||||
result: Some(json!({"id": id})),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(err) => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Invalid parameters: {}", err)),
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => RpcResponse {
|
||||
result: None,
|
||||
error: Some(format!("Unknown method: {}", request.method)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for health check with OpenAPI documentation
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "Server is healthy", body = String)
|
||||
),
|
||||
tag = "acldb"
|
||||
)]
|
||||
async fn health_check() -> impl Responder {
|
||||
HttpResponse::Ok().json(json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Tests will be added here
|
||||
}
|
344
acldb/src/topic.rs
Normal file
344
acldb/src/topic.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use ourdb::{OurDB, OurDBSetArgs};
|
||||
use tst::TST;
|
||||
use crate::error::Error;
|
||||
use crate::acl::{ACL, ACLRight};
|
||||
|
||||
/// ACLDBTopic represents a database instance for a specific topic within a circle
|
||||
pub struct ACLDBTopic {
|
||||
/// Circle ID
|
||||
circle_id: String,
|
||||
/// Topic name
|
||||
topic: String,
|
||||
/// OurDB instance
|
||||
db: Arc<RwLock<OurDB>>,
|
||||
/// TST instance for key-to-id mapping
|
||||
tst: Arc<RwLock<TST>>,
|
||||
}
|
||||
|
||||
impl ACLDBTopic {
|
||||
/// Creates a new ACLDBTopic instance
|
||||
pub fn new(circle_id: String, topic: String, db: Arc<RwLock<OurDB>>, tst: Arc<RwLock<TST>>) -> Self {
|
||||
ACLDBTopic {
|
||||
circle_id,
|
||||
topic,
|
||||
db,
|
||||
tst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a value in the database with optional ACL protection
|
||||
pub async fn set(&self, key: &str, value: &[u8]) -> Result<u32, Error> {
|
||||
self.set_with_acl(key, value, 0).await // 0 means no ACL protection
|
||||
}
|
||||
|
||||
/// Sets a value in the database with ACL protection
|
||||
pub async fn set_with_acl(&self, key: &str, value: &[u8], acl_id: u32) -> Result<u32, Error> {
|
||||
// Create the TST key
|
||||
let tst_key = format!("{}{}", self.topic, key);
|
||||
|
||||
// Check if the key already exists in TST
|
||||
let id = {
|
||||
// First try to get the ID from TST
|
||||
let mut id_opt = None;
|
||||
{
|
||||
let tst = self.tst.read().await;
|
||||
if let Ok(id_bytes) = tst.list(&tst_key) {
|
||||
if !id_bytes.is_empty() {
|
||||
let id_str = String::from_utf8_lossy(&id_bytes[0].as_bytes());
|
||||
if let Ok(parsed_id) = id_str.parse::<u32>() {
|
||||
id_opt = Some(parsed_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, get a new ID
|
||||
match id_opt {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let mut db = self.db.write().await;
|
||||
db.get_next_id()?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare the data with ACL ID prefix if needed
|
||||
let data = if acl_id > 0 {
|
||||
// Add ACL ID as the first 4 bytes
|
||||
let mut acl_data = acl_id.to_be_bytes().to_vec();
|
||||
acl_data.extend_from_slice(value);
|
||||
acl_data
|
||||
} else {
|
||||
value.to_vec()
|
||||
};
|
||||
|
||||
// Store the data in OurDB
|
||||
{
|
||||
let mut db = self.db.write().await;
|
||||
db.set(OurDBSetArgs {
|
||||
id: Some(id),
|
||||
data: &data,
|
||||
})?;
|
||||
}
|
||||
|
||||
// Store the ID in TST
|
||||
{
|
||||
let mut tst = self.tst.write().await;
|
||||
tst.set(&tst_key, id.to_string().into_bytes())?;
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Gets a value from the database
|
||||
pub async fn get(&self, key: &str) -> Result<Vec<u8>, Error> {
|
||||
// Create the TST key
|
||||
let tst_key = format!("{}{}", self.topic, key);
|
||||
|
||||
// Get the ID from TST
|
||||
let id = {
|
||||
let tst = self.tst.read().await;
|
||||
let keys = tst.list(&tst_key)?;
|
||||
if keys.is_empty() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let id_str = &keys[0];
|
||||
id_str.parse::<u32>().map_err(|_| Error::InvalidOperation("Invalid ID format in TST".to_string()))?
|
||||
};
|
||||
|
||||
// Get the data from OurDB
|
||||
let data = {
|
||||
let mut db = self.db.write().await;
|
||||
db.get(id)?
|
||||
};
|
||||
|
||||
// Check if the data has an ACL ID prefix
|
||||
if data.len() >= 4 {
|
||||
let (acl_id_bytes, actual_data) = data.split_at(4);
|
||||
let acl_id = u32::from_be_bytes([acl_id_bytes[0], acl_id_bytes[1], acl_id_bytes[2], acl_id_bytes[3]]);
|
||||
|
||||
if acl_id > 0 {
|
||||
// This record is ACL-protected, but we're not checking permissions here
|
||||
// The permission check should be done at a higher level
|
||||
return Ok(actual_data.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Gets a value from the database with permission check
|
||||
pub async fn get_with_permission(&self, key: &str, caller_pubkey: &str, parent_acls: &[ACL]) -> Result<Vec<u8>, Error> {
|
||||
// Create the TST key
|
||||
let tst_key = format!("{}{}", self.topic, key);
|
||||
|
||||
// Get the ID from TST
|
||||
let id = {
|
||||
let tst = self.tst.read().await;
|
||||
let keys = tst.list(&tst_key)?;
|
||||
if keys.is_empty() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let id_str = &keys[0];
|
||||
id_str.parse::<u32>().map_err(|_| Error::InvalidOperation("Invalid ID format in TST".to_string()))?
|
||||
};
|
||||
|
||||
// Get the data from OurDB
|
||||
let data = {
|
||||
let mut db = self.db.write().await;
|
||||
db.get(id)?
|
||||
};
|
||||
|
||||
// Check if the data has an ACL ID prefix
|
||||
if data.len() >= 4 {
|
||||
let (acl_id_bytes, actual_data) = data.split_at(4);
|
||||
let acl_id = u32::from_be_bytes([acl_id_bytes[0], acl_id_bytes[1], acl_id_bytes[2], acl_id_bytes[3]]);
|
||||
|
||||
if acl_id > 0 {
|
||||
// This record is ACL-protected, check permissions
|
||||
let acl_name = format!("acl_{}", acl_id);
|
||||
|
||||
// Find the ACL in the parent ACLs
|
||||
let has_permission = parent_acls.iter()
|
||||
.find(|acl| acl.name == acl_name)
|
||||
.map(|acl| acl.has_permission(caller_pubkey, ACLRight::Read))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_permission {
|
||||
return Err(Error::PermissionDenied);
|
||||
}
|
||||
|
||||
return Ok(actual_data.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Gets a value by ID from the database
|
||||
pub async fn get_by_id(&self, id: u32) -> Result<Vec<u8>, Error> {
|
||||
// Get the data from OurDB
|
||||
let data = {
|
||||
let mut db = self.db.write().await;
|
||||
db.get(id)?
|
||||
};
|
||||
|
||||
// Check if the data has an ACL ID prefix
|
||||
if data.len() >= 4 {
|
||||
let (_, actual_data) = data.split_at(4);
|
||||
return Ok(actual_data.to_vec());
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Gets a value by ID from the database with permission check
|
||||
pub async fn get_by_id_with_permission(&self, id: u32, caller_pubkey: &str, parent_acls: &[ACL]) -> Result<Vec<u8>, Error> {
|
||||
// Get the data from OurDB
|
||||
let data = {
|
||||
let mut db = self.db.write().await;
|
||||
db.get(id)?
|
||||
};
|
||||
|
||||
// Check if the data has an ACL ID prefix
|
||||
if data.len() >= 4 {
|
||||
let (acl_id_bytes, actual_data) = data.split_at(4);
|
||||
let acl_id = u32::from_be_bytes([acl_id_bytes[0], acl_id_bytes[1], acl_id_bytes[2], acl_id_bytes[3]]);
|
||||
|
||||
if acl_id > 0 {
|
||||
// This record is ACL-protected, check permissions
|
||||
let acl_name = format!("acl_{}", acl_id);
|
||||
|
||||
// Find the ACL in the parent ACLs
|
||||
let has_permission = parent_acls.iter()
|
||||
.find(|acl| acl.name == acl_name)
|
||||
.map(|acl| acl.has_permission(caller_pubkey, ACLRight::Read))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_permission {
|
||||
return Err(Error::PermissionDenied);
|
||||
}
|
||||
|
||||
return Ok(actual_data.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Deletes a value from the database
|
||||
pub async fn delete(&self, key: &str) -> Result<(), Error> {
|
||||
// Create the TST key
|
||||
let tst_key = format!("{}{}", self.topic, key);
|
||||
|
||||
// Get the ID from TST
|
||||
let id = {
|
||||
let tst = self.tst.read().await;
|
||||
let keys = tst.list(&tst_key)?;
|
||||
if keys.is_empty() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let id_str = &keys[0];
|
||||
id_str.parse::<u32>().map_err(|_| Error::InvalidOperation("Invalid ID format in TST".to_string()))?
|
||||
};
|
||||
|
||||
// Delete from OurDB
|
||||
{
|
||||
let mut db = self.db.write().await;
|
||||
db.delete(id)?;
|
||||
}
|
||||
|
||||
// Delete from TST
|
||||
{
|
||||
let mut tst = self.tst.write().await;
|
||||
tst.delete(&tst_key)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes a value from the database with permission check
|
||||
pub async fn delete_with_permission(&self, key: &str, caller_pubkey: &str, parent_acls: &[ACL]) -> Result<(), Error> {
|
||||
// Create the TST key
|
||||
let tst_key = format!("{}{}", self.topic, key);
|
||||
|
||||
// Get the ID from TST
|
||||
let id = {
|
||||
let tst = self.tst.read().await;
|
||||
let keys = tst.list(&tst_key)?;
|
||||
if keys.is_empty() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let id_str = &keys[0];
|
||||
id_str.parse::<u32>().map_err(|_| Error::InvalidOperation("Invalid ID format in TST".to_string()))?
|
||||
};
|
||||
|
||||
// Get the data to check ACL
|
||||
let data = {
|
||||
let db = self.db.read().await;
|
||||
db.get(id)?
|
||||
};
|
||||
|
||||
// Check if the data has an ACL ID prefix
|
||||
if data.len() >= 4 {
|
||||
let (acl_id_bytes, _) = data.split_at(4);
|
||||
let acl_id = u32::from_be_bytes([acl_id_bytes[0], acl_id_bytes[1], acl_id_bytes[2], acl_id_bytes[3]]);
|
||||
|
||||
if acl_id > 0 {
|
||||
// This record is ACL-protected, check permissions
|
||||
let acl_name = format!("acl_{}", acl_id);
|
||||
|
||||
// Find the ACL in the parent ACLs
|
||||
let has_permission = parent_acls.iter()
|
||||
.find(|acl| acl.name == acl_name)
|
||||
.map(|acl| acl.has_permission(caller_pubkey, ACLRight::Delete))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_permission {
|
||||
return Err(Error::PermissionDenied);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from OurDB
|
||||
{
|
||||
let mut db = self.db.write().await;
|
||||
db.delete(id)?
|
||||
};
|
||||
|
||||
// Delete from TST
|
||||
{
|
||||
let mut tst = self.tst.write().await;
|
||||
tst.delete(&tst_key)?
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets all keys with a given prefix
|
||||
pub async fn prefix(&self, prefix: &str) -> Result<Vec<String>, Error> {
|
||||
// Create the TST prefix
|
||||
let tst_prefix = format!("{}{}", self.topic, prefix);
|
||||
|
||||
// Get all keys with the prefix
|
||||
let keys = {
|
||||
let tst = self.tst.read().await;
|
||||
tst.list(&tst_prefix)?
|
||||
};
|
||||
|
||||
// Remove the topic prefix from the keys
|
||||
let topic_prefix = format!("{}", self.topic);
|
||||
let keys = keys.into_iter()
|
||||
.map(|key| key.strip_prefix(&topic_prefix).unwrap_or(&key).to_string())
|
||||
.collect();
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Tests will be added here
|
||||
}
|
52
acldb/src/utils.rs
Normal file
52
acldb/src/utils.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use crate::error::Error;
|
||||
|
||||
/// Decodes a base64 string to bytes
|
||||
pub fn base64_decode(input: &str) -> Result<Vec<u8>, Error> {
|
||||
base64::decode(input).map_err(|e| Error::InvalidRequest(format!("Invalid base64: {}", e)))
|
||||
}
|
||||
|
||||
/// Encodes bytes to a base64 string
|
||||
pub fn base64_encode(input: &[u8]) -> String {
|
||||
base64::encode(input)
|
||||
}
|
||||
|
||||
/// Generates a SHA-256 hash of the input
|
||||
pub fn sha256_hash(input: &[u8]) -> Vec<u8> {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Converts a hash to a hex string
|
||||
pub fn to_hex(bytes: &[u8]) -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Validates a signature against a message and public key
|
||||
pub fn validate_signature(message: &[u8], signature: &str, pubkey: &str) -> Result<bool, Error> {
|
||||
// In a real implementation, this would validate the cryptographic signature
|
||||
// For now, we'll just return true
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_base64() {
|
||||
let original = b"Hello, world!";
|
||||
let encoded = base64_encode(original);
|
||||
let decoded = base64_decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256() {
|
||||
let input = b"test";
|
||||
let hash = sha256_hash(input);
|
||||
let hex = to_hex(&hash);
|
||||
assert_eq!(hex, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08");
|
||||
}
|
||||
}
|
304
acldb/static/openapi.json
Normal file
304
acldb/static/openapi.json
Normal file
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "ACLDB API",
|
||||
"description": "API for the ACLDB module which implements an Access Control List layer on top of the existing ourdb and tst databases.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:8080",
|
||||
"description": "Local development server"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/rpc": {
|
||||
"post": {
|
||||
"summary": "RPC endpoint",
|
||||
"description": "Handles all RPC requests to the ACLDB system",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RpcRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RpcResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"description": "Returns the health status of the server",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server is healthy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"RpcRequest": {
|
||||
"type": "object",
|
||||
"required": ["method", "params", "signature"],
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "The name of the method to call",
|
||||
"example": "set"
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": "The parameters for the method"
|
||||
},
|
||||
"signature": {
|
||||
"type": "string",
|
||||
"description": "Cryptographic signature of the request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RpcResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"description": "The result of the method call if successful"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message if the method call failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AclUpdateParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "name", "pubkeys", "right"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the ACL exists"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for the ACL within the circle"
|
||||
},
|
||||
"pubkeys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Array of public keys to grant permissions to"
|
||||
},
|
||||
"right": {
|
||||
"type": "string",
|
||||
"description": "Permission level (read/write/delete/execute/admin)",
|
||||
"enum": ["read", "write", "delete", "execute", "admin"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"AclRemoveParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "name", "pubkeys"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the ACL exists"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the ACL to modify"
|
||||
},
|
||||
"pubkeys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Array of public keys to remove from the ACL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AclDelParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "name"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the ACL exists"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the ACL to delete"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "topic", "value"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the data belongs"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "String identifier for the database category"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Optional string key for the record"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Optional numeric ID for direct access"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Base64-encoded data to store"
|
||||
},
|
||||
"acl_id": {
|
||||
"type": "integer",
|
||||
"description": "ID of the ACL to protect this record (0 for public access)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DelParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "topic"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the data belongs"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "String identifier for the database category"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Optional string key for the record"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Optional numeric ID for direct access"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "topic"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the data belongs"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "String identifier for the database category"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Optional string key for the record"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "Optional numeric ID for direct access"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrefixParams": {
|
||||
"type": "object",
|
||||
"required": ["caller_pubkey", "circle_id", "topic", "prefix"],
|
||||
"properties": {
|
||||
"caller_pubkey": {
|
||||
"type": "string",
|
||||
"description": "Public key of the requesting user"
|
||||
},
|
||||
"circle_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the circle where the data belongs"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "String identifier for the database category"
|
||||
},
|
||||
"prefix": {
|
||||
"type": "string",
|
||||
"description": "Prefix to search for"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
acldb/static/swagger-ui.html
Normal file
54
acldb/static/swagger-ui.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ACLDB API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui.css" />
|
||||
<link rel="icon" type="image/png" href="https://unpkg.com/swagger-ui-dist@4.5.0/favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="https://unpkg.com/swagger-ui-dist@4.5.0/favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4.5.0/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user