diff --git a/Cargo.lock b/Cargo.lock index 1af6c34..850a98f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5158,7 +5158,7 @@ dependencies = [ [[package]] name = "xray-admin" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index d858c85..83f10b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xray-admin" -version = "0.1.0" +version = "0.1.1" edition = "2021" [dependencies] @@ -69,4 +69,4 @@ pem = "3.0" # PEM format support teloxide = { version = "0.13", features = ["macros"] } [dev-dependencies] -tempfile = "3.0" \ No newline at end of file +tempfile = "3.0" diff --git a/src/config/args.rs b/src/config/args.rs index 997f819..89dcd3c 100644 --- a/src/config/args.rs +++ b/src/config/args.rs @@ -51,13 +51,17 @@ mod tests { fn test_args_parsing() { let args = Args::try_parse_from(&[ "xray-admin", - "--config", "test.toml", - "--port", "9090", - "--log-level", "debug" - ]).unwrap(); + "--config", + "test.toml", + "--port", + "9090", + "--log-level", + "debug", + ]) + .unwrap(); assert_eq!(args.config, Some(PathBuf::from("test.toml"))); assert_eq!(args.port, Some(9090)); assert_eq!(args.log_level, Some("debug".to_string())); } -} \ No newline at end of file +} diff --git a/src/config/env.rs b/src/config/env.rs index 058d2df..af90149 100644 --- a/src/config/env.rs +++ b/src/config/env.rs @@ -43,21 +43,24 @@ impl EnvVars { /// Get database URL from environment #[allow(dead_code)] pub fn database_url() -> Option { - env::var("DATABASE_URL").ok() + env::var("DATABASE_URL") + .ok() .or_else(|| env::var("XRAY_ADMIN__DATABASE__URL").ok()) } /// Get telegram bot token from environment #[allow(dead_code)] pub fn telegram_token() -> Option { - env::var("TELEGRAM_BOT_TOKEN").ok() + env::var("TELEGRAM_BOT_TOKEN") + .ok() .or_else(|| env::var("XRAY_ADMIN__TELEGRAM__BOT_TOKEN").ok()) } /// Get JWT secret from environment #[allow(dead_code)] pub fn jwt_secret() -> Option { - env::var("JWT_SECRET").ok() + env::var("JWT_SECRET") + .ok() .or_else(|| env::var("XRAY_ADMIN__WEB__JWT_SECRET").ok()) } @@ -66,14 +69,29 @@ impl EnvVars { tracing::debug!("Environment information:"); tracing::debug!(" RUST_ENV: {:?}", env::var("RUST_ENV")); tracing::debug!(" ENVIRONMENT: {:?}", env::var("ENVIRONMENT")); - tracing::debug!(" DATABASE_URL: {}", - if env::var("DATABASE_URL").is_ok() { "set" } else { "not set" } + tracing::debug!( + " DATABASE_URL: {}", + if env::var("DATABASE_URL").is_ok() { + "set" + } else { + "not set" + } ); - tracing::debug!(" TELEGRAM_BOT_TOKEN: {}", - if env::var("TELEGRAM_BOT_TOKEN").is_ok() { "set" } else { "not set" } + tracing::debug!( + " TELEGRAM_BOT_TOKEN: {}", + if env::var("TELEGRAM_BOT_TOKEN").is_ok() { + "set" + } else { + "not set" + } ); - tracing::debug!(" JWT_SECRET: {}", - if env::var("JWT_SECRET").is_ok() { "set" } else { "not set" } + tracing::debug!( + " JWT_SECRET: {}", + if env::var("JWT_SECRET").is_ok() { + "set" + } else { + "not set" + } ); } } @@ -101,4 +119,4 @@ mod tests { env::remove_var("RUST_ENV"); } -} \ No newline at end of file +} diff --git a/src/config/file.rs b/src/config/file.rs index 3192e79..3831903 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -14,10 +14,14 @@ impl ConfigFile { pub fn load_toml>(path: P) -> Result { let content = fs::read_to_string(&path) .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; - - let config: AppConfig = toml::from_str(&content) - .with_context(|| format!("Failed to parse TOML config file: {}", path.as_ref().display()))?; - + + let config: AppConfig = toml::from_str(&content).with_context(|| { + format!( + "Failed to parse TOML config file: {}", + path.as_ref().display() + ) + })?; + Ok(config) } @@ -25,10 +29,14 @@ impl ConfigFile { pub fn load_yaml>(path: P) -> Result { let content = fs::read_to_string(&path) .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; - - let config: AppConfig = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML config file: {}", path.as_ref().display()))?; - + + let config: AppConfig = serde_yaml::from_str(&content).with_context(|| { + format!( + "Failed to parse YAML config file: {}", + path.as_ref().display() + ) + })?; + Ok(config) } @@ -36,17 +44,21 @@ impl ConfigFile { pub fn load_json>(path: P) -> Result { let content = fs::read_to_string(&path) .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?; - - let config: AppConfig = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse JSON config file: {}", path.as_ref().display()))?; - + + let config: AppConfig = serde_json::from_str(&content).with_context(|| { + format!( + "Failed to parse JSON config file: {}", + path.as_ref().display() + ) + })?; + Ok(config) } /// Auto-detect format and load configuration file pub fn load_auto>(path: P) -> Result { let path = path.as_ref(); - + match path.extension().and_then(|ext| ext.to_str()) { Some("toml") => Self::load_toml(path), Some("yaml") | Some("yml") => Self::load_yaml(path), @@ -68,41 +80,45 @@ impl ConfigFile { /// Save configuration to TOML file pub fn save_toml>(config: &AppConfig, path: P) -> Result<()> { - let content = toml::to_string_pretty(config) - .context("Failed to serialize config to TOML")?; - + let content = + toml::to_string_pretty(config).context("Failed to serialize config to TOML")?; + fs::write(&path, content) .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; - + Ok(()) } /// Save configuration to YAML file pub fn save_yaml>(config: &AppConfig, path: P) -> Result<()> { - let content = serde_yaml::to_string(config) - .context("Failed to serialize config to YAML")?; - + let content = + serde_yaml::to_string(config).context("Failed to serialize config to YAML")?; + fs::write(&path, content) .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; - + Ok(()) } /// Save configuration to JSON file pub fn save_json>(config: &AppConfig, path: P) -> Result<()> { - let content = serde_json::to_string_pretty(config) - .context("Failed to serialize config to JSON")?; - + let content = + serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?; + fs::write(&path, content) .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?; - + Ok(()) } /// Check if config file exists and is readable pub fn exists_and_readable>(path: P) -> bool { let path = path.as_ref(); - path.exists() && path.is_file() && fs::metadata(path).map(|m| !m.permissions().readonly()).unwrap_or(false) + path.exists() + && path.is_file() + && fs::metadata(path) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) } /// Find default config file in common locations @@ -140,26 +156,29 @@ mod tests { fn test_save_and_load_toml() -> Result<()> { let config = AppConfig::default(); let temp_file = NamedTempFile::new()?; - + ConfigFile::save_toml(&config, temp_file.path())?; let loaded_config = ConfigFile::load_toml(temp_file.path())?; - + assert_eq!(config.web.port, loaded_config.web.port); - assert_eq!(config.database.max_connections, loaded_config.database.max_connections); - + assert_eq!( + config.database.max_connections, + loaded_config.database.max_connections + ); + Ok(()) } #[test] fn test_auto_detect_format() -> Result<()> { let config = AppConfig::default(); - + // Test with .toml extension let temp_file = NamedTempFile::with_suffix(".toml")?; ConfigFile::save_toml(&config, temp_file.path())?; let loaded_config = ConfigFile::load_auto(temp_file.path())?; assert_eq!(config.web.port, loaded_config.web.port); - + Ok(()) } -} \ No newline at end of file +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 523c084..a976cda 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -147,7 +147,7 @@ impl AppConfig { /// 4. Default values (lowest) pub fn load() -> Result { let args = args::parse_args(); - + let mut builder = config::Config::builder() // Start with defaults .add_source(config::Config::try_from(&AppConfig::default())?); @@ -163,7 +163,7 @@ impl AppConfig { builder = builder.add_source( config::Environment::with_prefix("XRAY_ADMIN") .separator("__") - .try_parsing(true) + .try_parsing(true), ); // Override with command line arguments @@ -184,10 +184,10 @@ impl AppConfig { } let config: AppConfig = builder.build()?.try_deserialize()?; - + // Validate configuration config.validate()?; - + Ok(config) } @@ -196,8 +196,18 @@ impl AppConfig { tracing::info!(" Database URL: {}", mask_sensitive(&self.database.url)); tracing::info!(" Web server: {}:{}", self.web.host, self.web.port); tracing::info!(" Log level: {}", self.logging.level); - tracing::info!(" Telegram bot: {}", if self.telegram.bot_token.is_empty() { "disabled" } else { "enabled" }); - tracing::info!(" Xray config path: {}", self.xray.config_template_path.display()); + tracing::info!( + " Telegram bot: {}", + if self.telegram.bot_token.is_empty() { + "disabled" + } else { + "enabled" + } + ); + tracing::info!( + " Xray config path: {}", + self.xray.config_template_path.display() + ); } } @@ -216,7 +226,7 @@ fn mask_sensitive(url: &str) -> String { } } } - + // Fallback to URL parsing if simple approach fails if let Ok(parsed) = url::Url::parse(url) { if parsed.password().is_some() { @@ -249,4 +259,4 @@ mod tests { assert!(masked.contains("***")); assert!(!masked.contains("password")); } -} \ No newline at end of file +} diff --git a/src/database/entities/certificate.rs b/src/database/entities/certificate.rs index 4cdea42..b5cd7e9 100644 --- a/src/database/entities/certificate.rs +++ b/src/database/entities/certificate.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -7,29 +7,29 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + pub name: String, - + #[sea_orm(column_name = "cert_type")] pub cert_type: String, - + pub domain: String, - + #[serde(skip_serializing)] pub cert_data: Vec, - + #[serde(skip_serializing)] pub key_data: Vec, - + #[serde(skip_serializing)] pub chain_data: Option>, - + pub expires_at: DateTimeUtc, - + pub auto_renew: bool, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -180,7 +182,7 @@ impl From for CertificateDetailsResponse { fn from(cert: Model) -> Self { let certificate_pem = cert.certificate_pem(); let has_private_key = !cert.key_data.is_empty(); - + Self { id: cert.id, name: cert.name, @@ -220,14 +222,14 @@ impl Model { pub fn apply_update(self, dto: UpdateCertificateDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(name) = dto.name { active_model.name = Set(name); } if let Some(auto_renew) = dto.auto_renew { active_model.auto_renew = Set(auto_renew); } - + active_model } } @@ -246,4 +248,4 @@ impl From for ActiveModel { ..Self::new() } } -} \ No newline at end of file +} diff --git a/src/database/entities/dns_provider.rs b/src/database/entities/dns_provider.rs index e244afb..bcba458 100644 --- a/src/database/entities/dns_provider.rs +++ b/src/database/entities/dns_provider.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -8,18 +8,18 @@ use uuid::Uuid; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + pub name: String, - + pub provider_type: String, // "cloudflare", "route53", etc. - + #[serde(skip_serializing)] pub api_token: String, // Encrypted storage in production - + pub is_active: bool, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -40,7 +40,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -100,7 +102,7 @@ impl Model { /// Update this model with data from UpdateDnsProviderDto pub fn apply_update(self, dto: UpdateDnsProviderDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(name) = dto.name { active_model.name = Set(name); } @@ -110,11 +112,11 @@ impl Model { if let Some(is_active) = dto.is_active { active_model.is_active = Set(is_active); } - + active_model.updated_at = Set(chrono::Utc::now()); active_model } - + /// Convert to response DTO (without exposing API token) pub fn to_response_dto(&self) -> DnsProviderResponseDto { DnsProviderResponseDto { @@ -142,15 +144,15 @@ impl DnsProviderType { DnsProviderType::Cloudflare => "cloudflare", } } - + pub fn from_str(s: &str) -> Option { match s { "cloudflare" => Some(DnsProviderType::Cloudflare), _ => None, } } - + pub fn all() -> Vec { vec![DnsProviderType::Cloudflare] } -} \ No newline at end of file +} diff --git a/src/database/entities/inbound_template.rs b/src/database/entities/inbound_template.rs index df2aff1..733b359 100644 --- a/src/database/entities/inbound_template.rs +++ b/src/database/entities/inbound_template.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -8,29 +8,29 @@ use serde_json::Value; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + pub name: String, - + pub description: Option, - + pub protocol: String, - + pub default_port: i32, - + pub base_settings: Value, - + pub stream_settings: Value, - + pub requires_tls: bool, - + pub requires_domain: bool, - + pub variables: Value, - + pub is_active: bool, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -60,7 +60,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -187,9 +189,9 @@ impl From for InboundTemplateResponse { impl From for ActiveModel { fn from(dto: CreateInboundTemplateDto) -> Self { // Parse config_template as JSON or use default - let config_json: Value = serde_json::from_str(&dto.config_template) - .unwrap_or_else(|_| serde_json::json!({})); - + let config_json: Value = + serde_json::from_str(&dto.config_template).unwrap_or_else(|_| serde_json::json!({})); + Self { name: Set(dto.name), description: Set(None), @@ -212,17 +214,20 @@ impl Model { } #[allow(dead_code)] - pub fn apply_variables(&self, values: &serde_json::Map) -> Result<(Value, Value), String> { + pub fn apply_variables( + &self, + values: &serde_json::Map, + ) -> Result<(Value, Value), String> { let base_settings = self.base_settings.clone(); let stream_settings = self.stream_settings.clone(); - + // Replace variables in JSON using simple string replacement let base_str = base_settings.to_string(); let stream_str = stream_settings.to_string(); - + let mut result_base = base_str; let mut result_stream = stream_str; - + for (key, value) in values { let placeholder = format!("${{{}}}", key); let replacement = match value { @@ -233,18 +238,18 @@ impl Model { result_base = result_base.replace(&placeholder, &replacement); result_stream = result_stream.replace(&placeholder, &replacement); } - + let final_base: Value = serde_json::from_str(&result_base) .map_err(|e| format!("Invalid base settings after variable substitution: {}", e))?; let final_stream: Value = serde_json::from_str(&result_stream) .map_err(|e| format!("Invalid stream settings after variable substitution: {}", e))?; - + Ok((final_base, final_stream)) } pub fn apply_update(self, dto: UpdateInboundTemplateDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(name) = dto.name { active_model.name = Set(name); } @@ -267,12 +272,13 @@ impl Model { active_model.requires_domain = Set(requires_domain); } if let Some(variables) = dto.variables { - active_model.variables = Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![]))); + active_model.variables = + Set(serde_json::to_value(variables).unwrap_or(Value::Array(vec![]))); } if let Some(is_active) = dto.is_active { active_model.is_active = Set(is_active); } - + active_model } -} \ No newline at end of file +} diff --git a/src/database/entities/inbound_users.rs b/src/database/entities/inbound_users.rs index 595881d..2262905 100644 --- a/src/database/entities/inbound_users.rs +++ b/src/database/entities/inbound_users.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -8,24 +8,24 @@ use uuid::Uuid; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + /// Reference to the actual user pub user_id: Uuid, - + pub server_inbound_id: Uuid, - + /// Generated xray user ID (UUID for protocols like vmess/vless) pub xray_user_id: String, - + /// Generated password for protocols like trojan/shadowsocks pub password: Option, - + pub level: i32, - + pub is_active: bool, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -71,7 +71,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -99,12 +101,12 @@ impl CreateInboundUserDto { pub fn generate_xray_user_id(&self) -> String { Uuid::new_v4().to_string() } - + /// Generate random password (for trojan/shadowsocks) pub fn generate_password(&self) -> String { - use rand::prelude::*; use rand::distributions::Alphanumeric; - + use rand::prelude::*; + thread_rng() .sample_iter(&Alphanumeric) .take(24) @@ -123,7 +125,7 @@ pub struct UpdateInboundUserDto { impl From for ActiveModel { fn from(dto: CreateInboundUserDto) -> Self { let xray_user_id = dto.generate_xray_user_id(); - + Self { user_id: Set(dto.user_id), server_inbound_id: Set(dto.server_inbound_id), @@ -140,17 +142,17 @@ impl Model { /// Update this model with data from UpdateInboundUserDto pub fn apply_update(self, dto: UpdateInboundUserDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(level) = dto.level { active_model.level = Set(level); } if let Some(is_active) = dto.is_active { active_model.is_active = Set(is_active); } - + active_model } - + /// Generate email for xray client based on user information pub fn generate_client_email(&self, username: &str) -> String { format!("{}@OutFleet", username) @@ -185,4 +187,4 @@ impl From for InboundUserResponse { updated_at: model.updated_at.to_rfc3339(), } } -} \ No newline at end of file +} diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index d010aad..a46e4dd 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -1,23 +1,23 @@ -pub mod user; pub mod certificate; pub mod dns_provider; pub mod inbound_template; +pub mod inbound_users; pub mod server; pub mod server_inbound; -pub mod user_access; -pub mod inbound_users; pub mod telegram_config; +pub mod user; +pub mod user_access; pub mod user_request; pub mod prelude { - pub use super::user::Entity as User; pub use super::certificate::Entity as Certificate; pub use super::dns_provider::Entity as DnsProvider; pub use super::inbound_template::Entity as InboundTemplate; + pub use super::inbound_users::Entity as InboundUsers; pub use super::server::Entity as Server; pub use super::server_inbound::Entity as ServerInbound; - pub use super::user_access::Entity as UserAccess; - pub use super::inbound_users::Entity as InboundUsers; pub use super::telegram_config::Entity as TelegramConfig; + pub use super::user::Entity as User; + pub use super::user_access::Entity as UserAccess; pub use super::user_request::Entity as UserRequest; -} \ No newline at end of file +} diff --git a/src/database/entities/server.rs b/src/database/entities/server.rs index f58c8b4..411ce14 100644 --- a/src/database/entities/server.rs +++ b/src/database/entities/server.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -7,24 +7,24 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + pub name: String, - + pub hostname: String, - + pub grpc_hostname: String, - + pub grpc_port: i32, - + #[serde(skip_serializing)] pub api_credentials: Option, - + pub status: String, - + pub default_certificate_id: Option, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -67,7 +67,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -185,7 +187,7 @@ impl From for ServerResponse { impl Model { pub fn apply_update(self, dto: UpdateServerDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(name) = dto.name { active_model.name = Set(name); } @@ -207,16 +209,23 @@ impl Model { if let Some(default_certificate_id) = dto.default_certificate_id { active_model.default_certificate_id = Set(Some(default_certificate_id)); } - + active_model } pub fn get_grpc_endpoint(&self) -> String { let hostname = if self.grpc_hostname.is_empty() { - tracing::debug!("Using public hostname '{}' for gRPC (grpc_hostname is empty)", self.hostname); + tracing::debug!( + "Using public hostname '{}' for gRPC (grpc_hostname is empty)", + self.hostname + ); &self.hostname } else { - tracing::debug!("Using dedicated gRPC hostname '{}' (different from public hostname '{}')", self.grpc_hostname, self.hostname); + tracing::debug!( + "Using dedicated gRPC hostname '{}' (different from public hostname '{}')", + self.grpc_hostname, + self.hostname + ); &self.grpc_hostname }; let endpoint = format!("{}:{}", hostname, self.grpc_port); @@ -228,4 +237,4 @@ impl Model { pub fn get_status(&self) -> ServerStatus { self.status.clone().into() } -} \ No newline at end of file +} diff --git a/src/database/entities/server_inbound.rs b/src/database/entities/server_inbound.rs index 32a71d8..fe74dcf 100644 --- a/src/database/entities/server_inbound.rs +++ b/src/database/entities/server_inbound.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -8,23 +8,23 @@ use serde_json::Value; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + pub server_id: Uuid, - + pub template_id: Uuid, - + pub tag: String, - + pub port_override: Option, - + pub certificate_id: Option, - + pub variable_values: Value, - + pub is_active: bool, - + pub created_at: DateTimeUtc, - + pub updated_at: DateTimeUtc, } @@ -82,7 +82,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -95,7 +97,6 @@ impl ActiveModelBehavior for ActiveModel { Ok(self) }) } - } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -145,7 +146,7 @@ impl From for ServerInboundResponse { is_active: inbound.is_active, created_at: inbound.created_at, updated_at: inbound.updated_at, - template_name: None, // Will be filled by repository if needed + template_name: None, // Will be filled by repository if needed certificate_name: None, // Will be filled by repository if needed } } @@ -154,7 +155,7 @@ impl From for ServerInboundResponse { impl Model { pub fn apply_update(self, dto: UpdateServerInboundDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(tag) = dto.tag { active_model.tag = Set(tag); } @@ -170,7 +171,7 @@ impl Model { if let Some(is_active) = dto.is_active { active_model.is_active = Set(is_active); } - + active_model } @@ -201,4 +202,4 @@ impl From for ActiveModel { ..Self::new() } } -} \ No newline at end of file +} diff --git a/src/database/entities/telegram_config.rs b/src/database/entities/telegram_config.rs index d12462e..7c47bbe 100644 --- a/src/database/entities/telegram_config.rs +++ b/src/database/entities/telegram_config.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -7,16 +7,16 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + /// Telegram bot token (encrypted in production) pub bot_token: String, - + /// Whether the bot is active pub is_active: bool, - + /// When the config was created pub created_at: DateTimeUtc, - + /// Last time config was updated pub updated_at: DateTimeUtc, } @@ -40,7 +40,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -52,15 +54,15 @@ impl ActiveModelBehavior for ActiveModel { } else if self.id.is_not_set() { self.id = Set(Uuid::new_v4()); } - + if self.created_at.is_not_set() { self.created_at = Set(chrono::Utc::now()); } - + if self.updated_at.is_not_set() { self.updated_at = Set(chrono::Utc::now()); } - + Ok(self) }) } @@ -91,4 +93,4 @@ impl Model { updated_at: Set(self.updated_at), } } -} \ No newline at end of file +} diff --git a/src/database/entities/user.rs b/src/database/entities/user.rs index b9a7f67..ace6a39 100644 --- a/src/database/entities/user.rs +++ b/src/database/entities/user.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -7,23 +7,23 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + /// User display name pub name: String, - + /// Optional comment/description about the user #[sea_orm(column_type = "Text")] pub comment: Option, - + /// Optional Telegram user ID for bot integration pub telegram_id: Option, - + /// Whether the user is a Telegram admin pub is_telegram_admin: bool, - + /// When the user was registered/created pub created_at: DateTimeUtc, - + /// Last time user record was updated pub updated_at: DateTimeUtc, } @@ -48,7 +48,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -98,7 +100,7 @@ impl Model { /// Update this model with data from UpdateUserDto pub fn apply_update(self, dto: UpdateUserDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(name) = dto.name { active_model.name = Set(name); } @@ -114,7 +116,7 @@ impl Model { if let Some(is_admin) = dto.is_telegram_admin { active_model.is_telegram_admin = Set(is_admin); } - + active_model } @@ -147,9 +149,12 @@ mod tests { }; let active_model: ActiveModel = dto.into(); - + assert_eq!(active_model.name.unwrap(), "Test User"); - assert_eq!(active_model.comment.unwrap(), Some("Test comment".to_string())); + assert_eq!( + active_model.comment.unwrap(), + Some("Test comment".to_string()) + ); assert_eq!(active_model.telegram_id.unwrap(), Some(123456789)); } @@ -193,4 +198,4 @@ mod tests { assert!(user_with_telegram.has_telegram()); assert!(!user_without_telegram.has_telegram()); } -} \ No newline at end of file +} diff --git a/src/database/entities/user_access.rs b/src/database/entities/user_access.rs index 2a222cf..3d8b1e9 100644 --- a/src/database/entities/user_access.rs +++ b/src/database/entities/user_access.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -use sea_orm::{Set, ActiveModelTrait}; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -7,31 +7,31 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - + /// User ID this access is for pub user_id: Uuid, - + /// Server ID this access applies to pub server_id: Uuid, - + /// Server inbound ID this access applies to pub server_inbound_id: Uuid, - + /// User's unique identifier in xray (UUID for VLESS/VMess, password for Trojan) pub xray_user_id: String, - + /// User's email in xray pub xray_email: String, - + /// User level in xray (0-255) pub level: i32, - + /// Whether this access is currently active pub is_active: bool, - + /// When this access was created pub created_at: DateTimeUtc, - + /// Last time this access was updated pub updated_at: DateTimeUtc, } @@ -46,7 +46,7 @@ pub enum Relation { User, #[sea_orm( belongs_to = "super::server::Entity", - from = "Column::ServerId", + from = "Column::ServerId", to = "super::server::Column::Id" )] Server, @@ -90,7 +90,9 @@ impl ActiveModelBehavior for ActiveModel { mut self, _db: &'life0 C, insert: bool, - ) -> core::pin::Pin> + Send + 'async_trait>> + ) -> core::pin::Pin< + Box> + Send + 'async_trait>, + > where 'life0: 'async_trait, C: 'async_trait + ConnectionTrait, @@ -103,7 +105,6 @@ impl ActiveModelBehavior for ActiveModel { Ok(self) }) } - } /// User access creation data transfer object @@ -143,14 +144,14 @@ impl Model { /// Update this model with data from UpdateUserAccessDto pub fn apply_update(self, dto: UpdateUserAccessDto) -> ActiveModel { let mut active_model: ActiveModel = self.into(); - + if let Some(is_active) = dto.is_active { active_model.is_active = Set(is_active); } if let Some(level) = dto.level { active_model.level = Set(level); } - + active_model } } @@ -185,4 +186,4 @@ impl From for UserAccessResponse { updated_at: model.updated_at.to_rfc3339(), } } -} \ No newline at end of file +} diff --git a/src/database/entities/user_request.rs b/src/database/entities/user_request.rs index af99b7c..9744080 100644 --- a/src/database/entities/user_request.rs +++ b/src/database/entities/user_request.rs @@ -90,7 +90,9 @@ impl Model { parts.push(last.clone()); } if parts.is_empty() { - self.telegram_username.clone().unwrap_or_else(|| format!("User {}", self.telegram_id)) + self.telegram_username + .clone() + .unwrap_or_else(|| format!("User {}", self.telegram_id)) } else { parts.join(" ") } @@ -130,7 +132,7 @@ pub struct UpdateUserRequestDto { impl From for ActiveModel { fn from(dto: CreateUserRequestDto) -> Self { use sea_orm::ActiveValue::*; - + ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(None), @@ -153,20 +155,20 @@ impl From for ActiveModel { impl Model { pub fn apply_update(self, dto: UpdateUserRequestDto, processed_by: Uuid) -> ActiveModel { use sea_orm::ActiveValue::*; - + let mut active: ActiveModel = self.into(); - + if let Some(status) = dto.status { active.status = Set(status); active.processed_by_user_id = Set(Some(processed_by)); active.processed_at = Set(Some(chrono::Utc::now().into())); } - + if let Some(response) = dto.response_message { active.response_message = Set(Some(response)); } - + active.updated_at = Set(chrono::Utc::now().into()); active } -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000001_create_users_table.rs b/src/database/migrations/m20241201_000001_create_users_table.rs index 4e38563..37a7e3e 100644 --- a/src/database/migrations/m20241201_000001_create_users_table.rs +++ b/src/database/migrations/m20241201_000001_create_users_table.rs @@ -12,27 +12,10 @@ impl MigrationTrait for Migration { Table::create() .table(Users::Table) .if_not_exists() - .col( - ColumnDef::new(Users::Id) - .uuid() - .not_null() - .primary_key(), - ) - .col( - ColumnDef::new(Users::Name) - .string_len(255) - .not_null(), - ) - .col( - ColumnDef::new(Users::Comment) - .text() - .null(), - ) - .col( - ColumnDef::new(Users::TelegramId) - .big_integer() - .null(), - ) + .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Users::Name).string_len(255).not_null()) + .col(ColumnDef::new(Users::Comment).text().null()) + .col(ColumnDef::new(Users::TelegramId).big_integer().null()) .col( ColumnDef::new(Users::CreatedAt) .timestamp_with_time_zone() @@ -108,12 +91,7 @@ impl MigrationTrait for Migration { .await?; manager - .drop_index( - Index::drop() - .if_exists() - .name("idx_users_name") - .to_owned(), - ) + .drop_index(Index::drop().if_exists().name("idx_users_name").to_owned()) .await?; // Drop table @@ -132,4 +110,4 @@ enum Users { TelegramId, CreatedAt, UpdatedAt, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000002_create_certificates_table.rs b/src/database/migrations/m20241201_000002_create_certificates_table.rs index a4b6722..24c070c 100644 --- a/src/database/migrations/m20241201_000002_create_certificates_table.rs +++ b/src/database/migrations/m20241201_000002_create_certificates_table.rs @@ -32,21 +32,9 @@ impl MigrationTrait for Migration { .string_len(255) .not_null(), ) - .col( - ColumnDef::new(Certificates::CertData) - .blob() - .not_null(), - ) - .col( - ColumnDef::new(Certificates::KeyData) - .blob() - .not_null(), - ) - .col( - ColumnDef::new(Certificates::ChainData) - .blob() - .null(), - ) + .col(ColumnDef::new(Certificates::CertData).blob().not_null()) + .col(ColumnDef::new(Certificates::KeyData).blob().not_null()) + .col(ColumnDef::new(Certificates::ChainData).blob().null()) .col( ColumnDef::new(Certificates::ExpiresAt) .timestamp_with_time_zone() @@ -117,4 +105,4 @@ enum Certificates { AutoRenew, CreatedAt, UpdatedAt, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000003_create_inbound_templates_table.rs b/src/database/migrations/m20241201_000003_create_inbound_templates_table.rs index ab83340..d89d31e 100644 --- a/src/database/migrations/m20241201_000003_create_inbound_templates_table.rs +++ b/src/database/migrations/m20241201_000003_create_inbound_templates_table.rs @@ -22,11 +22,7 @@ impl MigrationTrait for Migration { .string_len(255) .not_null(), ) - .col( - ColumnDef::new(InboundTemplates::Description) - .text() - .null(), - ) + .col(ColumnDef::new(InboundTemplates::Description).text().null()) .col( ColumnDef::new(InboundTemplates::Protocol) .string_len(50) @@ -152,4 +148,4 @@ enum InboundTemplates { IsActive, CreatedAt, UpdatedAt, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000004_create_servers_table.rs b/src/database/migrations/m20241201_000004_create_servers_table.rs index c044c6c..fc378af 100644 --- a/src/database/migrations/m20241201_000004_create_servers_table.rs +++ b/src/database/migrations/m20241201_000004_create_servers_table.rs @@ -11,44 +11,23 @@ impl MigrationTrait for Migration { Table::create() .table(Servers::Table) .if_not_exists() - .col( - ColumnDef::new(Servers::Id) - .uuid() - .not_null() - .primary_key(), - ) - .col( - ColumnDef::new(Servers::Name) - .string_len(255) - .not_null(), - ) - .col( - ColumnDef::new(Servers::Hostname) - .string_len(255) - .not_null(), - ) + .col(ColumnDef::new(Servers::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Servers::Name).string_len(255).not_null()) + .col(ColumnDef::new(Servers::Hostname).string_len(255).not_null()) .col( ColumnDef::new(Servers::GrpcPort) .integer() .default(2053) .not_null(), ) - .col( - ColumnDef::new(Servers::ApiCredentials) - .text() - .null(), - ) + .col(ColumnDef::new(Servers::ApiCredentials).text().null()) .col( ColumnDef::new(Servers::Status) .string_len(50) .default("unknown") .not_null(), ) - .col( - ColumnDef::new(Servers::DefaultCertificateId) - .uuid() - .null(), - ) + .col(ColumnDef::new(Servers::DefaultCertificateId).uuid().null()) .col( ColumnDef::new(Servers::CreatedAt) .timestamp_with_time_zone() @@ -133,4 +112,4 @@ enum Servers { enum Certificates { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000005_create_server_inbounds_table.rs b/src/database/migrations/m20241201_000005_create_server_inbounds_table.rs index f342fda..e49e343 100644 --- a/src/database/migrations/m20241201_000005_create_server_inbounds_table.rs +++ b/src/database/migrations/m20241201_000005_create_server_inbounds_table.rs @@ -17,16 +17,8 @@ impl MigrationTrait for Migration { .not_null() .primary_key(), ) - .col( - ColumnDef::new(ServerInbounds::ServerId) - .uuid() - .not_null(), - ) - .col( - ColumnDef::new(ServerInbounds::TemplateId) - .uuid() - .not_null(), - ) + .col(ColumnDef::new(ServerInbounds::ServerId).uuid().not_null()) + .col(ColumnDef::new(ServerInbounds::TemplateId).uuid().not_null()) .col( ColumnDef::new(ServerInbounds::Tag) .string_len(255) @@ -37,11 +29,7 @@ impl MigrationTrait for Migration { .integer() .null(), ) - .col( - ColumnDef::new(ServerInbounds::CertificateId) - .uuid() - .null(), - ) + .col(ColumnDef::new(ServerInbounds::CertificateId).uuid().null()) .col( ColumnDef::new(ServerInbounds::VariableValues) .json() @@ -192,4 +180,4 @@ enum InboundTemplates { enum Certificates { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000006_create_user_access_table.rs b/src/database/migrations/m20241201_000006_create_user_access_table.rs index f247302..7a3752a 100644 --- a/src/database/migrations/m20241201_000006_create_user_access_table.rs +++ b/src/database/migrations/m20241201_000006_create_user_access_table.rs @@ -17,41 +17,17 @@ impl MigrationTrait for Migration { .not_null() .primary_key(), ) - .col( - ColumnDef::new(UserAccess::UserId) - .uuid() - .not_null(), - ) - .col( - ColumnDef::new(UserAccess::ServerId) - .uuid() - .not_null(), - ) + .col(ColumnDef::new(UserAccess::UserId).uuid().not_null()) + .col(ColumnDef::new(UserAccess::ServerId).uuid().not_null()) .col( ColumnDef::new(UserAccess::ServerInboundId) .uuid() .not_null(), ) - .col( - ColumnDef::new(UserAccess::XrayUserId) - .string() - .not_null(), - ) - .col( - ColumnDef::new(UserAccess::XrayEmail) - .string() - .not_null(), - ) - .col( - ColumnDef::new(UserAccess::Level) - .integer() - .not_null(), - ) - .col( - ColumnDef::new(UserAccess::IsActive) - .boolean() - .not_null(), - ) + .col(ColumnDef::new(UserAccess::XrayUserId).string().not_null()) + .col(ColumnDef::new(UserAccess::XrayEmail).string().not_null()) + .col(ColumnDef::new(UserAccess::Level).integer().not_null()) + .col(ColumnDef::new(UserAccess::IsActive).boolean().not_null()) .col( ColumnDef::new(UserAccess::CreatedAt) .timestamp_with_time_zone() @@ -193,4 +169,4 @@ enum Servers { enum ServerInbounds { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20241201_000007_create_inbound_users_table.rs b/src/database/migrations/m20241201_000007_create_inbound_users_table.rs index c801546..dd51cee 100644 --- a/src/database/migrations/m20241201_000007_create_inbound_users_table.rs +++ b/src/database/migrations/m20241201_000007_create_inbound_users_table.rs @@ -22,21 +22,9 @@ impl MigrationTrait for Migration { .uuid() .not_null(), ) - .col( - ColumnDef::new(InboundUsers::Username) - .string() - .not_null(), - ) - .col( - ColumnDef::new(InboundUsers::Email) - .string() - .not_null(), - ) - .col( - ColumnDef::new(InboundUsers::XrayUserId) - .string() - .not_null(), - ) + .col(ColumnDef::new(InboundUsers::Username).string().not_null()) + .col(ColumnDef::new(InboundUsers::Email).string().not_null()) + .col(ColumnDef::new(InboundUsers::XrayUserId).string().not_null()) .col( ColumnDef::new(InboundUsers::Level) .integer() @@ -122,4 +110,4 @@ enum InboundUsers { enum ServerInbounds { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20250919_000001_update_inbound_users_schema.rs b/src/database/migrations/m20250919_000001_update_inbound_users_schema.rs index a7da632..0793794 100644 --- a/src/database/migrations/m20250919_000001_update_inbound_users_schema.rs +++ b/src/database/migrations/m20250919_000001_update_inbound_users_schema.rs @@ -36,22 +36,18 @@ impl MigrationTrait for Migration { ColumnDef::new(InboundUsers::UserId) .uuid() .not_null() - .default(Expr::val("00000000-0000-0000-0000-000000000000")) + .default(Expr::val("00000000-0000-0000-0000-000000000000")), ) .to_owned(), ) .await?; - // Add password column + // Add password column manager .alter_table( Table::alter() .table(InboundUsers::Table) - .add_column( - ColumnDef::new(InboundUsers::Password) - .string() - .null() - ) + .add_column(ColumnDef::new(InboundUsers::Password).string().null()) .to_owned(), ) .await?; @@ -83,7 +79,7 @@ impl MigrationTrait for Migration { .from(InboundUsers::Table, InboundUsers::UserId) .to(Users::Table, Users::Id) .on_delete(ForeignKeyAction::Cascade) - .to_owned() + .to_owned(), ) .await?; @@ -153,7 +149,7 @@ impl MigrationTrait for Migration { ColumnDef::new(InboundUsers::Username) .string() .not_null() - .default("") + .default(""), ) .to_owned(), ) @@ -167,7 +163,7 @@ impl MigrationTrait for Migration { ColumnDef::new(InboundUsers::Email) .string() .not_null() - .default("") + .default(""), ) .to_owned(), ) @@ -239,4 +235,4 @@ enum InboundUsers { enum Users { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs b/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs index 2484730..2c6808e 100644 --- a/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs +++ b/src/database/migrations/m20250922_000001_add_grpc_hostname_to_servers.rs @@ -22,7 +22,7 @@ impl MigrationTrait for Migration { // Update existing servers: set grpc_hostname to hostname value let db = manager.get_connection(); - + // Use raw SQL to copy hostname to grpc_hostname for existing records // Handle both empty strings and default empty values db.execute_unprepared("UPDATE servers SET grpc_hostname = hostname WHERE grpc_hostname = '' OR grpc_hostname IS NULL") @@ -47,4 +47,4 @@ impl MigrationTrait for Migration { enum Servers { Table, GrpcHostname, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20250923_000001_create_dns_providers_table.rs b/src/database/migrations/m20250923_000001_create_dns_providers_table.rs index 4d5ffba..7d0d9cb 100644 --- a/src/database/migrations/m20250923_000001_create_dns_providers_table.rs +++ b/src/database/migrations/m20250923_000001_create_dns_providers_table.rs @@ -27,11 +27,7 @@ impl MigrationTrait for Migration { .string_len(50) .not_null(), ) - .col( - ColumnDef::new(DnsProviders::ApiToken) - .text() - .not_null(), - ) + .col(ColumnDef::new(DnsProviders::ApiToken).text().not_null()) .col( ColumnDef::new(DnsProviders::IsActive) .boolean() @@ -93,4 +89,4 @@ enum DnsProviders { IsActive, CreatedAt, UpdatedAt, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20250929_000001_create_telegram_config_table.rs b/src/database/migrations/m20250929_000001_create_telegram_config_table.rs index 0c22deb..0544e69 100644 --- a/src/database/migrations/m20250929_000001_create_telegram_config_table.rs +++ b/src/database/migrations/m20250929_000001_create_telegram_config_table.rs @@ -11,23 +11,29 @@ impl MigrationTrait for Migration { Table::create() .table(TelegramConfig::Table) .if_not_exists() - .col(ColumnDef::new(TelegramConfig::Id) - .uuid() - .not_null() - .primary_key()) - .col(ColumnDef::new(TelegramConfig::BotToken) - .string() - .not_null()) - .col(ColumnDef::new(TelegramConfig::IsActive) - .boolean() - .not_null() - .default(false)) - .col(ColumnDef::new(TelegramConfig::CreatedAt) - .timestamp_with_time_zone() - .not_null()) - .col(ColumnDef::new(TelegramConfig::UpdatedAt) - .timestamp_with_time_zone() - .not_null()) + .col( + ColumnDef::new(TelegramConfig::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(TelegramConfig::BotToken).string().not_null()) + .col( + ColumnDef::new(TelegramConfig::IsActive) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(TelegramConfig::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(TelegramConfig::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) .to_owned(), ) .await @@ -48,4 +54,4 @@ pub enum TelegramConfig { IsActive, CreatedAt, UpdatedAt, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs b/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs index 35978c9..af55418 100644 --- a/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs +++ b/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Users::IsTelegramAdmin) .boolean() .not_null() - .default(false) + .default(false), ) .to_owned(), ) @@ -37,4 +37,4 @@ impl MigrationTrait for Migration { enum Users { Table, IsTelegramAdmin, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20251018_000001_create_user_requests_table.rs b/src/database/migrations/m20251018_000001_create_user_requests_table.rs index 61a91ba..fc9185a 100644 --- a/src/database/migrations/m20251018_000001_create_user_requests_table.rs +++ b/src/database/migrations/m20251018_000001_create_user_requests_table.rs @@ -20,9 +20,7 @@ impl MigrationTrait for Migration { .default(Expr::cust("gen_random_uuid()")), ) .col( - ColumnDef::new(UserRequests::UserId) - .uuid() - .null(), // Can be null if user doesn't exist yet + ColumnDef::new(UserRequests::UserId).uuid().null(), // Can be null if user doesn't exist yet ) .col( ColumnDef::new(UserRequests::TelegramId) @@ -51,16 +49,8 @@ impl MigrationTrait for Migration { .not_null() .default("pending"), // pending, approved, declined ) - .col( - ColumnDef::new(UserRequests::RequestMessage) - .text() - .null(), - ) - .col( - ColumnDef::new(UserRequests::ResponseMessage) - .text() - .null(), - ) + .col(ColumnDef::new(UserRequests::RequestMessage).text().null()) + .col(ColumnDef::new(UserRequests::ResponseMessage).text().null()) .col( ColumnDef::new(UserRequests::ProcessedByUserId) .uuid() @@ -190,4 +180,4 @@ enum UserRequests { enum Users { Table, Id, -} \ No newline at end of file +} diff --git a/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs b/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs index 9f7fa77..4dbda9b 100644 --- a/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs +++ b/src/database/migrations/m20251018_000002_remove_unique_telegram_id.rs @@ -35,4 +35,4 @@ impl MigrationTrait for Migration { Ok(()) } -} \ No newline at end of file +} diff --git a/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs b/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs index 0f24742..f72c20c 100644 --- a/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs +++ b/src/database/migrations/m20251018_000003_add_language_to_user_requests.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { .add_column( ColumnDef::new(UserRequests::Language) .string() - .default("en") // Default to English + .default("en"), // Default to English ) .to_owned(), ) @@ -38,4 +38,4 @@ impl MigrationTrait for Migration { enum UserRequests { Table, Language, -} \ No newline at end of file +} diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs index 6eb7a39..b54c852 100644 --- a/src/database/migrations/mod.rs +++ b/src/database/migrations/mod.rs @@ -39,4 +39,4 @@ impl MigratorTrait for Migrator { Box::new(m20251018_000003_add_language_to_user_requests::Migration), ] } -} \ No newline at end of file +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 03ee4a8..fc1525f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use sea_orm::{Database, DatabaseConnection, ConnectOptions, Statement, DatabaseBackend, ConnectionTrait}; +use sea_orm::{ + ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, Statement, +}; use sea_orm_migration::MigratorTrait; use std::time::Duration; use tracing::{info, warn}; @@ -22,10 +24,10 @@ impl DatabaseManager { /// Create a new database connection pub async fn new(config: &DatabaseConfig) -> Result { info!("Connecting to database..."); - + // URL-encode the connection string to handle special characters in passwords let encoded_url = Self::encode_database_url(&config.url)?; - + let mut opt = ConnectOptions::new(&encoded_url); opt.max_connections(config.max_connections) .min_connections(1) @@ -37,16 +39,16 @@ impl DatabaseManager { .sqlx_logging_level(log::LevelFilter::Debug); let connection = Database::connect(opt).await?; - + info!("Database connection established successfully"); - + let manager = Self { connection }; - + // Run migrations if auto_migrate is enabled if config.auto_migrate { manager.migrate().await?; } - + Ok(manager) } @@ -58,7 +60,7 @@ impl DatabaseManager { /// Run database migrations pub async fn migrate(&self) -> Result<()> { info!("Running database migrations..."); - + match Migrator::up(&self.connection, None).await { Ok(_) => { info!("Database migrations completed successfully"); @@ -99,21 +101,22 @@ impl DatabaseManager { let scheme = &url[..scheme_end + 3]; let user_pass = &url[scheme_end + 3..at_pos]; let host_db = &url[at_pos..]; - + if let Some(user_colon) = user_pass.find(':') { let user = &user_pass[..user_colon]; let password = &user_pass[user_colon + 1..]; - + // URL-encode the password part only let encoded_password = urlencoding::encode(password); - let encoded_url = format!("{}{}:{}{}", scheme, user, encoded_password, host_db); - + let encoded_url = + format!("{}{}:{}{}", scheme, user, encoded_password, host_db); + return Ok(encoded_url); } } } } - + // If parsing fails, return original URL Ok(url.to_string()) } @@ -132,7 +135,10 @@ mod tests { let normal_url = "postgresql://user:password@localhost:5432/db"; let encoded_normal = DatabaseManager::encode_database_url(normal_url).unwrap(); - assert_eq!(encoded_normal, "postgresql://user:password@localhost:5432/db"); + assert_eq!( + encoded_normal, + "postgresql://user:password@localhost:5432/db" + ); } #[tokio::test] @@ -158,4 +164,4 @@ mod tests { assert!(health.is_ok()); } } -} \ No newline at end of file +} diff --git a/src/database/repository/certificate.rs b/src/database/repository/certificate.rs index 84861e5..974add3 100644 --- a/src/database/repository/certificate.rs +++ b/src/database/repository/certificate.rs @@ -1,6 +1,6 @@ -use sea_orm::*; use crate::database::entities::{certificate, prelude::*}; use anyhow::Result; +use sea_orm::*; use uuid::Uuid; #[derive(Clone)] @@ -13,11 +13,14 @@ impl CertificateRepository { Self { db } } - pub async fn create(&self, cert_data: certificate::CreateCertificateDto) -> Result { + pub async fn create( + &self, + cert_data: certificate::CreateCertificateDto, + ) -> Result { let cert = certificate::ActiveModel::from(cert_data); let result = Certificate::insert(cert).exec(&self.db).await?; - + Certificate::find_by_id(result.last_insert_id) .one(&self.db) .await? @@ -48,7 +51,11 @@ impl CertificateRepository { .await?) } - pub async fn update(&self, id: Uuid, cert_data: certificate::UpdateCertificateDto) -> Result { + pub async fn update( + &self, + id: Uuid, + cert_data: certificate::UpdateCertificateDto, + ) -> Result { let cert = Certificate::find_by_id(id) .one(&self.db) .await? @@ -66,7 +73,7 @@ impl CertificateRepository { pub async fn find_expiring_soon(&self, days: i64) -> Result> { let threshold = chrono::Utc::now() + chrono::Duration::days(days); - + Ok(Certificate::find() .filter(certificate::Column::ExpiresAt.lt(threshold)) .all(&self.db) @@ -75,11 +82,11 @@ impl CertificateRepository { /// Update certificate data (cert and key) and expiration date pub async fn update_certificate_data( - &self, - id: Uuid, - cert_pem: &str, + &self, + id: Uuid, + cert_pem: &str, key_pem: &str, - expires_at: chrono::DateTime + expires_at: chrono::DateTime, ) -> Result { let mut cert: certificate::ActiveModel = Certificate::find_by_id(id) .one(&self.db) @@ -94,4 +101,4 @@ impl CertificateRepository { Ok(cert.update(&self.db).await?) } -} \ No newline at end of file +} diff --git a/src/database/repository/dns_provider.rs b/src/database/repository/dns_provider.rs index 743b080..abb505d 100644 --- a/src/database/repository/dns_provider.rs +++ b/src/database/repository/dns_provider.rs @@ -1,9 +1,12 @@ use anyhow::Result; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set, PaginatorTrait}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + Set, +}; use uuid::Uuid; use crate::database::entities::dns_provider::{ - Entity, Model, ActiveModel, CreateDnsProviderDto, UpdateDnsProviderDto, Column, DnsProviderType + ActiveModel, Column, CreateDnsProviderDto, DnsProviderType, Entity, Model, UpdateDnsProviderDto, }; pub struct DnsProviderRepository { @@ -89,7 +92,7 @@ impl DnsProviderRepository { let mut active_model: ActiveModel = provider.into(); active_model.is_active = Set(true); active_model.updated_at = Set(chrono::Utc::now()); - + let updated_provider = active_model.update(&self.db).await?; Ok(Some(updated_provider)) } @@ -103,7 +106,7 @@ impl DnsProviderRepository { let mut active_model: ActiveModel = provider.into(); active_model.is_active = Set(false); active_model.updated_at = Set(chrono::Utc::now()); - + let updated_provider = active_model.update(&self.db).await?; Ok(Some(updated_provider)) } @@ -111,17 +114,20 @@ impl DnsProviderRepository { /// Check if a provider name already exists pub async fn name_exists(&self, name: &str, exclude_id: Option) -> Result { let mut query = Entity::find().filter(Column::Name.eq(name)); - + if let Some(id) = exclude_id { query = query.filter(Column::Id.ne(id)); } - + let count = query.count(&self.db).await?; Ok(count > 0) } /// Get the first active provider of a specific type - pub async fn get_active_provider_by_type(&self, provider_type: DnsProviderType) -> Result> { + pub async fn get_active_provider_by_type( + &self, + provider_type: DnsProviderType, + ) -> Result> { let provider = Entity::find() .filter(Column::ProviderType.eq(provider_type.as_str())) .filter(Column::IsActive.eq(true)) @@ -129,4 +135,4 @@ impl DnsProviderRepository { .await?; Ok(provider) } -} \ No newline at end of file +} diff --git a/src/database/repository/inbound_template.rs b/src/database/repository/inbound_template.rs index 3bedd81..152c89e 100644 --- a/src/database/repository/inbound_template.rs +++ b/src/database/repository/inbound_template.rs @@ -1,6 +1,6 @@ -use sea_orm::*; use crate::database::entities::{inbound_template, prelude::*}; use anyhow::Result; +use sea_orm::*; use uuid::Uuid; #[derive(Clone)] @@ -14,11 +14,14 @@ impl InboundTemplateRepository { Self { db } } - pub async fn create(&self, template_data: inbound_template::CreateInboundTemplateDto) -> Result { + pub async fn create( + &self, + template_data: inbound_template::CreateInboundTemplateDto, + ) -> Result { let template = inbound_template::ActiveModel::from(template_data); let result = InboundTemplate::insert(template).exec(&self.db).await?; - + InboundTemplate::find_by_id(result.last_insert_id) .one(&self.db) .await? @@ -47,7 +50,11 @@ impl InboundTemplateRepository { .await?) } - pub async fn update(&self, id: Uuid, template_data: inbound_template::UpdateInboundTemplateDto) -> Result { + pub async fn update( + &self, + id: Uuid, + template_data: inbound_template::UpdateInboundTemplateDto, + ) -> Result { let template = InboundTemplate::find_by_id(id) .one(&self.db) .await? @@ -62,4 +69,4 @@ impl InboundTemplateRepository { let result = InboundTemplate::delete_by_id(id).exec(&self.db).await?; Ok(result.rows_affected > 0) } -} \ No newline at end of file +} diff --git a/src/database/repository/inbound_users.rs b/src/database/repository/inbound_users.rs index ccafbbe..84752bc 100644 --- a/src/database/repository/inbound_users.rs +++ b/src/database/repository/inbound_users.rs @@ -1,9 +1,9 @@ use anyhow::Result; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use crate::database::entities::inbound_users::{ - Entity, Model, ActiveModel, CreateInboundUserDto, UpdateInboundUserDto, Column + ActiveModel, Column, CreateInboundUserDto, Entity, Model, UpdateInboundUserDto, }; use crate::services::uri_generator::ClientConfigData; @@ -46,7 +46,11 @@ impl InboundUsersRepository { } /// Find user by user_id and inbound (for uniqueness check - one user per inbound) - pub async fn find_by_user_and_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result> { + pub async fn find_by_user_and_inbound( + &self, + user_id: Uuid, + inbound_id: Uuid, + ) -> Result> { let user = Entity::find() .filter(Column::UserId.eq(user_id)) .filter(Column::ServerInboundId.eq(inbound_id)) @@ -96,7 +100,7 @@ impl InboundUsersRepository { let mut active_model: ActiveModel = user.into(); active_model.is_active = Set(true); active_model.updated_at = Set(chrono::Utc::now()); - + let updated_user = active_model.update(&self.db).await?; Ok(Some(updated_user)) } @@ -111,7 +115,7 @@ impl InboundUsersRepository { let mut active_model: ActiveModel = user.into(); active_model.is_active = Set(false); active_model.updated_at = Set(chrono::Utc::now()); - + let updated_user = active_model.update(&self.db).await?; Ok(Some(updated_user)) } @@ -126,17 +130,25 @@ impl InboundUsersRepository { } /// Check if user already has access to this inbound - pub async fn user_has_access_to_inbound(&self, user_id: Uuid, inbound_id: Uuid) -> Result { + pub async fn user_has_access_to_inbound( + &self, + user_id: Uuid, + inbound_id: Uuid, + ) -> Result { let exists = self.find_by_user_and_inbound(user_id, inbound_id).await?; Ok(exists.is_some()) } /// Get complete client configuration data for URI generation - pub async fn get_client_config_data(&self, user_id: Uuid, server_inbound_id: Uuid) -> Result> { + pub async fn get_client_config_data( + &self, + user_id: Uuid, + server_inbound_id: Uuid, + ) -> Result> { use crate::database::entities::{ - user, server, server_inbound, inbound_template, certificate + certificate, inbound_template, server, server_inbound, user, }; - + // Get the inbound_user record first let inbound_user = Entity::find() .filter(Column::UserId.eq(user_id)) @@ -144,32 +156,34 @@ impl InboundUsersRepository { .filter(Column::IsActive.eq(true)) .one(&self.db) .await?; - + if let Some(inbound_user) = inbound_user { // Get user info let user_entity = user::Entity::find_by_id(inbound_user.user_id) .one(&self.db) .await? .ok_or_else(|| anyhow::anyhow!("User not found"))?; - + // Get server inbound info - let server_inbound_entity = server_inbound::Entity::find_by_id(inbound_user.server_inbound_id) - .one(&self.db) - .await? - .ok_or_else(|| anyhow::anyhow!("Server inbound not found"))?; - + let server_inbound_entity = + server_inbound::Entity::find_by_id(inbound_user.server_inbound_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Server inbound not found"))?; + // Get server info let server_entity = server::Entity::find_by_id(server_inbound_entity.server_id) .one(&self.db) .await? .ok_or_else(|| anyhow::anyhow!("Server not found"))?; - + // Get template info - let template_entity = inbound_template::Entity::find_by_id(server_inbound_entity.template_id) - .one(&self.db) - .await? - .ok_or_else(|| anyhow::anyhow!("Template not found"))?; - + let template_entity = + inbound_template::Entity::find_by_id(server_inbound_entity.template_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Template not found"))?; + // Get certificate info (optional) let certificate_domain = if let Some(cert_id) = server_inbound_entity.certificate_id { certificate::Entity::find_by_id(cert_id) @@ -179,14 +193,16 @@ impl InboundUsersRepository { } else { None }; - + let config = ClientConfigData { user_name: user_entity.name, xray_user_id: inbound_user.xray_user_id, password: inbound_user.password, level: inbound_user.level, hostname: server_entity.hostname, - port: server_inbound_entity.port_override.unwrap_or(template_entity.default_port), + port: server_inbound_entity + .port_override + .unwrap_or(template_entity.default_port), protocol: template_entity.protocol, stream_settings: template_entity.stream_settings, base_settings: template_entity.base_settings, @@ -197,7 +213,7 @@ impl InboundUsersRepository { inbound_tag: server_inbound_entity.tag, template_name: template_entity.name, }; - + Ok(Some(config)) } else { Ok(None) @@ -205,23 +221,29 @@ impl InboundUsersRepository { } /// Get all client configuration data for a user - pub async fn get_all_client_configs_for_user(&self, user_id: Uuid) -> Result> { + pub async fn get_all_client_configs_for_user( + &self, + user_id: Uuid, + ) -> Result> { // Get all active inbound users for this user let inbound_users = Entity::find() .filter(Column::UserId.eq(user_id)) .filter(Column::IsActive.eq(true)) .all(&self.db) .await?; - + let mut configs = Vec::new(); - + for inbound_user in inbound_users { // Get the client config data for each inbound - if let Ok(Some(config)) = self.get_client_config_data(user_id, inbound_user.server_inbound_id).await { + if let Ok(Some(config)) = self + .get_client_config_data(user_id, inbound_user.server_inbound_id) + .await + { configs.push(config); } } - + Ok(configs) } -} \ No newline at end of file +} diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 2e81045..7dc7cde 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -1,21 +1,21 @@ -pub mod user; pub mod certificate; pub mod dns_provider; pub mod inbound_template; +pub mod inbound_users; pub mod server; pub mod server_inbound; -pub mod user_access; -pub mod inbound_users; pub mod telegram_config; +pub mod user; +pub mod user_access; pub mod user_request; -pub use user::UserRepository; pub use certificate::CertificateRepository; pub use dns_provider::DnsProviderRepository; pub use inbound_template::InboundTemplateRepository; +pub use inbound_users::InboundUsersRepository; pub use server::ServerRepository; pub use server_inbound::ServerInboundRepository; -pub use user_access::UserAccessRepository; -pub use inbound_users::InboundUsersRepository; pub use telegram_config::TelegramConfigRepository; -pub use user_request::UserRequestRepository; \ No newline at end of file +pub use user::UserRepository; +pub use user_access::UserAccessRepository; +pub use user_request::UserRequestRepository; diff --git a/src/database/repository/server.rs b/src/database/repository/server.rs index 8869838..7cf7425 100644 --- a/src/database/repository/server.rs +++ b/src/database/repository/server.rs @@ -1,6 +1,6 @@ -use sea_orm::*; -use crate::database::entities::{server, prelude::*}; +use crate::database::entities::{prelude::*, server}; use anyhow::Result; +use sea_orm::*; use uuid::Uuid; #[derive(Clone)] @@ -18,7 +18,7 @@ impl ServerRepository { let server = server::ActiveModel::from(server_data); let result = Server::insert(server).exec(&self.db).await?; - + Server::find_by_id(result.last_insert_id) .one(&self.db) .await? @@ -54,7 +54,11 @@ impl ServerRepository { .await?) } - pub async fn update(&self, id: Uuid, server_data: server::UpdateServerDto) -> Result { + pub async fn update( + &self, + id: Uuid, + server_data: server::UpdateServerDto, + ) -> Result { let server = Server::find_by_id(id) .one(&self.db) .await? @@ -71,9 +75,11 @@ impl ServerRepository { } pub async fn get_grpc_endpoint(&self, id: Uuid) -> Result { - let server = self.find_by_id(id).await? + let server = self + .find_by_id(id) + .await? .ok_or_else(|| anyhow::anyhow!("Server not found"))?; - + Ok(server.get_grpc_endpoint()) } @@ -85,4 +91,4 @@ impl ServerRepository { let count = Server::find().count(&self.db).await?; Ok(count) } -} \ No newline at end of file +} diff --git a/src/database/repository/server_inbound.rs b/src/database/repository/server_inbound.rs index eab44fb..913f075 100644 --- a/src/database/repository/server_inbound.rs +++ b/src/database/repository/server_inbound.rs @@ -1,6 +1,6 @@ -use sea_orm::*; -use crate::database::entities::{server_inbound, prelude::*}; +use crate::database::entities::{prelude::*, server_inbound}; use anyhow::Result; +use sea_orm::*; use uuid::Uuid; #[derive(Clone)] @@ -14,7 +14,11 @@ impl ServerInboundRepository { Self { db } } - pub async fn create(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto) -> Result { + pub async fn create( + &self, + server_id: Uuid, + inbound_data: server_inbound::CreateServerInboundDto, + ) -> Result { let mut inbound: server_inbound::ActiveModel = inbound_data.into(); inbound.id = Set(Uuid::new_v4()); inbound.server_id = Set(server_id); @@ -22,26 +26,31 @@ impl ServerInboundRepository { inbound.updated_at = Set(chrono::Utc::now()); let result = ServerInbound::insert(inbound).exec(&self.db).await?; - + ServerInbound::find_by_id(result.last_insert_id) .one(&self.db) .await? .ok_or_else(|| anyhow::anyhow!("Failed to retrieve created server inbound")) } - pub async fn create_with_protocol(&self, server_id: Uuid, inbound_data: server_inbound::CreateServerInboundDto, protocol: &str) -> Result { + pub async fn create_with_protocol( + &self, + server_id: Uuid, + inbound_data: server_inbound::CreateServerInboundDto, + protocol: &str, + ) -> Result { let mut inbound: server_inbound::ActiveModel = inbound_data.into(); inbound.id = Set(Uuid::new_v4()); inbound.server_id = Set(server_id); inbound.created_at = Set(chrono::Utc::now()); inbound.updated_at = Set(chrono::Utc::now()); - + // Override tag with protocol prefix let id = inbound.id.as_ref(); inbound.tag = Set(format!("{}-inbound-{}", protocol, id)); let result = ServerInbound::insert(inbound).exec(&self.db).await?; - + ServerInbound::find_by_id(result.last_insert_id) .one(&self.db) .await? @@ -63,9 +72,12 @@ impl ServerInboundRepository { .await?) } - pub async fn find_by_server_id_with_template(&self, server_id: Uuid) -> Result> { - use crate::database::entities::{inbound_template, certificate}; - + pub async fn find_by_server_id_with_template( + &self, + server_id: Uuid, + ) -> Result> { + use crate::database::entities::{certificate, inbound_template}; + let inbounds = ServerInbound::find() .filter(server_inbound::Column::ServerId.eq(server_id)) .all(&self.db) @@ -74,26 +86,33 @@ impl ServerInboundRepository { let mut responses = Vec::new(); for inbound in inbounds { let mut response = server_inbound::ServerInboundResponse::from(inbound.clone()); - + // Load template information - if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id).one(&self.db).await { + if let Ok(Some(template)) = InboundTemplate::find_by_id(inbound.template_id) + .one(&self.db) + .await + { response.template_name = Some(template.name); } - + // Load certificate information if let Some(cert_id) = inbound.certificate_id { - if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await { + if let Ok(Some(certificate)) = Certificate::find_by_id(cert_id).one(&self.db).await + { response.certificate_name = Some(certificate.domain); } } - + responses.push(response); } Ok(responses) } - pub async fn find_by_template_id(&self, template_id: Uuid) -> Result> { + pub async fn find_by_template_id( + &self, + template_id: Uuid, + ) -> Result> { Ok(ServerInbound::find() .filter(server_inbound::Column::TemplateId.eq(template_id)) .all(&self.db) @@ -107,14 +126,20 @@ impl ServerInboundRepository { .await?) } - pub async fn find_by_certificate_id(&self, certificate_id: Uuid) -> Result> { + pub async fn find_by_certificate_id( + &self, + certificate_id: Uuid, + ) -> Result> { Ok(ServerInbound::find() .filter(server_inbound::Column::CertificateId.eq(certificate_id)) .all(&self.db) .await?) } - pub async fn find_active_by_server(&self, server_id: Uuid) -> Result> { + pub async fn find_active_by_server( + &self, + server_id: Uuid, + ) -> Result> { Ok(ServerInbound::find() .filter(server_inbound::Column::ServerId.eq(server_id)) .filter(server_inbound::Column::IsActive.eq(true)) @@ -122,7 +147,11 @@ impl ServerInboundRepository { .await?) } - pub async fn update(&self, id: Uuid, inbound_data: server_inbound::UpdateServerInboundDto) -> Result { + pub async fn update( + &self, + id: Uuid, + inbound_data: server_inbound::UpdateServerInboundDto, + ) -> Result { let inbound = ServerInbound::find_by_id(id) .one(&self.db) .await? @@ -175,4 +204,4 @@ impl ServerInboundRepository { let count = ServerInbound::find().count(&self.db).await?; Ok(count) } -} \ No newline at end of file +} diff --git a/src/database/repository/telegram_config.rs b/src/database/repository/telegram_config.rs index 5fb332f..551132d 100644 --- a/src/database/repository/telegram_config.rs +++ b/src/database/repository/telegram_config.rs @@ -1,9 +1,11 @@ use anyhow::Result; -use sea_orm::{DatabaseConnection, EntityTrait, ActiveModelTrait, Set, QueryFilter, ColumnTrait, QueryOrder}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; use uuid::Uuid; use crate::database::entities::telegram_config::{ - self, Model, CreateTelegramConfigDto, UpdateTelegramConfigDto + self, CreateTelegramConfigDto, Model, UpdateTelegramConfigDto, }; pub struct TelegramConfigRepository { @@ -88,7 +90,7 @@ impl TelegramConfigRepository { /// Activate a configuration (deactivates all others) pub async fn activate(&self, id: Uuid) -> Result> { self.deactivate_all_except(id).await?; - + let model = telegram_config::Entity::find_by_id(id) .one(&self.db) .await?; @@ -164,4 +166,4 @@ impl TelegramConfigRepository { Ok(()) } -} \ No newline at end of file +} diff --git a/src/database/repository/user.rs b/src/database/repository/user.rs index 700b4d6..3b3b54b 100644 --- a/src/database/repository/user.rs +++ b/src/database/repository/user.rs @@ -1,9 +1,14 @@ use anyhow::Result; -use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, QuerySelect}; +use sea_orm::{ + ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, + QuerySelect, +}; use uuid::Uuid; -use sea_orm::{Set, ActiveModelTrait}; -use crate::database::entities::user::{Entity as User, Column, Model, ActiveModel, CreateUserDto, UpdateUserDto}; +use crate::database::entities::user::{ + ActiveModel, Column, CreateUserDto, Entity as User, Model, UpdateUserDto, +}; +use sea_orm::{ActiveModelTrait, Set}; pub struct UserRepository { db: DatabaseConnection, @@ -46,7 +51,12 @@ impl UserRepository { } /// Search users by name (with pagination for backward compatibility) - pub async fn search_by_name(&self, query: &str, page: u64, per_page: u64) -> Result> { + pub async fn search_by_name( + &self, + query: &str, + page: u64, + per_page: u64, + ) -> Result> { let users = User::find() .filter(Column::Name.contains(query)) .order_by_desc(Column::CreatedAt) @@ -60,22 +70,22 @@ impl UserRepository { /// Universal search - searches by name, telegram_id, or user_id pub async fn search(&self, query: &str) -> Result> { use sea_orm::Condition; - + let mut condition = Condition::any(); - + // Search by name (case-insensitive partial match) condition = condition.add(Column::Name.contains(query)); - + // Try to parse as telegram_id (i64) if let Ok(telegram_id) = query.parse::() { condition = condition.add(Column::TelegramId.eq(telegram_id)); } - + // Try to parse as UUID (user_id) if let Ok(user_id) = Uuid::parse_str(query) { condition = condition.add(Column::Id.eq(user_id)); } - + let users = User::find() .filter(condition) .order_by_desc(Column::CreatedAt) @@ -89,7 +99,9 @@ impl UserRepository { /// Create a new user pub async fn create(&self, dto: CreateUserDto) -> Result { let active_model: ActiveModel = dto.into(); - let user = User::insert(active_model).exec_with_returning(&self.db).await?; + let user = User::insert(active_model) + .exec_with_returning(&self.db) + .await?; Ok(user) } @@ -126,14 +138,13 @@ impl UserRepository { Ok(count > 0) } - /// Set user as Telegram admin pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result> { if let Some(user) = self.get_by_id(user_id).await? { let mut active_model: ActiveModel = user.into(); active_model.is_telegram_admin = Set(is_admin); active_model.updated_at = Set(chrono::Utc::now()); - + let updated = active_model.update(&self.db).await?; Ok(Some(updated)) } else { @@ -168,29 +179,46 @@ impl UserRepository { .await?; Ok(admins) } - + /// Get the first admin user (for system operations) pub async fn get_first_admin(&self) -> Result> { let admin = User::find() .filter(Column::IsTelegramAdmin.eq(true)) .one(&self.db) .await?; - + Ok(admin) } + + /// Count total users + pub async fn count_all(&self) -> Result { + let count = User::find().count(&self.db).await?; + + Ok(count as i64) + } + + /// Find users with pagination + pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result> { + let users = User::find() + .order_by_desc(Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&self.db) + .await?; + + Ok(users) + } } #[cfg(test)] mod tests { use super::*; - use crate::database::DatabaseManager; use crate::config::DatabaseConfig; + use crate::database::DatabaseManager; async fn setup_test_db() -> Result { let config = DatabaseConfig { - url: std::env::var("DATABASE_URL").unwrap_or_else(|_| - "sqlite::memory:".to_string() - ), + url: std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()), max_connections: 5, connection_timeout: 30, auto_migrate: true, @@ -243,4 +271,4 @@ mod tests { let deleted_user = repo.get_by_id(created_user.id).await.unwrap(); assert!(deleted_user.is_none()); } -} \ No newline at end of file +} diff --git a/src/database/repository/user_access.rs b/src/database/repository/user_access.rs index 63b5751..63bf54d 100644 --- a/src/database/repository/user_access.rs +++ b/src/database/repository/user_access.rs @@ -1,8 +1,10 @@ +use anyhow::Result; use sea_orm::*; use uuid::Uuid; -use anyhow::Result; -use crate::database::entities::user_access::{self, Entity as UserAccess, Model, ActiveModel, CreateUserAccessDto, UpdateUserAccessDto}; +use crate::database::entities::user_access::{ + self, ActiveModel, CreateUserAccessDto, Entity as UserAccess, Model, UpdateUserAccessDto, +}; pub struct UserAccessRepository { db: DatabaseConnection, @@ -35,7 +37,11 @@ impl UserAccessRepository { } /// Find user access by server and inbound - pub async fn find_by_server_inbound(&self, server_id: Uuid, server_inbound_id: Uuid) -> Result> { + pub async fn find_by_server_inbound( + &self, + server_id: Uuid, + server_inbound_id: Uuid, + ) -> Result> { let records = UserAccess::find() .filter(user_access::Column::ServerId.eq(server_id)) .filter(user_access::Column::ServerInboundId.eq(server_inbound_id)) @@ -45,7 +51,12 @@ impl UserAccessRepository { } /// Find active user access for specific user, server and inbound - pub async fn find_active_access(&self, user_id: Uuid, server_id: Uuid, server_inbound_id: Uuid) -> Result> { + pub async fn find_active_access( + &self, + user_id: Uuid, + server_id: Uuid, + server_inbound_id: Uuid, + ) -> Result> { let record = UserAccess::find() .filter(user_access::Column::UserId.eq(user_id)) .filter(user_access::Column::ServerId.eq(server_id)) @@ -83,18 +94,26 @@ impl UserAccessRepository { /// Enable user access (set is_active = true) pub async fn enable(&self, id: Uuid) -> Result> { - self.update(id, UpdateUserAccessDto { - is_active: Some(true), - level: None, - }).await + self.update( + id, + UpdateUserAccessDto { + is_active: Some(true), + level: None, + }, + ) + .await } /// Disable user access (set is_active = false) pub async fn disable(&self, id: Uuid) -> Result> { - self.update(id, UpdateUserAccessDto { - is_active: Some(false), - level: None, - }).await + self.update( + id, + UpdateUserAccessDto { + is_active: Some(false), + level: None, + }, + ) + .await } /// Get all active access for a user @@ -115,4 +134,4 @@ impl UserAccessRepository { .await?; Ok(result.rows_affected) } -} \ No newline at end of file +} diff --git a/src/database/repository/user_request.rs b/src/database/repository/user_request.rs index fce93c5..be60b74 100644 --- a/src/database/repository/user_request.rs +++ b/src/database/repository/user_request.rs @@ -1,9 +1,12 @@ -use anyhow::Result; -use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, QueryOrder, PaginatorTrait, QuerySelect}; -use uuid::Uuid; use crate::database::entities::user_request::{ - self, Model, ActiveModel, CreateUserRequestDto, UpdateUserRequestDto, RequestStatus + self, ActiveModel, CreateUserRequestDto, Model, RequestStatus, UpdateUserRequestDto, }; +use anyhow::Result; +use sea_orm::{ + ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, + QuerySelect, +}; +use uuid::Uuid; pub struct UserRequestRepository { db: DatabaseConnection, @@ -18,10 +21,10 @@ impl UserRequestRepository { let paginator = user_request::Entity::find() .order_by_desc(user_request::Column::CreatedAt) .paginate(&self.db, per_page); - + let total = paginator.num_items().await?; let items = paginator.fetch_page(page - 1).await?; - + Ok((items, total)) } @@ -30,17 +33,15 @@ impl UserRequestRepository { .filter(user_request::Column::Status.eq("pending")) .order_by_desc(user_request::Column::CreatedAt) .paginate(&self.db, per_page); - + let total = paginator.num_items().await?; let items = paginator.fetch_page(page - 1).await?; - + Ok((items, total)) } pub async fn find_by_id(&self, id: Uuid) -> Result> { - let request = user_request::Entity::find_by_id(id) - .one(&self.db) - .await?; + let request = user_request::Entity::find_by_id(id).one(&self.db).await?; Ok(request) } @@ -73,6 +74,25 @@ impl UserRequestRepository { Ok(request) } + /// Count total requests + pub async fn count_all(&self) -> Result { + let count = user_request::Entity::find().count(&self.db).await?; + + Ok(count as i64) + } + + /// Find requests with pagination + pub async fn find_paginated(&self, offset: u64, limit: u64) -> Result> { + let requests = user_request::Entity::find() + .order_by_desc(user_request::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&self.db) + .await?; + + Ok(requests) + } + pub async fn create(&self, dto: CreateUserRequestDto) -> Result { use sea_orm::ActiveModelTrait; let active_model: ActiveModel = dto.into(); @@ -80,11 +100,14 @@ impl UserRequestRepository { Ok(request) } - pub async fn update(&self, id: Uuid, dto: UpdateUserRequestDto, processed_by: Uuid) -> Result> { - let model = user_request::Entity::find_by_id(id) - .one(&self.db) - .await?; - + pub async fn update( + &self, + id: Uuid, + dto: UpdateUserRequestDto, + processed_by: Uuid, + ) -> Result> { + let model = user_request::Entity::find_by_id(id).one(&self.db).await?; + match model { Some(model) => { use sea_orm::ActiveModelTrait; @@ -96,7 +119,12 @@ impl UserRequestRepository { } } - pub async fn approve(&self, id: Uuid, response_message: Option, processed_by: Uuid) -> Result> { + pub async fn approve( + &self, + id: Uuid, + response_message: Option, + processed_by: Uuid, + ) -> Result> { let dto = UpdateUserRequestDto { status: Some(RequestStatus::Approved.as_str().to_string()), response_message, @@ -105,7 +133,12 @@ impl UserRequestRepository { self.update(id, dto, processed_by).await } - pub async fn decline(&self, id: Uuid, response_message: Option, processed_by: Uuid) -> Result> { + pub async fn decline( + &self, + id: Uuid, + response_message: Option, + processed_by: Uuid, + ) -> Result> { let dto = UpdateUserRequestDto { status: Some(RequestStatus::Declined.as_str().to_string()), response_message, @@ -128,4 +161,4 @@ impl UserRequestRepository { .await?; Ok(count) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 9720ece..b69a7e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,9 @@ mod database; mod services; mod web; -use config::{AppConfig, args::parse_args}; +use config::{args::parse_args, AppConfig}; use database::DatabaseManager; -use services::{TaskScheduler, XrayService, TelegramService}; +use services::{TaskScheduler, TelegramService, XrayService}; #[tokio::main] async fn main() -> Result<()> { @@ -24,7 +24,6 @@ async fn main() -> Result<()> { // Initialize logging early with basic configuration init_logging(&args.log_level.as_deref().unwrap_or("info"))?; - // Handle special flags if args.print_default_config { print_default_config()?; @@ -33,9 +32,7 @@ async fn main() -> Result<()> { // Load configuration let config = match AppConfig::load() { - Ok(config) => { - config - } + Ok(config) => config, Err(e) => { tracing::error!("Failed to load configuration: {}", e); if args.validate_config { @@ -58,12 +55,9 @@ async fn main() -> Result<()> { config::env::EnvVars::print_env_info(); } - // Initialize database connection let db = match DatabaseManager::new(&config.database).await { - Ok(db) => { - db - } + Ok(db) => db, Err(e) => { tracing::error!("Failed to initialize database: {}", e); return Err(e); @@ -82,7 +76,7 @@ async fn main() -> Result<()> { // Initialize xray service let xray_service = XrayService::new(); - + // Initialize and start task scheduler with dependencies let mut task_scheduler = TaskScheduler::new().await?; task_scheduler.start(db.clone(), xray_service).await?; @@ -97,7 +91,7 @@ async fn main() -> Result<()> { } // Start web server with task scheduler - + tokio::select! { result = web::start_server(db, config.clone(), Some(telegram_service.clone())) => { match result { @@ -123,12 +117,12 @@ fn init_logging(level: &str) -> Result<()> { .with(filter) .with( tracing_subscriber::fmt::layer() - .with_target(true) // Show module names + .with_target(true) // Show module names .with_thread_ids(false) .with_thread_names(false) .with_file(false) .with_line_number(false) - .compact() + .compact(), ) .try_init()?; @@ -138,11 +132,11 @@ fn init_logging(level: &str) -> Result<()> { fn print_default_config() -> Result<()> { let default_config = AppConfig::default(); let toml_content = toml::to_string_pretty(&default_config)?; - + println!("# Default configuration for Xray Admin Panel"); println!("# Save this to config.toml and modify as needed\n"); println!("{}", toml_content); - + Ok(()) } @@ -179,4 +173,4 @@ mod tests { let masked = mask_url(url); assert_eq!(masked, url); } -} \ No newline at end of file +} diff --git a/src/services/acme/client.rs b/src/services/acme/client.rs index 00a2121..ebf6f31 100644 --- a/src/services/acme/client.rs +++ b/src/services/acme/client.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use tokio::time::sleep; use tracing::{debug, info, warn}; -use crate::services::acme::{CloudflareClient, AcmeError}; +use crate::services::acme::{AcmeError, CloudflareClient}; pub struct AcmeClient { cloudflare: CloudflareClient, @@ -21,7 +21,7 @@ impl AcmeClient { directory_url: String, ) -> Result { info!("Creating ACME client for directory: {}", directory_url); - + let cloudflare = CloudflareClient::new(cloudflare_token)?; // Create Let's Encrypt account @@ -47,17 +47,24 @@ impl AcmeClient { }) } - pub async fn get_certificate(&mut self, domain: &str, base_domain: &str) -> Result<(String, String), AcmeError> { + pub async fn get_certificate( + &mut self, + domain: &str, + base_domain: &str, + ) -> Result<(String, String), AcmeError> { info!("Starting certificate request for domain: {}", domain); // Validate domain if domain.is_empty() || base_domain.is_empty() { - return Err(AcmeError::InvalidDomain("Domain cannot be empty".to_string())); + return Err(AcmeError::InvalidDomain( + "Domain cannot be empty".to_string(), + )); } // Create a new order let identifiers = vec![Identifier::Dns(domain.to_string())]; - let mut order = self.account + let mut order = self + .account .new_order(&NewOrder::new(&identifiers)) .await .map_err(|e| AcmeError::OrderCreation(e.to_string()))?; @@ -66,13 +73,12 @@ impl AcmeClient { // Process authorizations let mut authorizations = order.authorizations(); - + while let Some(authz_result) = authorizations.next().await { - let mut authz = authz_result - .map_err(|e| AcmeError::Challenge(e.to_string()))?; - + let mut authz = authz_result.map_err(|e| AcmeError::Challenge(e.to_string()))?; + let identifier = format!("{:?}", authz.identifier()); - + if authz.status == AuthorizationStatus::Valid { info!("Authorization already valid for: {:?}", identifier); continue; @@ -93,7 +99,8 @@ impl AcmeClient { // Create DNS record let challenge_domain = format!("_acme-challenge.{}", domain); - let record_id = self.cloudflare + let record_id = self + .cloudflare .create_txt_record(base_domain, &challenge_domain, &challenge_value) .await?; @@ -105,9 +112,11 @@ impl AcmeClient { // Submit challenge info!("Submitting challenge..."); - challenge.set_ready().await + challenge + .set_ready() + .await .map_err(|e| AcmeError::Challenge(e.to_string()))?; - + (challenge_value, record_id) }; @@ -129,7 +138,9 @@ impl AcmeClient { return Err(AcmeError::Challenge("Order processing timeout".to_string())); } - order.refresh().await + order + .refresh() + .await .map_err(|e| AcmeError::OrderCreation(e.to_string()))?; match order.state().status { @@ -154,55 +165,73 @@ impl AcmeClient { // Generate CSR info!("Generating certificate signing request..."); let mut params = CertificateParams::new(vec![domain.to_string()]); - + params.distinguished_name = DistinguishedName::new(); - + let key_pair = KeyPair::generate(&rcgen::PKCS_ECDSA_P256_SHA256) .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; - + // Set the key pair for CSR generation params.key_pair = Some(key_pair); - + // Generate CSR using rcgen certificate let cert = rcgen::Certificate::from_params(params) .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; - let csr_der = cert.serialize_request_der() + let csr_der = cert + .serialize_request_der() .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; // Finalize order with CSR info!("Finalizing order with CSR..."); - order.finalize_csr(&csr_der).await + order + .finalize_csr(&csr_der) + .await .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; - + // Wait for certificate to be ready info!("Waiting for certificate to be generated..."); let start = Instant::now(); let timeout = Duration::from_secs(300); // 5 minutes - + let cert_chain_pem = loop { if start.elapsed() > timeout { - return Err(AcmeError::CertificateGeneration("Certificate generation timeout".to_string())); + return Err(AcmeError::CertificateGeneration( + "Certificate generation timeout".to_string(), + )); } - order.refresh().await + order + .refresh() + .await .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?; match order.state().status { OrderStatus::Valid => { info!("Certificate is ready!"); - break order.certificate().await + break order + .certificate() + .await .map_err(|e| AcmeError::CertificateGeneration(e.to_string()))? - .ok_or_else(|| AcmeError::CertificateGeneration("Certificate not available".to_string()))?; + .ok_or_else(|| { + AcmeError::CertificateGeneration( + "Certificate not available".to_string(), + ) + })?; } OrderStatus::Invalid => { - return Err(AcmeError::CertificateGeneration("Order became invalid during certificate generation".to_string())); + return Err(AcmeError::CertificateGeneration( + "Order became invalid during certificate generation".to_string(), + )); } OrderStatus::Processing => { debug!("Certificate still being processed, waiting..."); sleep(Duration::from_secs(3)).await; } _ => { - debug!("Waiting for certificate, order status: {:?}", order.state().status); + debug!( + "Waiting for certificate, order status: {:?}", + order.state().status + ); sleep(Duration::from_secs(3)).await; } } @@ -214,12 +243,16 @@ impl AcmeClient { Ok((cert_chain_pem, private_key_pem)) } - async fn wait_for_dns_propagation(&self, record_name: &str, expected_value: &str) -> Result<(), AcmeError> { + async fn wait_for_dns_propagation( + &self, + record_name: &str, + expected_value: &str, + ) -> Result<(), AcmeError> { info!("Checking DNS propagation for: {}", record_name); - + let start = Instant::now(); let timeout = Duration::from_secs(120); // 2 minutes - + while start.elapsed() < timeout { match self.check_dns_txt_record(record_name, expected_value).await { Ok(true) => { @@ -233,17 +266,21 @@ impl AcmeClient { debug!("DNS check failed: {:?}", e); } } - + sleep(Duration::from_secs(10)).await; } - + warn!("DNS propagation timeout, but continuing anyway"); Ok(()) } - async fn check_dns_txt_record(&self, record_name: &str, expected_value: &str) -> Result { + async fn check_dns_txt_record( + &self, + record_name: &str, + expected_value: &str, + ) -> Result { use std::process::Command; - + let output = Command::new("dig") .args(&["+short", "TXT", record_name]) .output() @@ -268,7 +305,11 @@ impl AcmeClient { } async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) { - if let Err(e) = self.cloudflare.delete_txt_record(base_domain, record_id).await { + if let Err(e) = self + .cloudflare + .delete_txt_record(base_domain, record_id) + .await + { warn!("Failed to cleanup DNS record {}: {:?}", record_id, e); } } @@ -277,11 +318,13 @@ impl AcmeClient { pub fn get_base_domain(domain: &str) -> Result { let parts: Vec<&str> = domain.split('.').collect(); if parts.len() < 2 { - return Err(AcmeError::InvalidDomain("Domain must have at least 2 parts".to_string())); + return Err(AcmeError::InvalidDomain( + "Domain must have at least 2 parts".to_string(), + )); } - + // Take the last two parts for base domain let base_domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]); Ok(base_domain) } -} \ No newline at end of file +} diff --git a/src/services/acme/cloudflare.rs b/src/services/acme/cloudflare.rs index 6eef8c6..74c9b65 100644 --- a/src/services/acme/cloudflare.rs +++ b/src/services/acme/cloudflare.rs @@ -74,10 +74,11 @@ impl CloudflareClient { async fn get_zone_id(&self, domain: &str) -> Result { info!("Getting Cloudflare zone ID for domain: {}", domain); - + let url = format!("https://api.cloudflare.com/client/v4/zones?name={}", domain); - - let response = self.client + + let response = self + .client .get(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .header("Content-Type", "application/json") @@ -87,7 +88,10 @@ impl CloudflareClient { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(AcmeError::CloudflareApi(format!("HTTP {}: {}", status, body))); + return Err(AcmeError::CloudflareApi(format!( + "HTTP {}: {}", + status, body + ))); } let zones: CloudflareZonesResponse = response.json().await?; @@ -95,17 +99,28 @@ impl CloudflareClient { if !zones.success { let errors = zones.errors.unwrap_or_default(); let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); - return Err(AcmeError::CloudflareApi(format!("API errors: {}", error_messages.join(", ")))); + return Err(AcmeError::CloudflareApi(format!( + "API errors: {}", + error_messages.join(", ") + ))); } - zones.result + zones + .result .into_iter() .find(|z| z.name == domain) .map(|z| z.id) - .ok_or_else(|| AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain))) + .ok_or_else(|| { + AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain)) + }) } - pub async fn create_txt_record(&self, domain: &str, record_name: &str, content: &str) -> Result { + pub async fn create_txt_record( + &self, + domain: &str, + record_name: &str, + content: &str, + ) -> Result { let zone_id = self.get_zone_id(domain).await?; info!("Creating TXT record {} in zone {}", record_name, domain); @@ -116,9 +131,13 @@ impl CloudflareClient { ttl: 120, // 2 minutes TTL for quick propagation }; - let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id); + let url = format!( + "https://api.cloudflare.com/client/v4/zones/{}/dns_records", + zone_id + ); - let response = self.client + let response = self + .client .post(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .header("Content-Type", "application/json") @@ -129,7 +148,10 @@ impl CloudflareClient { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(AcmeError::CloudflareApi(format!("Failed to create DNS record ({}): {}", status, body))); + return Err(AcmeError::CloudflareApi(format!( + "Failed to create DNS record ({}): {}", + status, body + ))); } let result: CreateDnsRecordResponse = response.json().await?; @@ -137,7 +159,10 @@ impl CloudflareClient { if !result.success { let errors = result.errors.unwrap_or_default(); let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); - return Err(AcmeError::CloudflareApi(format!("Failed to create record: {}", error_messages.join(", ")))); + return Err(AcmeError::CloudflareApi(format!( + "Failed to create record: {}", + error_messages.join(", ") + ))); } debug!("Created DNS record with ID: {}", result.result.id); @@ -148,9 +173,13 @@ impl CloudflareClient { let zone_id = self.get_zone_id(domain).await?; info!("Deleting TXT record {} from zone {}", record_id, domain); - let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", zone_id, record_id); + let url = format!( + "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", + zone_id, record_id + ); - let response = self.client + let response = self + .client .delete(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .send() @@ -159,22 +188,30 @@ impl CloudflareClient { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(AcmeError::CloudflareApi(format!("Failed to delete DNS record ({}): {}", status, body))); + return Err(AcmeError::CloudflareApi(format!( + "Failed to delete DNS record ({}): {}", + status, body + ))); } info!("Successfully deleted DNS record"); Ok(()) } - pub async fn find_txt_record(&self, domain: &str, record_name: &str) -> Result, AcmeError> { + pub async fn find_txt_record( + &self, + domain: &str, + record_name: &str, + ) -> Result, AcmeError> { let zone_id = self.get_zone_id(domain).await?; - + let url = format!( "https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=TXT&name={}", zone_id, record_name ); - let response = self.client + let response = self + .client .get(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .send() @@ -183,7 +220,10 @@ impl CloudflareClient { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(AcmeError::CloudflareApi(format!("Failed to list DNS records ({}): {}", status, body))); + return Err(AcmeError::CloudflareApi(format!( + "Failed to list DNS records ({}): {}", + status, body + ))); } let records: CloudflareDnsRecordsResponse = response.json().await?; @@ -191,9 +231,12 @@ impl CloudflareClient { if !records.success { let errors = records.errors.unwrap_or_default(); let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); - return Err(AcmeError::CloudflareApi(format!("Failed to list records: {}", error_messages.join(", ")))); + return Err(AcmeError::CloudflareApi(format!( + "Failed to list records: {}", + error_messages.join(", ") + ))); } Ok(records.result.first().map(|r| r.id.clone())) } -} \ No newline at end of file +} diff --git a/src/services/acme/error.rs b/src/services/acme/error.rs index a93cae2..f0357de 100644 --- a/src/services/acme/error.rs +++ b/src/services/acme/error.rs @@ -4,37 +4,37 @@ use thiserror::Error; pub enum AcmeError { #[error("ACME account creation failed: {0}")] AccountCreation(String), - + #[error("ACME order creation failed: {0}")] OrderCreation(String), - + #[error("ACME challenge failed: {0}")] Challenge(String), - + #[error("DNS propagation timeout")] DnsPropagationTimeout, - + #[error("Certificate generation failed: {0}")] CertificateGeneration(String), - + #[error("Cloudflare API error: {0}")] CloudflareApi(String), - + #[error("DNS provider not found")] DnsProviderNotFound, - + #[error("Invalid domain: {0}")] InvalidDomain(String), - + #[error("HTTP request failed: {0}")] HttpRequest(#[from] reqwest::Error), - + #[error("JSON parsing failed: {0}")] JsonParsing(#[from] serde_json::Error), - + #[error("IO error: {0}")] Io(#[from] std::io::Error), - + #[error("Instant ACME error: {0}")] InstantAcme(String), -} \ No newline at end of file +} diff --git a/src/services/acme/mod.rs b/src/services/acme/mod.rs index cc33168..ca9dccd 100644 --- a/src/services/acme/mod.rs +++ b/src/services/acme/mod.rs @@ -4,4 +4,4 @@ pub mod error; pub use client::AcmeClient; pub use cloudflare::CloudflareClient; -pub use error::AcmeError; \ No newline at end of file +pub use error::AcmeError; diff --git a/src/services/certificates.rs b/src/services/certificates.rs index a70d49d..44405d8 100644 --- a/src/services/certificates.rs +++ b/src/services/certificates.rs @@ -1,10 +1,13 @@ -use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType, KeyPair, PKCS_ECDSA_P256_SHA256}; +use rcgen::{ + Certificate, CertificateParams, DistinguishedName, DnType, KeyPair, SanType, + PKCS_ECDSA_P256_SHA256, +}; use std::net::IpAddr; use time::{Duration, OffsetDateTime}; use uuid::Uuid; -use crate::database::repository::DnsProviderRepository; use crate::database::entities::dns_provider::DnsProviderType; +use crate::database::repository::DnsProviderRepository; use crate::services::acme::{AcmeClient, AcmeError}; use sea_orm::DatabaseConnection; @@ -19,7 +22,7 @@ impl CertificateService { pub fn new() -> Self { Self { db: None } } - + pub fn with_db(db: DatabaseConnection) -> Self { Self { db: Some(db) } } @@ -27,17 +30,17 @@ impl CertificateService { /// Generate self-signed certificate optimized for Xray pub async fn generate_self_signed(&self, domain: &str) -> anyhow::Result<(String, String)> { tracing::info!("Generating self-signed certificate for domain: {}", domain); - + // Create certificate parameters with ECDSA (recommended for Xray) let mut params = CertificateParams::new(vec![domain.to_string()]); - + // Use ECDSA P-256 which is recommended for Xray (equivalent to RSA-3072 in strength) params.alg = &PKCS_ECDSA_P256_SHA256; - + // Generate ECDSA key pair let key_pair = KeyPair::generate(&PKCS_ECDSA_P256_SHA256)?; params.key_pair = Some(key_pair); - + // Set certificate subject with proper fields let mut distinguished_name = DistinguishedName::new(); distinguished_name.push(DnType::CommonName, domain); @@ -47,57 +50,60 @@ impl CertificateService { distinguished_name.push(DnType::StateOrProvinceName, "State"); distinguished_name.push(DnType::LocalityName, "City"); params.distinguished_name = distinguished_name; - + // Add comprehensive Subject Alternative Names for better compatibility let mut san_list = vec![ SanType::DnsName(domain.to_string()), SanType::DnsName("localhost".to_string()), ]; - + // Add IP addresses if domain looks like an IP if let Ok(ip) = domain.parse::() { san_list.push(SanType::IpAddress(ip)); } - + // Always add localhost IP for local testing - san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)))); - + san_list.push(SanType::IpAddress(IpAddr::V4(std::net::Ipv4Addr::new( + 127, 0, 0, 1, + )))); + // If domain is not an IP, also add wildcard subdomain if domain.parse::().is_err() && !domain.starts_with("*.") { san_list.push(SanType::DnsName(format!("*.{}", domain))); } - + params.subject_alt_names = san_list; - + // Set validity period (1 year as recommended) params.not_before = OffsetDateTime::now_utc(); params.not_after = OffsetDateTime::now_utc() + Duration::days(365); - + // Set serial number params.serial_number = Some(rcgen::SerialNumber::from_slice(&[1, 2, 3, 4])); - + // Generate certificate let cert = Certificate::from_params(params)?; - + // Get PEM format with proper formatting let cert_pem = cert.serialize_pem()?; let key_pem = cert.serialize_private_key_pem(); - + // Validate PEM format - if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----") || !cert_pem.ends_with("-----END CERTIFICATE-----\n") { + if !cert_pem.starts_with("-----BEGIN CERTIFICATE-----") + || !cert_pem.ends_with("-----END CERTIFICATE-----\n") + { return Err(anyhow::anyhow!("Invalid certificate PEM format")); } - + if !key_pem.starts_with("-----BEGIN") || !key_pem.contains("PRIVATE KEY-----") { return Err(anyhow::anyhow!("Invalid private key PEM format")); } - + tracing::debug!("Generated ECDSA P-256 certificate for domain: {}", domain); - + Ok((cert_pem, key_pem)) } - /// Generate Let's Encrypt certificate using DNS challenge pub async fn generate_letsencrypt_certificate( &self, @@ -106,123 +112,148 @@ impl CertificateService { acme_email: &str, staging: bool, ) -> Result<(String, String), AcmeError> { - tracing::info!("Generating Let's Encrypt certificate for domain: {} using DNS challenge", domain); - + tracing::info!( + "Generating Let's Encrypt certificate for domain: {} using DNS challenge", + domain + ); + // Get database connection - let db = self.db.as_ref() + let db = self + .db + .as_ref() .ok_or_else(|| AcmeError::DnsProviderNotFound)?; - + // Get DNS provider let dns_repo = DnsProviderRepository::new(db.clone()); - let dns_provider = dns_repo.find_by_id(dns_provider_id) + let dns_provider = dns_repo + .find_by_id(dns_provider_id) .await .map_err(|_| AcmeError::DnsProviderNotFound)? .ok_or_else(|| AcmeError::DnsProviderNotFound)?; - + // Verify provider is Cloudflare (only supported provider for now) if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() { - return Err(AcmeError::CloudflareApi("Only Cloudflare provider is supported".to_string())); + return Err(AcmeError::CloudflareApi( + "Only Cloudflare provider is supported".to_string(), + )); } - + if !dns_provider.is_active { return Err(AcmeError::DnsProviderNotFound); } - + // Determine ACME directory URL let directory_url = if staging { "https://acme-staging-v02.api.letsencrypt.org/directory" } else { "https://acme-v02.api.letsencrypt.org/directory" }; - + // Create ACME client let mut acme_client = AcmeClient::new( dns_provider.api_token.clone(), acme_email, directory_url.to_string(), - ).await?; - + ) + .await?; + // Get base domain for DNS operations let base_domain = AcmeClient::get_base_domain(domain)?; - + // Generate certificate - let (cert_pem, key_pem) = acme_client - .get_certificate(domain, &base_domain) - .await?; - - tracing::info!("Successfully generated Let's Encrypt certificate for domain: {}", domain); + let (cert_pem, key_pem) = acme_client.get_certificate(domain, &base_domain).await?; + + tracing::info!( + "Successfully generated Let's Encrypt certificate for domain: {}", + domain + ); Ok((cert_pem, key_pem)) } /// Renew certificate by ID (used for manual renewal) pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> { - let db = self.db.as_ref() + let db = self + .db + .as_ref() .ok_or_else(|| anyhow::anyhow!("Database connection not available"))?; - + // Get the certificate from database let cert_repo = crate::database::repository::CertificateRepository::new(db.clone()); - let certificate = cert_repo.find_by_id(cert_id) + let certificate = cert_repo + .find_by_id(cert_id) .await? .ok_or_else(|| anyhow::anyhow!("Certificate not found"))?; - - tracing::info!("Renewing certificate '{}' for domain: {}", certificate.name, certificate.domain); - + + tracing::info!( + "Renewing certificate '{}' for domain: {}", + certificate.name, + certificate.domain + ); + match certificate.cert_type.as_str() { "letsencrypt" => { // For Let's Encrypt, we need to regenerate using ACME // Find an active Cloudflare DNS provider let dns_repo = crate::database::repository::DnsProviderRepository::new(db.clone()); let providers = dns_repo.find_active_by_type("cloudflare").await?; - + if providers.is_empty() { - return Err(anyhow::anyhow!("No active Cloudflare DNS provider found for Let's Encrypt renewal")); + return Err(anyhow::anyhow!( + "No active Cloudflare DNS provider found for Let's Encrypt renewal" + )); } - + let dns_provider = &providers[0]; let acme_email = "admin@example.com"; // TODO: Store this with certificate - + // Generate new certificate - let (cert_pem, key_pem) = self.generate_letsencrypt_certificate( - &certificate.domain, - dns_provider.id, - acme_email, - false, // Production - ).await?; - + let (cert_pem, key_pem) = self + .generate_letsencrypt_certificate( + &certificate.domain, + dns_provider.id, + acme_email, + false, // Production + ) + .await?; + // Update in database - cert_repo.update_certificate_data( - cert_id, - &cert_pem, - &key_pem, - chrono::Utc::now() + chrono::Duration::days(90), - ).await?; - + cert_repo + .update_certificate_data( + cert_id, + &cert_pem, + &key_pem, + chrono::Utc::now() + chrono::Duration::days(90), + ) + .await?; + Ok((cert_pem, key_pem)) } "self_signed" => { // For self-signed, generate a new one let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?; - + // Update in database - cert_repo.update_certificate_data( - cert_id, - &cert_pem, - &key_pem, - chrono::Utc::now() + chrono::Duration::days(365), - ).await?; - + cert_repo + .update_certificate_data( + cert_id, + &cert_pem, + &key_pem, + chrono::Utc::now() + chrono::Duration::days(365), + ) + .await?; + Ok((cert_pem, key_pem)) } - _ => { - Err(anyhow::anyhow!("Cannot renew imported certificates automatically")) - } + _ => Err(anyhow::anyhow!( + "Cannot renew imported certificates automatically" + )), } } /// Renew certificate (legacy method for backward compatibility) pub async fn renew_certificate(&self, domain: &str) -> anyhow::Result<(String, String)> { tracing::info!("Renewing certificate for domain: {}", domain); - + // For backward compatibility, just generate a new self-signed certificate self.generate_self_signed(domain).await } @@ -232,4 +263,4 @@ impl Default for CertificateService { fn default() -> Self { Self::new() } -} \ No newline at end of file +} diff --git a/src/services/events.rs b/src/services/events.rs index 0e6ea31..d24e9d8 100644 --- a/src/services/events.rs +++ b/src/services/events.rs @@ -4,7 +4,7 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub enum SyncEvent { - InboundChanged(Uuid), // server_id + InboundChanged(Uuid), // server_id UserAccessChanged(Uuid), // server_id } @@ -27,4 +27,4 @@ pub fn send_sync_event(event: SyncEvent) { } else { tracing::error!("Event bus not initialized"); } -} \ No newline at end of file +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 4c52bbe..d52eecd 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,13 +1,13 @@ -pub mod xray; pub mod acme; pub mod certificates; pub mod events; pub mod tasks; -pub mod uri_generator; pub mod telegram; +pub mod uri_generator; +pub mod xray; -pub use xray::XrayService; -pub use tasks::TaskScheduler; -pub use uri_generator::UriGeneratorService; pub use certificates::CertificateService; -pub use telegram::TelegramService; \ No newline at end of file +pub use tasks::TaskScheduler; +pub use telegram::TelegramService; +pub use uri_generator::UriGeneratorService; +pub use xray::XrayService; diff --git a/src/services/tasks.rs b/src/services/tasks.rs index 52751af..b039bd1 100644 --- a/src/services/tasks.rs +++ b/src/services/tasks.rs @@ -1,18 +1,21 @@ -use anyhow::Result; -use tokio_cron_scheduler::{JobScheduler, Job}; -use tracing::{info, error, warn, debug}; -use crate::database::DatabaseManager; -use crate::database::repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, InboundUsersRepository, CertificateRepository, UserRepository}; use crate::database::entities::inbound_users; -use crate::services::XrayService; +use crate::database::repository::{ + CertificateRepository, InboundTemplateRepository, InboundUsersRepository, + ServerInboundRepository, ServerRepository, UserRepository, +}; +use crate::database::DatabaseManager; use crate::services::events::SyncEvent; -use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, RelationTrait, JoinType}; -use uuid::Uuid; +use crate::services::XrayService; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use sea_orm::{ColumnTrait, EntityTrait, JoinType, QueryFilter, RelationTrait}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::sync::{Arc, RwLock}; -use chrono::{DateTime, Utc}; -use serde::{Serialize, Deserialize}; +use tokio_cron_scheduler::{Job, JobScheduler}; +use tracing::{debug, error, info, warn}; +use uuid::Uuid; pub struct TaskScheduler { scheduler: JobScheduler, @@ -47,7 +50,10 @@ impl TaskScheduler { pub async fn new() -> Result { let scheduler = JobScheduler::new().await?; let task_status = Arc::new(RwLock::new(HashMap::new())); - Ok(Self { scheduler, task_status }) + Ok(Self { + scheduler, + task_status, + }) } /// Get current status of all tasks @@ -56,15 +62,20 @@ impl TaskScheduler { } /// Start event-driven sync handler - pub async fn start_event_handler(db: DatabaseManager, mut event_receiver: tokio::sync::broadcast::Receiver) { + pub async fn start_event_handler( + db: DatabaseManager, + mut event_receiver: tokio::sync::broadcast::Receiver, + ) { let xray_service = XrayService::new(); - + tokio::spawn(async move { - while let Ok(event) = event_receiver.recv().await { match event { - SyncEvent::InboundChanged(server_id) | SyncEvent::UserAccessChanged(server_id) => { - if let Err(e) = sync_single_server_by_id(&xray_service, &db, server_id).await { + SyncEvent::InboundChanged(server_id) + | SyncEvent::UserAccessChanged(server_id) => { + if let Err(e) = + sync_single_server_by_id(&xray_service, &db, server_id).await + { error!("Failed to sync server {} from event: {}", server_id, e); } } @@ -74,34 +85,36 @@ impl TaskScheduler { } pub async fn start(&mut self, db: DatabaseManager, xray_service: XrayService) -> Result<()> { - // Initialize task status { let mut status = self.task_status.write().unwrap(); - status.insert("xray_sync".to_string(), TaskStatus { - name: "Xray Synchronization".to_string(), - description: "Synchronizes database state with xray servers".to_string(), - schedule: "0 * * * * * (every minute)".to_string(), - status: TaskState::Idle, - last_run: None, - next_run: Some(Utc::now() + chrono::Duration::minutes(1)), - total_runs: 0, - success_count: 0, - error_count: 0, - last_error: None, - last_duration_ms: None, - }); + status.insert( + "xray_sync".to_string(), + TaskStatus { + name: "Xray Synchronization".to_string(), + description: "Synchronizes database state with xray servers".to_string(), + schedule: "0 * * * * * (every minute)".to_string(), + status: TaskState::Idle, + last_run: None, + next_run: Some(Utc::now() + chrono::Duration::minutes(1)), + total_runs: 0, + success_count: 0, + error_count: 0, + last_error: None, + last_duration_ms: None, + }, + ); } - + // Run initial sync in background to avoid blocking startup let db_initial = db.clone(); let xray_service_initial = xray_service.clone(); let task_status_initial = self.task_status.clone(); - + tokio::spawn(async move { info!("Starting initial xray sync in background..."); let start_time = Utc::now(); - + // Update status to running { let mut status = task_status_initial.write().unwrap(); @@ -111,7 +124,7 @@ impl TaskScheduler { task.total_runs += 1; } } - + match sync_xray_state(db_initial, xray_service_initial).await { Ok(_) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; @@ -123,7 +136,7 @@ impl TaskScheduler { task.last_error = None; } info!("Initial xray sync completed successfully in {}ms", duration); - }, + } Err(e) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; let mut status = task_status_initial.write().unwrap(); @@ -137,20 +150,20 @@ impl TaskScheduler { } } }); - + // Add synchronization task that runs every minute let db_clone = db.clone(); let xray_service_clone = xray_service.clone(); let task_status_clone = self.task_status.clone(); - + let sync_job = Job::new_async("0 */5 * * * *", move |_uuid, _l| { let db = db_clone.clone(); let xray_service = xray_service_clone.clone(); let task_status = task_status_clone.clone(); - + Box::pin(async move { let start_time = Utc::now(); - + // Update status to running { let mut status = task_status.write().unwrap(); @@ -161,7 +174,7 @@ impl TaskScheduler { task.next_run = Some(start_time + chrono::Duration::minutes(1)); } } - + match sync_xray_state(db, xray_service).await { Ok(_) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; @@ -172,7 +185,7 @@ impl TaskScheduler { task.last_duration_ms = Some(duration); task.last_error = None; } - }, + } Err(e) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; let mut status = task_status.write().unwrap(); @@ -187,38 +200,42 @@ impl TaskScheduler { } }) })?; - + self.scheduler.add(sync_job).await?; - + // Add certificate renewal task that runs once a day at 2 AM let db_clone_cert = db.clone(); let task_status_cert = self.task_status.clone(); - + // Initialize certificate renewal task status { let mut status = self.task_status.write().unwrap(); - status.insert("cert_renewal".to_string(), TaskStatus { - name: "Certificate Renewal".to_string(), - description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), - schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), - status: TaskState::Idle, - last_run: None, - next_run: Some(Utc::now() + chrono::Duration::days(1)), - total_runs: 0, - success_count: 0, - error_count: 0, - last_error: None, - last_duration_ms: None, - }); + status.insert( + "cert_renewal".to_string(), + TaskStatus { + name: "Certificate Renewal".to_string(), + description: "Renews Let's Encrypt certificates that expire within 15 days" + .to_string(), + schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), + status: TaskState::Idle, + last_run: None, + next_run: Some(Utc::now() + chrono::Duration::days(1)), + total_runs: 0, + success_count: 0, + error_count: 0, + last_error: None, + last_duration_ms: None, + }, + ); } - + let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| { let db = db_clone_cert.clone(); let task_status = task_status_cert.clone(); - + Box::pin(async move { let start_time = Utc::now(); - + // Update task status to running { let mut status = task_status.write().unwrap(); @@ -228,7 +245,7 @@ impl TaskScheduler { task.total_runs += 1; } } - + match check_and_renew_certificates(&db).await { Ok(_) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; @@ -239,7 +256,7 @@ impl TaskScheduler { task.last_duration_ms = Some(duration); task.last_error = None; } - }, + } Err(e) => { let duration = (Utc::now() - start_time).num_milliseconds() as u64; let mut status = task_status.write().unwrap(); @@ -254,9 +271,9 @@ impl TaskScheduler { } }) })?; - + self.scheduler.add(cert_renewal_job).await?; - + // Also run certificate check on startup info!("Running initial certificate renewal check..."); tokio::spawn(async move { @@ -264,7 +281,7 @@ impl TaskScheduler { error!("Initial certificate renewal check failed: {}", e); } }); - + self.scheduler.start().await?; Ok(()) } @@ -281,7 +298,12 @@ impl TaskScheduler { } } - fn update_task_status_with_error(&self, task_id: &str, error: String, duration_ms: Option) { + fn update_task_status_with_error( + &self, + task_id: &str, + error: String, + duration_ms: Option, + ) { let mut status = self.task_status.write().unwrap(); if let Some(task) = status.get_mut(task_id) { task.status = TaskState::Error; @@ -301,11 +323,10 @@ impl TaskScheduler { /// Synchronize xray server state with database state async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Result<()> { - let server_repo = ServerRepository::new(db.connection().clone()); let inbound_repo = ServerInboundRepository::new(db.connection().clone()); let template_repo = InboundTemplateRepository::new(db.connection().clone()); - + // Get all servers from database let servers = match server_repo.find_all().await { Ok(servers) => servers, @@ -314,50 +335,50 @@ async fn sync_xray_state(db: DatabaseManager, xray_service: XrayService) -> Resu return Err(e.into()); } }; - - + for server in servers { - let endpoint = server.get_grpc_endpoint(); - + // Test connection first match xray_service.test_connection(server.id, &endpoint).await { Ok(false) => { - warn!("Cannot connect to server {} at {}, skipping", server.name, endpoint); + warn!( + "Cannot connect to server {} at {}, skipping", + server.name, endpoint + ); continue; - }, + } Err(e) => { error!("Error testing connection to server {}: {}", server.name, e); continue; } _ => {} } - + // Get desired inbounds from database - let desired_inbounds = match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await { - Ok(inbounds) => inbounds, - Err(e) => { - error!("Failed to get desired inbounds for server {}: {}", server.name, e); - continue; - } - }; - - + let desired_inbounds = + match get_desired_inbounds_from_db(&db, &server, &inbound_repo, &template_repo).await { + Ok(inbounds) => inbounds, + Err(e) => { + error!( + "Failed to get desired inbounds for server {}: {}", + server.name, e + ); + continue; + } + }; + // Synchronize inbounds - if let Err(e) = sync_server_inbounds( - &xray_service, - server.id, - &endpoint, - &desired_inbounds - ).await { + if let Err(e) = + sync_server_inbounds(&xray_service, server.id, &endpoint, &desired_inbounds).await + { error!("Failed to sync inbounds for server {}: {}", server.name, e); } } - + Ok(()) } - /// Get desired inbounds configuration from database async fn get_desired_inbounds_from_db( db: &DatabaseManager, @@ -365,38 +386,47 @@ async fn get_desired_inbounds_from_db( inbound_repo: &ServerInboundRepository, template_repo: &InboundTemplateRepository, ) -> Result> { - // Get all inbounds for this server let inbounds = inbound_repo.find_by_server_id(server.id).await?; let mut desired_inbounds = HashMap::new(); - + for inbound in inbounds { // Get template for this inbound let template = match template_repo.find_by_id(inbound.template_id).await? { Some(template) => template, None => { - warn!("Template {} not found for inbound {}, skipping", inbound.template_id, inbound.tag); + warn!( + "Template {} not found for inbound {}, skipping", + inbound.template_id, inbound.tag + ); continue; } }; - + // Get users for this inbound let users = get_users_for_inbound(db, inbound.id).await?; - - + // Get port from template or override let port = inbound.port_override.unwrap_or(template.default_port); - + // Get certificate if specified let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id { match load_certificate_from_db(db, inbound.certificate_id).await { Ok((cert, key)) => { - info!("Loaded certificate {} for inbound {}, has_cert={}, has_key={}", - cert_id, inbound.tag, cert.is_some(), key.is_some()); + info!( + "Loaded certificate {} for inbound {}, has_cert={}, has_key={}", + cert_id, + inbound.tag, + cert.is_some(), + key.is_some() + ); (cert, key) - }, + } Err(e) => { - warn!("Failed to load certificate {} for inbound {}: {}", cert_id, inbound.tag, e); + warn!( + "Failed to load certificate {} for inbound {}: {}", + cert_id, inbound.tag, e + ); (None, None) } } @@ -404,7 +434,7 @@ async fn get_desired_inbounds_from_db( debug!("No certificate configured for inbound {}", inbound.tag); (None, None) }; - + let desired_inbound = DesiredInbound { tag: inbound.tag.clone(), port, @@ -415,22 +445,24 @@ async fn get_desired_inbounds_from_db( cert_pem, key_pem, }; - + desired_inbounds.insert(inbound.tag.clone(), desired_inbound); } - + Ok(desired_inbounds) } /// Get users for specific inbound from database async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result> { let inbound_users_repo = InboundUsersRepository::new(db.connection().clone()); - - let inbound_users = inbound_users_repo.find_active_by_inbound_id(inbound_id).await?; - + + let inbound_users = inbound_users_repo + .find_active_by_inbound_id(inbound_id) + .await?; + // Get user details to generate emails let user_repo = UserRepository::new(db.connection().clone()); - + let mut users: Vec = Vec::new(); for inbound_user in inbound_users { if let Some(user) = user_repo.find_by_id(inbound_user.user_id).await? { @@ -442,23 +474,24 @@ async fn get_users_for_inbound(db: &DatabaseManager, inbound_id: Uuid) -> Result }); } } - + Ok(users) } /// Load certificate from database -async fn load_certificate_from_db(db: &DatabaseManager, cert_id: Option) -> Result<(Option, Option)> { +async fn load_certificate_from_db( + db: &DatabaseManager, + cert_id: Option, +) -> Result<(Option, Option)> { let cert_id = match cert_id { Some(id) => id, None => return Ok((None, None)), }; - + let cert_repo = CertificateRepository::new(db.connection().clone()); - + match cert_repo.find_by_id(cert_id).await? { - Some(cert) => { - Ok((Some(cert.certificate_pem()), Some(cert.private_key_pem()))) - }, + Some(cert) => Ok((Some(cert.certificate_pem()), Some(cert.private_key_pem()))), None => { warn!("Certificate {} not found", cert_id); Ok((None, None)) @@ -474,7 +507,9 @@ async fn sync_server_inbounds( desired_inbounds: &HashMap, ) -> Result<()> { // Use optimized batch sync with single client - xray_service.sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds).await + xray_service + .sync_server_inbounds_optimized(server_id, endpoint, desired_inbounds) + .await } /// Sync a single server by ID (for event-driven sync) @@ -486,7 +521,7 @@ async fn sync_single_server_by_id( let server_repo = ServerRepository::new(db.connection().clone()); let inbound_repo = ServerInboundRepository::new(db.connection().clone()); let template_repo = InboundTemplateRepository::new(db.connection().clone()); - + // Get server let server = match server_repo.find_by_id(server_id).await? { Some(server) => server, @@ -495,22 +530,22 @@ async fn sync_single_server_by_id( return Ok(()); } }; - + // For now, sync all servers (can add active/inactive flag later) - + // Get desired inbounds from database - let desired_inbounds = get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?; - + let desired_inbounds = + get_desired_inbounds_from_db(db, &server, &inbound_repo, &template_repo).await?; + // Build endpoint let endpoint = server.get_grpc_endpoint(); - + // Sync server sync_server_inbounds(xray_service, server_id, &endpoint, &desired_inbounds).await?; - + Ok(()) } - /// Represents desired inbound configuration from database #[derive(Debug, Clone)] pub struct DesiredInbound { @@ -534,73 +569,79 @@ pub struct XrayUser { /// Check and renew certificates that expire within 15 days async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { - use crate::services::certificates::CertificateService; use crate::database::repository::DnsProviderRepository; - + use crate::services::certificates::CertificateService; + info!("Starting certificate renewal check..."); - + let cert_repo = CertificateRepository::new(db.connection().clone()); let dns_repo = DnsProviderRepository::new(db.connection().clone()); let cert_service = CertificateService::with_db(db.connection().clone()); - + // Get all certificates let certificates = cert_repo.find_all().await?; let mut renewed_count = 0; let mut checked_count = 0; - + for cert in certificates { // Only check Let's Encrypt certificates with auto_renew enabled if cert.cert_type != "letsencrypt" || !cert.auto_renew { continue; } - + checked_count += 1; - + // Check if certificate expires within 15 days if cert.expires_soon(15) { info!( - "Certificate '{}' (ID: {}) expires at {} - renewing...", + "Certificate '{}' (ID: {}) expires at {} - renewing...", cert.name, cert.id, cert.expires_at ); - + // Find the DNS provider used for this certificate // For now, we'll use the first active Cloudflare provider // In production, you might want to store the provider ID with the certificate let providers = dns_repo.find_active_by_type("cloudflare").await?; - + if providers.is_empty() { error!( - "Cannot renew certificate '{}': No active Cloudflare DNS provider found", + "Cannot renew certificate '{}': No active Cloudflare DNS provider found", cert.name ); continue; } - + let dns_provider = &providers[0]; - + // Need to get the ACME email - for now using a default // In production, this should be stored with the certificate let acme_email = "admin@example.com"; // TODO: Store this with certificate - + // Attempt to renew the certificate - match cert_service.generate_letsencrypt_certificate( - &cert.domain, - dns_provider.id, - acme_email, - false, // Use production Let's Encrypt - ).await { + match cert_service + .generate_letsencrypt_certificate( + &cert.domain, + dns_provider.id, + acme_email, + false, // Use production Let's Encrypt + ) + .await + { Ok((new_cert_pem, new_key_pem)) => { // Update the certificate in database - match cert_repo.update_certificate_data( - cert.id, - &new_cert_pem, - &new_key_pem, - chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days - ).await { + match cert_repo + .update_certificate_data( + cert.id, + &new_cert_pem, + &new_key_pem, + chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days + ) + .await + { Ok(_) => { info!("Successfully renewed certificate '{}'", cert.name); renewed_count += 1; - + // Trigger sync for all servers using this certificate // This will be done via the event system if let Err(e) = trigger_cert_renewal_sync(db, cert.id).await { @@ -608,7 +649,10 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { } } Err(e) => { - error!("Failed to save renewed certificate '{}' to database: {}", cert.name, e); + error!( + "Failed to save renewed certificate '{}' to database: {}", + cert.name, e + ); } } } @@ -618,17 +662,17 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { } } else { debug!( - "Certificate '{}' expires at {} - no renewal needed yet", + "Certificate '{}' expires at {} - no renewal needed yet", cert.name, cert.expires_at ); } } - + info!( - "Certificate renewal check completed: checked {}, renewed {}", + "Certificate renewal check completed: checked {}, renewed {}", checked_count, renewed_count ); - + Ok(()) } @@ -636,23 +680,26 @@ async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> { async fn trigger_cert_renewal_sync(db: &DatabaseManager, cert_id: Uuid) -> Result<()> { use crate::services::events::send_sync_event; use crate::services::events::SyncEvent; - + let inbound_repo = ServerInboundRepository::new(db.connection().clone()); - + // Find all server inbounds that use this certificate let inbounds = inbound_repo.find_by_certificate_id(cert_id).await?; - + // Collect unique server IDs let mut server_ids = std::collections::HashSet::new(); for inbound in inbounds { server_ids.insert(inbound.server_id); } - + // Trigger sync for each server for server_id in server_ids { - info!("Triggering sync for server {} after certificate renewal", server_id); + info!( + "Triggering sync for server {} after certificate renewal", + server_id + ); send_sync_event(SyncEvent::InboundChanged(server_id)); } - + Ok(()) -} \ No newline at end of file +} diff --git a/src/services/telegram/bot.rs b/src/services/telegram/bot.rs index 6e07b58..65898b3 100644 --- a/src/services/telegram/bot.rs +++ b/src/services/telegram/bot.rs @@ -1,9 +1,9 @@ -use teloxide::{Bot, prelude::*}; +use teloxide::{prelude::*, Bot}; use tokio::sync::oneshot; -use crate::database::DatabaseManager; -use crate::config::AppConfig; use super::handlers::{self, Command}; +use crate::config::AppConfig; +use crate::database::DatabaseManager; /// Run the bot polling loop pub async fn run_polling( @@ -20,16 +20,11 @@ pub async fn run_polling( .branch( dptree::entry() .filter_command::() - .endpoint(handlers::handle_command) - ) - .branch( - dptree::endpoint(handlers::handle_message) + .endpoint(handlers::handle_command), ) + .branch(dptree::endpoint(handlers::handle_message)), ) - .branch( - Update::filter_callback_query() - .endpoint(handlers::handle_callback_query) - ); + .branch(Update::filter_callback_query().endpoint(handlers::handle_callback_query)); let mut dispatcher = Dispatcher::builder(bot.clone(), handler) .dependencies(dptree::deps![db, app_config]) @@ -45,4 +40,4 @@ pub async fn run_polling( tracing::info!("Telegram bot received shutdown signal"); } } -} \ No newline at end of file +} diff --git a/src/services/telegram/error.rs b/src/services/telegram/error.rs index da78da5..8c96fd8 100644 --- a/src/services/telegram/error.rs +++ b/src/services/telegram/error.rs @@ -43,4 +43,4 @@ impl From for TelegramError { fn from(err: anyhow::Error) -> Self { Self::Other(err.to_string()) } -} \ No newline at end of file +} diff --git a/src/services/telegram/handlers/admin.rs b/src/services/telegram/handlers/admin.rs index 06adbeb..bc8f91d 100644 --- a/src/services/telegram/handlers/admin.rs +++ b/src/services/telegram/handlers/admin.rs @@ -1,107 +1,191 @@ -use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery}}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use teloxide::{ + prelude::*, + types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup}, +}; use uuid::Uuid; -use crate::database::DatabaseManager; -use crate::database::repository::{UserRepository, UserRequestRepository}; -use crate::database::entities::user_request::RequestStatus; -use super::super::localization::{LocalizationService, Language}; -use super::types::{get_selected_servers, generate_short_request_id, get_full_request_id, generate_short_server_id, get_full_server_id}; +use super::super::localization::{Language, LocalizationService}; +use super::types::{ + generate_short_request_id, generate_short_server_id, generate_short_user_id, + get_full_request_id, get_full_server_id, get_selected_servers, get_user_language, +}; use super::user::handle_start; +use crate::database::entities::user_request::RequestStatus; +use crate::database::repository::{UserRepository, UserRequestRepository}; +use crate::database::DatabaseManager; -/// Handle admin requests edit (show list of recent requests) +/// Handle admin requests edit (show list of recent requests) - redirect to first page pub async fn handle_admin_requests_edit( bot: Bot, q: &CallbackQuery, db: &DatabaseManager, +) -> Result<(), Box> { + handle_request_list(bot, q, db, 1).await +} + +/// Handle request list with pagination +pub async fn handle_request_list( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + page: u32, ) -> Result<(), Box> { let admin_telegram_id = q.from.id.0 as i64; let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + let user_repo = UserRepository::new(db.connection()); let request_repo = UserRequestRepository::new(db.connection().clone()); - + // Check if user is admin - if !user_repo.is_telegram_id_admin(admin_telegram_id).await.unwrap_or(false) { + if !user_repo + .is_telegram_id_admin(admin_telegram_id) + .await + .unwrap_or(false) + { bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "unauthorized")) .await?; return Ok(()); } - - // Get recent requests (last 10) - let recent_requests = request_repo.find_recent(10).await.unwrap_or_default(); - - if recent_requests.is_empty() { - // Edit message to show no requests + + let per_page = 20; + let offset = (page - 1) * per_page; + + // Get total request count + let total_requests = match request_repo.count_all().await { + Ok(count) => count, + Err(e) => { + tracing::error!("Failed to count requests: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + if total_requests == 0 { + let message = l10n.get(lang.clone(), "no_pending_requests"); + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_menu"), + "back_to_menu", + )]]); + if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { - bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_pending_requests")) - .reply_markup(InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], - ])) + bot.edit_message_text(chat_id, regular_msg.id, message) + .reply_markup(keyboard) .await?; } } + bot.answer_callback_query(q.id.clone()).await?; return Ok(()); } - - // Build message with request list - let mut message_lines = vec!["📋 Recent Access Requests\n".to_string()]; - let mut keyboard_buttons = vec![]; - - for request in &recent_requests { + + // Get requests for current page + let requests = match request_repo + .find_paginated(offset as u64, per_page as u64) + .await + { + Ok(requests) => requests, + Err(e) => { + tracing::error!("Failed to get requests: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + let total_pages = (total_requests + per_page as i64 - 1) / per_page as i64; + + // Build message + let mut message_lines = vec!["📋 Access Requests".to_string()]; + message_lines.push(format!( + "\n{}", + l10n.format( + lang.clone(), + "page_info", + &[ + ("page", &page.to_string()), + ("total", &total_pages.to_string()) + ] + ) + )); + message_lines.push("".to_string()); + + // Build request buttons + let mut keyboard_buttons = Vec::new(); + + for request in &requests { let status_emoji = match request.status.as_str() { "pending" => "⏳", "approved" => "✅", "declined" => "❌", - _ => "❓" + _ => "❓", }; - + let username = request.telegram_username.as_deref().unwrap_or("unknown"); - let processed_info = if let Some(processed_by_id) = request.processed_by_user_id { - if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await { - format!(" by {}", admin.name) - } else { - String::new() - } - } else { - String::new() - }; - - let button_text = format!("{} {} @{}{}", status_emoji, request.get_full_name(), username, processed_info); - - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback(button_text, format!("view_request:{}", request.id)) - ]); + let request_display = format!("{} {} @{}", status_emoji, request.get_full_name(), username); + + let short_request_id = generate_short_request_id(&request.id.to_string()); + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + request_display, + format!("view_request:{}", short_request_id), + )]); } - + + // Add pagination buttons + let mut pagination_row = Vec::new(); + + if page > 1 { + pagination_row.push(InlineKeyboardButton::callback( + l10n.get(lang.clone(), "prev_page"), + format!("request_list:{}", page - 1), + )); + } + + if page < total_pages as u32 { + pagination_row.push(InlineKeyboardButton::callback( + l10n.get(lang.clone(), "next_page"), + format!("request_list:{}", page + 1), + )); + } + + if !pagination_row.is_empty() { + keyboard_buttons.push(pagination_row); + } + // Add back button - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback(l10n.get(lang, "back"), "back") - ]); - + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_menu"), + "back_to_menu", + )]); + let message = message_lines.join("\n"); - - // Edit the existing message instead of sending a new one + let keyboard = InlineKeyboardMarkup::new(keyboard_buttons); + if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { bot.edit_message_text(chat_id, regular_msg.id, message) .parse_mode(teloxide::types::ParseMode::Html) - .reply_markup(InlineKeyboardMarkup::new(keyboard_buttons)) + .reply_markup(keyboard) .await?; } } - + bot.answer_callback_query(q.id.clone()).await?; - Ok(()) } @@ -115,29 +199,38 @@ pub async fn handle_approve_request( let admin_telegram_id = q.from.id.0 as i64; let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - let _chat_id = q.message.as_ref().and_then(|m| { - match m { + let _chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + let user_repo = UserRepository::new(db.connection()); let request_repo = UserRequestRepository::new(db.connection().clone()); - + // Get admin user - let admin = user_repo.get_by_telegram_id(admin_telegram_id).await + let admin = user_repo + .get_by_telegram_id(admin_telegram_id) + .await .unwrap_or(None) .ok_or(l10n.get(lang.clone(), "admin_not_found"))?; - - // Parse request ID - let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; - + + // Get full UUID from short request ID + let request_uuid_str = get_full_request_id(request_id) + .ok_or_else(|| l10n.get(lang.clone(), "invalid_request_id"))?; + let request_id = Uuid::parse_str(&request_uuid_str) + .map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + // Get the request - let request = request_repo.find_by_id(request_id).await + let request = request_repo + .find_by_id(request_id) + .await .unwrap_or(None) .ok_or(l10n.get(lang.clone(), "request_not_found"))?; - + // Check if request is already processed if request.status != "pending" { bot.answer_callback_query(q.id.clone()) @@ -145,7 +238,7 @@ pub async fn handle_approve_request( .await?; return Ok(()); } - + // Create user account let username = request.telegram_username.as_deref().unwrap_or("Unknown"); let dto = crate::database::entities::user::CreateUserDto { @@ -154,44 +247,60 @@ pub async fn handle_approve_request( telegram_id: Some(request.telegram_id), is_telegram_admin: false, }; - + match user_repo.create(dto).await { Ok(new_user) => { // Approve the request - request_repo.approve( - request_id, - Some(format!("Approved by {}", admin.name)), - admin.id - ).await?; - + request_repo + .approve( + request_id, + Some(format!("Approved by {}", admin.name)), + admin.id, + ) + .await?; + // Update the callback message to show approval status and server selection if let Some(message) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { if let Some(text) = msg.text() { - let updated_text = format!("{}\n\n✅ APPROVED by {}\n\n📋 Select servers to grant access:", text, admin.name); + let updated_text = format!( + "{}\n\n✅ APPROVED by {}\n\n📋 Select servers to grant access:", + text, admin.name + ); // Generate short ID for the request let short_request_id = generate_short_request_id(&request_id.to_string()); let callback_data = format!("s:{}", short_request_id); - tracing::info!("Generated callback data for server selection: '{}' (length: {})", callback_data, callback_data.len()); - + tracing::info!( + "Generated callback data for server selection: '{}' (length: {})", + callback_data, + callback_data.len() + ); + let server_selection_keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback("Select Servers", callback_data)], - vec![InlineKeyboardButton::callback("All Requests", "back_to_requests")], + vec![InlineKeyboardButton::callback( + "Select Servers", + callback_data, + )], + vec![InlineKeyboardButton::callback( + "All Requests", + "back_to_requests", + )], ]); - - let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text) + + let _ = bot + .edit_message_text(msg.chat.id, msg.id, updated_text) .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(server_selection_keyboard) .await; } } } - + // Send main menu to the user instead of just notification let user_lang = Language::from_telegram_code(Some(&request.get_language())); let user_repo_for_user = UserRepository::new(db.connection()); let is_admin = false; // New users are not admins by default - + // Create a fake user object for language detection let fake_user = teloxide::types::User { id: teloxide::types::UserId(request.telegram_id as u64), @@ -203,17 +312,18 @@ pub async fn handle_approve_request( is_premium: false, added_to_attachment_menu: false, }; - + // Send main menu using handle_start handle_start( - bot.clone(), - ChatId(request.telegram_id), - request.telegram_id, + bot.clone(), + ChatId(request.telegram_id), + request.telegram_id, &fake_user, &user_repo_for_user, - db - ).await?; - + db, + ) + .await?; + bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "request_approved_admin")) .await?; @@ -221,11 +331,15 @@ pub async fn handle_approve_request( Err(e) => { tracing::error!("Failed to create user during approval: {}", e); bot.answer_callback_query(q.id.clone()) - .text(l10n.format(lang.clone(), "user_creation_failed", &[("error", &e.to_string())])) + .text(l10n.format( + lang.clone(), + "user_creation_failed", + &[("error", &e.to_string())], + )) .await?; } } - + Ok(()) } @@ -239,23 +353,30 @@ pub async fn handle_decline_request( let admin_telegram_id = q.from.id.0 as i64; let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - + let user_repo = UserRepository::new(db.connection()); let request_repo = UserRequestRepository::new(db.connection().clone()); - + // Get admin user - let admin = user_repo.get_by_telegram_id(admin_telegram_id).await + let admin = user_repo + .get_by_telegram_id(admin_telegram_id) + .await .unwrap_or(None) .ok_or(l10n.get(lang.clone(), "admin_not_found"))?; - - // Parse request ID - let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; - + + // Get full UUID from short request ID + let request_uuid_str = get_full_request_id(request_id) + .ok_or_else(|| l10n.get(lang.clone(), "invalid_request_id"))?; + let request_id = Uuid::parse_str(&request_uuid_str) + .map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + // Get the request - let request = request_repo.find_by_id(request_id).await + let request = request_repo + .find_by_id(request_id) + .await .unwrap_or(None) .ok_or(l10n.get(lang.clone(), "request_not_found"))?; - + // Check if request is already processed if request.status != "pending" { bot.answer_callback_query(q.id.clone()) @@ -263,43 +384,48 @@ pub async fn handle_decline_request( .await?; return Ok(()); } - + // Decline the request - request_repo.decline( - request_id, - Some(format!("Declined by {}", admin.name)), - admin.id - ).await?; - + request_repo + .decline( + request_id, + Some(format!("Declined by {}", admin.name)), + admin.id, + ) + .await?; + // Update the callback message to show decline status if let Some(message) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { if let Some(text) = msg.text() { let updated_text = format!("{}\n\n❌ DECLINED by {}", text, admin.name); - let back_keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")], - ]); - - let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text) + let back_keyboard = + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "📋 All Requests", + "back_to_requests", + )]]); + + let _ = bot + .edit_message_text(msg.chat.id, msg.id, updated_text) .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(back_keyboard) .await; } } } - + // Notify the user using their saved language preference let user_lang = Language::from_telegram_code(Some(&request.get_language())); let user_message = l10n.get(user_lang, "request_declined_notification"); - + bot.send_message(ChatId(request.telegram_id), user_message) .parse_mode(teloxide::types::ParseMode::Html) .await?; - + bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "request_declined_admin")) .await?; - + Ok(()) } @@ -312,24 +438,31 @@ pub async fn handle_view_request( ) -> Result<(), Box> { let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + let request_repo = UserRequestRepository::new(db.connection().clone()); let user_repo = UserRepository::new(db.connection()); - - // Parse request ID - let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; - + + // Get full UUID from short request ID + let request_uuid_str = get_full_request_id(request_id) + .ok_or_else(|| l10n.get(lang.clone(), "invalid_request_id"))?; + let request_id = Uuid::parse_str(&request_uuid_str) + .map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?; + // Get the request - let request = request_repo.find_by_id(request_id).await + let request = request_repo + .find_by_id(request_id) + .await .unwrap_or(None) .ok_or(l10n.get(lang.clone(), "request_not_found"))?; - + // Get processed by admin info let processed_by = if let Some(processed_by_id) = request.processed_by_user_id { if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await { @@ -340,20 +473,23 @@ pub async fn handle_view_request( } else { String::new() }; - + let processed_at = if let Some(processed_at) = request.processed_at { - format!("\n⏰ Processed at: {}", processed_at.format("%Y-%m-%d %H:%M UTC")) + format!( + "\n⏰ Processed at: {}", + processed_at.format("%Y-%m-%d %H:%M UTC") + ) } else { String::new() }; - + let status_emoji = match request.status.as_str() { "pending" => "⏳", "approved" => "✅", "declined" => "❌", - _ => "❓" + _ => "❓", }; - + let message = format!( "📋 Access Request Details\n\n\ 👤 Name: {}\n\ @@ -372,13 +508,19 @@ pub async fn handle_view_request( processed_at, request.request_message.as_deref().unwrap_or("No message") ); - + // Create keyboard based on request status let keyboard = if request.status == "pending" { InlineKeyboardMarkup::new(vec![ vec![ - InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)), - InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)), + InlineKeyboardButton::callback( + l10n.get(lang.clone(), "approve"), + format!("approve:{}", request.id), + ), + InlineKeyboardButton::callback( + l10n.get(lang.clone(), "decline"), + format!("decline:{}", request.id), + ), ], vec![ InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"), @@ -386,14 +528,12 @@ pub async fn handle_view_request( ], ]) } else { - InlineKeyboardMarkup::new(vec![ - vec![ - InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"), - InlineKeyboardButton::callback("🏠 Menu", "back"), - ], - ]) + InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"), + InlineKeyboardButton::callback("🏠 Menu", "back"), + ]]) }; - + // Edit the existing message instead of sending a new one if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { @@ -403,9 +543,9 @@ pub async fn handle_view_request( .await?; } } - + bot.answer_callback_query(q.id.clone()).await?; - + Ok(()) } @@ -417,24 +557,31 @@ pub async fn handle_stats( ) -> Result<(), Box> { let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - + let user_repo = UserRepository::new(db.connection()); let server_repo = crate::database::repository::ServerRepository::new(db.connection()); let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); let request_repo = UserRequestRepository::new(db.connection().clone()); - + let user_count = user_repo.count().await.unwrap_or(0); let server_count = server_repo.count().await.unwrap_or(0); let inbound_count = inbound_repo.count().await.unwrap_or(0); - let pending_requests = request_repo.count_by_status(RequestStatus::Pending).await.unwrap_or(0); - - let message = l10n.format(lang, "statistics", &[ - ("users", &user_count.to_string()), - ("servers", &server_count.to_string()), - ("inbounds", &inbound_count.to_string()), - ("pending", &pending_requests.to_string()) - ]); - + let pending_requests = request_repo + .count_by_status(RequestStatus::Pending) + .await + .unwrap_or(0); + + let message = l10n.format( + lang, + "statistics", + &[ + ("users", &user_count.to_string()), + ("servers", &server_count.to_string()), + ("inbounds", &inbound_count.to_string()), + ("pending", &pending_requests.to_string()), + ], + ); + bot.send_message(chat_id, message) .parse_mode(teloxide::types::ParseMode::Html) .await?; @@ -452,7 +599,7 @@ pub async fn handle_broadcast( let users = user_repo.get_all(1, 1000).await.unwrap_or_default(); let mut sent_count = 0; let mut failed_count = 0; - + for user in users { if let Some(telegram_id) = user.telegram_id { match bot.send_message(ChatId(telegram_id), &message).await { @@ -461,15 +608,19 @@ pub async fn handle_broadcast( } } } - + let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - - let result_message = l10n.format(lang, "broadcast_complete", &[ - ("sent", &sent_count.to_string()), - ("failed", &failed_count.to_string()) - ]); - + + let result_message = l10n.format( + lang, + "broadcast_complete", + &[ + ("sent", &sent_count.to_string()), + ("failed", &failed_count.to_string()), + ], + ); + bot.send_message(chat_id, result_message).await?; Ok(()) @@ -484,18 +635,20 @@ pub async fn handle_select_server_access( ) -> Result<(), Box> { let lang = Language::English; // Default admin language let _l10n = LocalizationService::new(); - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; + }) + .ok_or("No chat ID")?; let server_repo = crate::database::repository::ServerRepository::new(db.connection()); - + // Get all active servers let servers = server_repo.find_all().await.unwrap_or_default(); - + if servers.is_empty() { bot.answer_callback_query(q.id.clone()) .text("No servers available") @@ -504,11 +657,14 @@ pub async fn handle_select_server_access( } // Get the full request ID from the short ID - let request_id = get_full_request_id(short_request_id) - .ok_or("Invalid request ID")?; - - tracing::info!("Handling server selection for request: {} (short: {})", request_id, short_request_id); - + let request_id = get_full_request_id(short_request_id).ok_or("Invalid request ID")?; + + tracing::info!( + "Handling server selection for request: {} (short: {})", + request_id, + short_request_id + ); + // Initialize selected servers for this request (empty initially) { let mut selected = get_selected_servers().lock().unwrap(); @@ -529,16 +685,21 @@ pub async fn handle_select_server_access( } else { format!("⬜ {}", server.name) }; - + let short_server_id = generate_short_server_id(&server.id.to_string()); let callback_data = format!("t:{}:{}", short_request_id, short_server_id); - tracing::debug!("Toggle button callback: '{}' (length: {})", callback_data, callback_data.len()); - - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback(button_text, callback_data) - ]); + tracing::debug!( + "Toggle button callback: '{}' (length: {})", + callback_data, + callback_data.len() + ); + + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + button_text, + callback_data, + )]); } - + // Add apply and back buttons keyboard_buttons.push(vec![ InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", short_request_id)), @@ -569,26 +730,26 @@ pub async fn handle_toggle_server( short_server_id: &str, db: &DatabaseManager, ) -> Result<(), Box> { - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; + }) + .ok_or("No chat ID")?; // Get the full IDs from the short IDs - let request_id = get_full_request_id(short_request_id) - .ok_or("Invalid request ID")?; - let server_id = get_full_server_id(short_server_id) - .ok_or("Invalid server ID")?; - + let request_id = get_full_request_id(short_request_id).ok_or("Invalid request ID")?; + let server_id = get_full_server_id(short_server_id).ok_or("Invalid server ID")?; + tracing::info!("Toggling server {} for request {}", server_id, request_id); - + // Toggle server selection { let mut selected = get_selected_servers().lock().unwrap(); let server_list = selected.entry(request_id.clone()).or_insert_with(Vec::new); - + if let Some(pos) = server_list.iter().position(|x| x == &server_id) { server_list.remove(pos); } else { @@ -599,7 +760,7 @@ pub async fn handle_toggle_server( // Rebuild the keyboard with updated selection let server_repo = crate::database::repository::ServerRepository::new(db.connection()); let servers = server_repo.find_all().await.unwrap_or_default(); - + let mut keyboard_buttons = vec![]; let selected_servers = { let selected = get_selected_servers().lock().unwrap(); @@ -613,15 +774,16 @@ pub async fn handle_toggle_server( } else { format!("⬜ {}", server.name) }; - + let short_server_id = generate_short_server_id(&server.id.to_string()); let callback_data = format!("t:{}:{}", short_request_id, short_server_id); - - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback(button_text, callback_data) - ]); + + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + button_text, + callback_data, + )]); } - + // Add apply and back buttons keyboard_buttons.push(vec![ InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", short_request_id)), @@ -655,17 +817,18 @@ pub async fn handle_apply_server_access( ) -> Result<(), Box> { let lang = Language::English; // Default admin language let _l10n = LocalizationService::new(); - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; + }) + .ok_or("No chat ID")?; // Get the full request ID from the short ID - let request_id = get_full_request_id(short_request_id) - .ok_or("Invalid request ID")?; - + let request_id = get_full_request_id(short_request_id).ok_or("Invalid request ID")?; + // Get selected servers let selected_server_ids = { let selected = get_selected_servers().lock().unwrap(); @@ -682,17 +845,23 @@ pub async fn handle_apply_server_access( let request_repo = UserRequestRepository::new(db.connection().clone()); let user_repo = UserRepository::new(db.connection()); let server_repo = crate::database::repository::ServerRepository::new(db.connection()); - let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection().clone()); - let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + let inbound_repo = + crate::database::repository::ServerInboundRepository::new(db.connection().clone()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); // Parse request ID and get request let request_uuid = Uuid::parse_str(&request_id).map_err(|_| "Invalid request ID")?; - let request = request_repo.find_by_id(request_uuid).await + let request = request_repo + .find_by_id(request_uuid) + .await .unwrap_or(None) .ok_or("Request not found")?; // Get user - let user = user_repo.get_by_telegram_id(request.telegram_id).await + let user = user_repo + .get_by_telegram_id(request.telegram_id) + .await .unwrap_or(None) .ok_or("User not found")?; @@ -705,19 +874,24 @@ pub async fn handle_apply_server_access( // Get server info if let Ok(Some(server)) = server_repo.find_by_id(server_id).await { granted_servers.push(server.name.clone()); - + // Get all inbounds for this server if let Ok(inbounds) = inbound_repo.find_by_server_id(server_id).await { for inbound in inbounds { // Check if user already has access to this inbound - if !inbound_users_repo.user_has_access_to_inbound(user.id, inbound.id).await.unwrap_or(false) { + if !inbound_users_repo + .user_has_access_to_inbound(user.id, inbound.id) + .await + .unwrap_or(false) + { // Create inbound user access - let dto = crate::database::entities::inbound_users::CreateInboundUserDto { - user_id: user.id, - server_inbound_id: inbound.id, - level: Some(0), - }; - + let dto = + crate::database::entities::inbound_users::CreateInboundUserDto { + user_id: user.id, + server_inbound_id: inbound.id, + level: Some(0), + }; + if let Ok(_) = inbound_users_repo.create(dto).await { total_inbounds += 1; } @@ -742,9 +916,10 @@ pub async fn handle_apply_server_access( total_inbounds ); - let back_keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")], - ]); + let back_keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "📋 All Requests", + "back_to_requests", + )]]); // Edit the existing message if let Some(msg) = &q.message { @@ -757,8 +932,726 @@ pub async fn handle_apply_server_access( } bot.answer_callback_query(q.id.clone()) - .text(format!("Access granted to {} servers", granted_servers.len())) + .text(format!( + "Access granted to {} servers", + granted_servers.len() + )) .await?; Ok(()) -} \ No newline at end of file +} + +/// Handle manage users button +pub async fn handle_manage_users( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, +) -> Result<(), Box> { + // Redirect to first page of user list + handle_user_list(bot, q, db, 1).await +} + +/// Handle user list with pagination +pub async fn handle_user_list( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + page: u32, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + }) + .ok_or("No chat ID")?; + + let user_repo = UserRepository::new(db.connection()); + let per_page = 20; + let offset = (page - 1) * per_page; + + // Get total user count + let total_users = match user_repo.count_all().await { + Ok(count) => count, + Err(e) => { + tracing::error!("Failed to count users: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + if total_users == 0 { + let message = l10n.get(lang.clone(), "no_users_found"); + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_menu"), + "back_to_menu", + )]]); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + // Get users for current page + let users = match user_repo + .find_paginated(offset as u64, per_page as u64) + .await + { + Ok(users) => users, + Err(e) => { + tracing::error!("Failed to get users: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + let total_pages = (total_users + per_page as i64 - 1) / per_page as i64; + + // Build message + let mut message_lines = vec![l10n.get(lang.clone(), "user_list")]; + message_lines.push(format!( + "\n{}", + l10n.format( + lang.clone(), + "page_info", + &[ + ("page", &page.to_string()), + ("total", &total_pages.to_string()) + ] + ) + )); + message_lines.push("".to_string()); + + // Build user buttons + let mut keyboard_buttons = Vec::new(); + + for user in &users { + let user_display = if let Some(telegram_id) = user.telegram_id { + format!("👤 {} (@{})", user.name, telegram_id) + } else { + format!("👤 {}", user.name) + }; + + let short_user_id = generate_short_user_id(&user.id.to_string()); + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + user_display, + format!("user_details:{}", short_user_id), + )]); + } + + // Add pagination buttons + let mut pagination_row = Vec::new(); + + if page > 1 { + pagination_row.push(InlineKeyboardButton::callback( + l10n.get(lang.clone(), "prev_page"), + format!("user_list:{}", page - 1), + )); + } + + if page < total_pages as u32 { + pagination_row.push(InlineKeyboardButton::callback( + l10n.get(lang.clone(), "next_page"), + format!("user_list:{}", page + 1), + )); + } + + if !pagination_row.is_empty() { + keyboard_buttons.push(pagination_row); + } + + // Add back button + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_menu"), + "back_to_menu", + )]); + + let message = message_lines.join("\n"); + let keyboard = InlineKeyboardMarkup::new(keyboard_buttons); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + Ok(()) +} + +/// Handle user details view +pub async fn handle_user_details( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + user_id_str: &str, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + }) + .ok_or("No chat ID")?; + + let user_id = match Uuid::parse_str(user_id_str) { + Ok(id) => id, + Err(_) => { + bot.answer_callback_query(q.id.clone()) + .text("Invalid user ID") + .await?; + return Ok(()); + } + }; + + let user_repo = UserRepository::new(db.connection()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + + // Get user info + let user = match user_repo.get_by_id(user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + bot.answer_callback_query(q.id.clone()) + .text("User not found") + .await?; + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get user: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + // Get user's active inbound accesses + let inbound_users = inbound_users_repo + .find_by_user_id(user_id) + .await + .unwrap_or_default(); + let active_inbounds = inbound_users.iter().filter(|iu| iu.is_active).count(); + + // Build user info message + let mut message_lines = vec![l10n.get(lang.clone(), "user_details")]; + message_lines.push("".to_string()); + message_lines.push(format!("👤 Name: {}", user.name)); + message_lines.push(format!("🆔 ID: {}", user.id)); + + if let Some(telegram_id) = user.telegram_id { + message_lines.push(format!("📱 Telegram: @{}", telegram_id)); + } + + message_lines.push(format!("📊 Active Accesses: {}", active_inbounds)); + message_lines.push(format!( + "🛡️ Admin: {}", + if user.is_telegram_admin { "Yes" } else { "No" } + )); + message_lines.push(format!( + "📅 Created: {}", + user.created_at.format("%Y-%m-%d %H:%M UTC") + )); + + // Build keyboard + let short_user_id = generate_short_user_id(&user_id.to_string()); + let mut keyboard_buttons = vec![ + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "manage_access"), + format!("user_manage:{}", short_user_id), + )], + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "back_to_users"), + "user_list:1", + )], + vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_menu"), + "back_to_menu", + )], + ]; + + let message = message_lines.join("\n"); + let keyboard = InlineKeyboardMarkup::new(keyboard_buttons); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + Ok(()) +} + +/// Handle user access management +pub async fn handle_user_manage_access( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + user_id_str: &str, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + }) + .ok_or("No chat ID")?; + + let user_id = match Uuid::parse_str(user_id_str) { + Ok(id) => id, + Err(_) => { + bot.answer_callback_query(q.id.clone()) + .text("Invalid user ID") + .await?; + return Ok(()); + } + }; + + let user_repo = UserRepository::new(db.connection()); + let server_repo = crate::database::repository::ServerRepository::new(db.connection().clone()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + + // Get user info + let user = match user_repo.get_by_id(user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + bot.answer_callback_query(q.id.clone()) + .text("User not found") + .await?; + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get user: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + // Get all servers + let all_servers = match server_repo.find_all().await { + Ok(servers) => servers, + Err(e) => { + tracing::error!("Failed to get servers: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + // Filter servers that have active inbounds + let mut servers_with_inbounds = Vec::new(); + let inbound_repo = + crate::database::repository::ServerInboundRepository::new(db.connection().clone()); + + for server in all_servers { + match inbound_repo.find_by_server_id(server.id).await { + Ok(inbounds) => { + if !inbounds.is_empty() { + servers_with_inbounds.push(server); + } + } + Err(e) => { + tracing::warn!("Failed to get inbounds for server {}: {}", server.name, e); + } + } + } + + if servers_with_inbounds.is_empty() { + let message = "No servers with inbounds available"; + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "back_to_users"), + format!("user_details:{}", user_id), + )]]); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + // Get user's current accesses + let user_inbounds = inbound_users_repo + .find_by_user_id(user_id) + .await + .unwrap_or_default(); + let mut user_server_ids = std::collections::HashSet::new(); + + for inbound_user in &user_inbounds { + if inbound_user.is_active { + // Get server ID for this inbound + if let Ok(Some(inbound)) = + crate::database::repository::ServerInboundRepository::new(db.connection().clone()) + .find_by_id(inbound_user.server_inbound_id) + .await + { + user_server_ids.insert(inbound.server_id); + } + } + } + + // Initialize or get selected servers for this user + let selected_servers = get_user_selected_servers(&user_id.to_string()); + + // Build message + let mut message_lines = vec![ + format!("🔧 Manage Access for {}", user.name), + "".to_string(), + "Select servers to grant or revoke access:".to_string(), + "".to_string(), + ]; + + // Build server buttons + let mut keyboard_buttons = Vec::new(); + let short_user_id = generate_short_user_id(&user_id.to_string()); + + for server in &servers_with_inbounds { + let has_access = user_server_ids.contains(&server.id); + let is_selected = selected_servers.contains(&server.id.to_string()); + + let (emoji, action) = if has_access { + if is_selected { + ("❌", "Remove") // Selected for removal + } else { + ("✅", "Keep") // Current access, not selected for removal + } + } else { + if is_selected { + ("✅", "Grant") // Selected for granting + } else { + ("⚪", "Grant") // No access, not selected + } + }; + + message_lines.push(format!("{} {} - {}", emoji, server.name, action)); + + let short_server_id = generate_short_server_id(&server.id.to_string()); + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + format!("{} {}", emoji, server.name), + format!("user_toggle:{}:{}", short_user_id, short_server_id), + )]); + } + + // Add apply and back buttons + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "grant_access"), + format!("user_apply:{}", short_user_id), + )]); + + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "back_to_users"), + format!("user_details:{}", short_user_id), + )]); + + let message = message_lines.join("\n"); + let keyboard = InlineKeyboardMarkup::new(keyboard_buttons); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()).await?; + Ok(()) +} + +/// Handle user server toggle +pub async fn handle_user_toggle_server( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + user_id_str: &str, + server_id_str: &str, +) -> Result<(), Box> { + let user_id = match Uuid::parse_str(user_id_str) { + Ok(id) => id, + Err(_) => { + bot.answer_callback_query(q.id.clone()) + .text("Invalid user ID") + .await?; + return Ok(()); + } + }; + + let server_id = match Uuid::parse_str(server_id_str) { + Ok(id) => id, + Err(_) => { + bot.answer_callback_query(q.id.clone()) + .text("Invalid server ID") + .await?; + return Ok(()); + } + }; + + // Toggle server selection + toggle_user_server_selection(&user_id.to_string(), &server_id.to_string()); + + // Refresh the access management view + handle_user_manage_access(bot, q, db, user_id_str).await +} + +/// Handle apply user access changes +pub async fn handle_user_apply_access( + bot: Bot, + q: &CallbackQuery, + db: &DatabaseManager, + user_id_str: &str, +) -> Result<(), Box> { + let from = &q.from; + let lang = get_user_language(from); + let l10n = LocalizationService::new(); + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { + teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), + _ => None, + }) + .ok_or("No chat ID")?; + + let user_id = match Uuid::parse_str(user_id_str) { + Ok(id) => id, + Err(_) => { + bot.answer_callback_query(q.id.clone()) + .text("Invalid user ID") + .await?; + return Ok(()); + } + }; + + let user_repo = UserRepository::new(db.connection()); + let server_repo = crate::database::repository::ServerRepository::new(db.connection().clone()); + let inbound_repo = + crate::database::repository::ServerInboundRepository::new(db.connection().clone()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + + // Get user info + let user = match user_repo.get_by_id(user_id).await { + Ok(Some(user)) => user, + Ok(None) => { + bot.answer_callback_query(q.id.clone()) + .text("User not found") + .await?; + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get user: {}", e); + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang, "error_occurred")) + .await?; + return Ok(()); + } + }; + + // Get selected servers + let selected_server_ids = get_user_selected_servers(&user_id.to_string()); + + if selected_server_ids.is_empty() { + bot.answer_callback_query(q.id.clone()) + .text("No servers selected") + .await?; + return Ok(()); + } + + // Get user's current server accesses + let user_inbounds = inbound_users_repo + .find_by_user_id(user_id) + .await + .unwrap_or_default(); + let mut current_server_ids = std::collections::HashSet::new(); + + for inbound_user in &user_inbounds { + if inbound_user.is_active { + if let Ok(Some(inbound)) = inbound_repo + .find_by_id(inbound_user.server_inbound_id) + .await + { + current_server_ids.insert(inbound.server_id.to_string()); + } + } + } + + let mut granted_servers = Vec::new(); + let mut removed_servers = Vec::new(); + let mut total_changes = 0; + + for server_id_str in &selected_server_ids { + if let Ok(server_id) = Uuid::parse_str(server_id_str) { + let has_access = current_server_ids.contains(server_id_str); + + if has_access { + // Remove access - deactivate all inbounds for this server + for inbound_user in &user_inbounds { + if let Ok(Some(inbound)) = inbound_repo + .find_by_id(inbound_user.server_inbound_id) + .await + { + if inbound.server_id == server_id && inbound_user.is_active { + if let Ok(_) = inbound_users_repo.disable(inbound_user.id).await { + total_changes += 1; + } + } + } + } + + if let Ok(Some(server)) = server_repo.find_by_id(server_id).await { + removed_servers.push(server.name); + } + } else { + // Grant access - add to all inbounds for this server + if let Ok(inbounds) = inbound_repo.find_by_server_id(server_id).await { + for inbound in inbounds { + // Check if user already has access to this inbound + if !inbound_users_repo + .user_has_access_to_inbound(user_id, inbound.id) + .await + .unwrap_or(false) + { + let dto = + crate::database::entities::inbound_users::CreateInboundUserDto { + user_id, + server_inbound_id: inbound.id, + level: Some(0), + }; + + if let Ok(_) = inbound_users_repo.create(dto).await { + total_changes += 1; + } + } + } + } + + if let Ok(Some(server)) = server_repo.find_by_id(server_id).await { + granted_servers.push(server.name); + } + } + } + } + + // Clear selected servers + clear_user_selected_servers(&user_id.to_string()); + + // Build result message + let mut message_lines = vec![ + format!("✅ Access Updated for {}", user.name), + "".to_string(), + ]; + + if !granted_servers.is_empty() { + message_lines.push(format!( + "✅ Granted access to: {}", + granted_servers.join(", ") + )); + } + + if !removed_servers.is_empty() { + message_lines.push(format!( + "❌ Removed access from: {}", + removed_servers.join(", ") + )); + } + + message_lines.push("".to_string()); + message_lines.push(format!("📊 Total changes: {}", total_changes)); + + let short_user_id = generate_short_user_id(&user_id.to_string()); + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "back_to_users"), + format!("user_details:{}", short_user_id), + )], + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "back_to_menu"), + "back_to_menu", + )], + ]); + + let message = message_lines.join("\n"); + + if let Some(msg) = &q.message { + if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { + bot.edit_message_text(chat_id, regular_msg.id, message) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + } + + bot.answer_callback_query(q.id.clone()) + .text(l10n.get(lang.clone(), "access_updated")) + .await?; + + Ok(()) +} + +// Global storage for user selected servers for access management +static USER_SELECTED_SERVERS: OnceLock>>>> = OnceLock::new(); + +fn get_user_selected_servers_storage() -> &'static Arc>>> { + USER_SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) +} + +fn get_user_selected_servers(user_id: &str) -> Vec { + let storage = get_user_selected_servers_storage().lock().unwrap(); + storage.get(user_id).cloned().unwrap_or_default() +} + +fn toggle_user_server_selection(user_id: &str, server_id: &str) { + let mut storage = get_user_selected_servers_storage().lock().unwrap(); + let servers = storage.entry(user_id.to_string()).or_insert_with(Vec::new); + + if let Some(pos) = servers.iter().position(|id| id == server_id) { + servers.remove(pos); + } else { + servers.push(server_id.to_string()); + } +} + +fn clear_user_selected_servers(user_id: &str) { + let mut storage = get_user_selected_servers_storage().lock().unwrap(); + storage.remove(user_id); +} diff --git a/src/services/telegram/handlers/mod.rs b/src/services/telegram/handlers/mod.rs index a14a93b..ade21ed 100644 --- a/src/services/telegram/handlers/mod.rs +++ b/src/services/telegram/handlers/mod.rs @@ -1,15 +1,15 @@ pub mod admin; -pub mod user; pub mod types; +pub mod user; // Re-export main handler functions for easier access pub use admin::*; -pub use user::*; pub use types::*; +pub use user::*; -use teloxide::{prelude::*, types::CallbackQuery}; -use crate::database::DatabaseManager; use crate::config::AppConfig; +use crate::database::DatabaseManager; +use teloxide::{prelude::*, types::CallbackQuery}; /// Handle bot commands pub async fn handle_command( @@ -30,44 +30,62 @@ pub async fn handle_command( } Command::Requests => { // Check if user is admin - if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + if user_repo + .is_telegram_id_admin(telegram_id) + .await + .unwrap_or(false) + { // Create a fake callback query for admin requests // This is a workaround since the admin_requests function expects a callback query // In practice, we could refactor this to not need a callback query tracing::info!("Admin {} requested to view requests", telegram_id); - + let message = "📋 Use the inline keyboard to view recent requests."; - let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![ - vec![teloxide::types::InlineKeyboardButton::callback("📋 Recent Requests", "admin_requests")], - ]); - + let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![vec![ + teloxide::types::InlineKeyboardButton::callback( + "📋 Recent Requests", + "admin_requests", + ), + ]]); + bot.send_message(chat_id, message) .reply_markup(keyboard) .await?; } else { let lang = get_user_language(from); let l10n = super::localization::LocalizationService::new(); - bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + bot.send_message(chat_id, l10n.get(lang, "unauthorized")) + .await?; } } Command::Stats => { // Check if user is admin - if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + if user_repo + .is_telegram_id_admin(telegram_id) + .await + .unwrap_or(false) + { handle_stats(bot, chat_id, &db).await?; } else { let lang = get_user_language(from); let l10n = super::localization::LocalizationService::new(); - bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + bot.send_message(chat_id, l10n.get(lang, "unauthorized")) + .await?; } } Command::Broadcast { message } => { // Check if user is admin - if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + if user_repo + .is_telegram_id_admin(telegram_id) + .await + .unwrap_or(false) + { handle_broadcast(bot, chat_id, message, &user_repo).await?; } else { let lang = get_user_language(from); let l10n = super::localization::LocalizationService::new(); - bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?; + bot.send_message(chat_id, l10n.get(lang, "unauthorized")) + .await?; } } } @@ -100,68 +118,120 @@ pub async fn handle_callback_query( db: DatabaseManager, app_config: AppConfig, ) -> Result<(), Box> { - if let Some(data) = &q.data { - if let Some(callback_data) = CallbackData::parse(data) { - match callback_data { - CallbackData::RequestAccess => { - handle_request_access(bot, &q, &db).await?; - } - CallbackData::MyConfigs => { - handle_my_configs_edit(bot, &q, &db).await?; - } - CallbackData::SubscriptionLink => { - handle_subscription_link(bot, &q, &db, &app_config).await?; - } - CallbackData::Support => { - handle_support(bot, &q).await?; - } - CallbackData::AdminRequests => { - handle_admin_requests_edit(bot, &q, &db).await?; - } - CallbackData::ApproveRequest(request_id) => { - handle_approve_request(bot, &q, &request_id, &db).await?; - } - CallbackData::DeclineRequest(request_id) => { - handle_decline_request(bot, &q, &request_id, &db).await?; - } - CallbackData::ViewRequest(request_id) => { - handle_view_request(bot, &q, &request_id, &db).await?; - } - CallbackData::ShowServerConfigs(encoded_server_name) => { - handle_show_server_configs(bot, &q, &encoded_server_name, &db).await?; - } - CallbackData::SelectServerAccess(request_id) => { - // The request_id is now the full UUID from the mapping - let short_id = types::generate_short_request_id(&request_id); - handle_select_server_access(bot, &q, &short_id, &db).await?; - } - CallbackData::ToggleServer(request_id, server_id) => { - // Both IDs are now full UUIDs from the mapping - let short_request_id = types::generate_short_request_id(&request_id); - let short_server_id = types::generate_short_server_id(&server_id); - handle_toggle_server(bot, &q, &short_request_id, &short_server_id, &db).await?; - } - CallbackData::ApplyServerAccess(request_id) => { - // The request_id is now the full UUID from the mapping - let short_id = types::generate_short_request_id(&request_id); - handle_apply_server_access(bot, &q, &short_id, &db).await?; - } - CallbackData::Back => { - // Back to main menu - edit the existing message - handle_start_edit(bot, &q, &db).await?; - } - CallbackData::BackToConfigs => { - handle_my_configs_edit(bot, &q, &db).await?; - } - CallbackData::BackToRequests => { - handle_admin_requests_edit(bot, &q, &db).await?; + // Wrap all callback handling in a try-catch to send main menu on any error + let result = async { + if let Some(data) = &q.data { + if let Some(callback_data) = CallbackData::parse(data) { + match callback_data { + CallbackData::RequestAccess => { + handle_request_access(bot.clone(), &q, &db).await?; + } + CallbackData::MyConfigs => { + handle_my_configs_edit(bot.clone(), &q, &db).await?; + } + CallbackData::SubscriptionLink => { + handle_subscription_link(bot.clone(), &q, &db, &app_config).await?; + } + CallbackData::Support => { + handle_support(bot.clone(), &q).await?; + } + CallbackData::AdminRequests => { + handle_admin_requests_edit(bot.clone(), &q, &db).await?; + } + CallbackData::RequestList(page) => { + handle_request_list(bot.clone(), &q, &db, page).await?; + } + CallbackData::ApproveRequest(request_id) => { + handle_approve_request(bot.clone(), &q, &request_id, &db).await?; + } + CallbackData::DeclineRequest(request_id) => { + handle_decline_request(bot.clone(), &q, &request_id, &db).await?; + } + CallbackData::ViewRequest(request_id) => { + handle_view_request(bot.clone(), &q, &request_id, &db).await?; + } + CallbackData::ShowServerConfigs(encoded_server_name) => { + handle_show_server_configs(bot.clone(), &q, &encoded_server_name, &db).await?; + } + CallbackData::SelectServerAccess(request_id) => { + // The request_id is now the full UUID from the mapping + let short_id = types::generate_short_request_id(&request_id); + handle_select_server_access(bot.clone(), &q, &short_id, &db).await?; + } + CallbackData::ToggleServer(request_id, server_id) => { + // Both IDs are now full UUIDs from the mapping + let short_request_id = types::generate_short_request_id(&request_id); + let short_server_id = types::generate_short_server_id(&server_id); + handle_toggle_server(bot.clone(), &q, &short_request_id, &short_server_id, &db).await?; + } + CallbackData::ApplyServerAccess(request_id) => { + // The request_id is now the full UUID from the mapping + let short_id = types::generate_short_request_id(&request_id); + handle_apply_server_access(bot.clone(), &q, &short_id, &db).await?; + } + CallbackData::Back => { + // Back to main menu - edit the existing message + handle_start_edit(bot.clone(), &q, &db).await?; + } + CallbackData::BackToConfigs => { + handle_my_configs_edit(bot.clone(), &q, &db).await?; + } + CallbackData::BackToRequests => { + handle_admin_requests_edit(bot.clone(), &q, &db).await?; + } + CallbackData::ManageUsers => { + handle_manage_users(bot.clone(), &q, &db).await?; + } + CallbackData::UserList(page) => { + handle_user_list(bot.clone(), &q, &db, page).await?; + } + CallbackData::UserDetails(user_id) => { + handle_user_details(bot.clone(), &q, &db, &user_id).await?; + } + CallbackData::UserManageAccess(user_id) => { + handle_user_manage_access(bot.clone(), &q, &db, &user_id).await?; + } + CallbackData::UserToggleServer(user_id, server_id) => { + handle_user_toggle_server(bot.clone(), &q, &db, &user_id, &server_id).await?; + } + CallbackData::UserApplyAccess(user_id) => { + handle_user_apply_access(bot.clone(), &q, &db, &user_id).await?; + } + CallbackData::BackToUsers(page) => { + handle_user_list(bot.clone(), &q, &db, page).await?; + } + CallbackData::BackToMenu => { + handle_start_edit(bot.clone(), &q, &db).await?; + } } + } else { + tracing::warn!("Unknown callback data: {}", data); + return Err("Invalid callback data".into()); + } + } + Ok::<(), Box>(()) + }.await; + + // If any error occurred, send main menu and answer callback query + if let Err(e) = result { + tracing::warn!("Error handling callback query '{}': {}", q.data.as_deref().unwrap_or("None"), e); + + // Answer the callback query first to remove loading state + let _ = bot.answer_callback_query(q.id.clone()).await; + + // Try to send main menu + if let Some(message) = q.message { + let chat_id = message.chat().id; + let from = &q.from; + let telegram_id = from.id.0 as i64; + let user_repo = crate::database::repository::UserRepository::new(db.connection()); + + // Try to send main menu - if this fails too, just log it + if let Err(menu_error) = handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await { + tracing::error!("Failed to send main menu after callback error: {}", menu_error); } - } else { - tracing::warn!("Unknown callback data: {}", data); - bot.answer_callback_query(q.id.clone()).await?; } } Ok(()) -} \ No newline at end of file +} diff --git a/src/services/telegram/handlers/types.rs b/src/services/telegram/handlers/types.rs index f8287ab..b0330de 100644 --- a/src/services/telegram/handlers/types.rs +++ b/src/services/telegram/handlers/types.rs @@ -1,7 +1,7 @@ -use teloxide::utils::command::BotCommands; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User}; +use teloxide::utils::command::BotCommands; -use super::super::localization::{LocalizationService, Language}; +use super::super::localization::{Language, LocalizationService}; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -27,16 +27,25 @@ pub enum CallbackData { SubscriptionLink, Support, AdminRequests, - ApproveRequest(String), // request_id - DeclineRequest(String), // request_id - ViewRequest(String), // request_id + RequestList(u32), // page number + ApproveRequest(String), // request_id + DeclineRequest(String), // request_id + ViewRequest(String), // request_id ShowServerConfigs(String), // server_name encoded Back, - BackToConfigs, // Back to configs list from server view - BackToRequests, // Back to requests list from request view - SelectServerAccess(String), // request_id - show server selection after approval + BackToConfigs, // Back to configs list from server view + BackToRequests, // Back to requests list from request view + SelectServerAccess(String), // request_id - show server selection after approval ToggleServer(String, String), // request_id, server_id - toggle server selection - ApplyServerAccess(String), // request_id - apply selected servers + ApplyServerAccess(String), // request_id - apply selected servers + ManageUsers, + UserList(u32), // page number + UserDetails(String), // user_id + UserManageAccess(String), // user_id + UserToggleServer(String, String), // user_id, server_id + UserApplyAccess(String), // user_id + BackToUsers(u32), // page number + BackToMenu, } impl CallbackData { @@ -47,9 +56,11 @@ impl CallbackData { "subscription_link" => Some(CallbackData::SubscriptionLink), "support" => Some(CallbackData::Support), "admin_requests" => Some(CallbackData::AdminRequests), + "manage_users" => Some(CallbackData::ManageUsers), "back" => Some(CallbackData::Back), "back_to_configs" => Some(CallbackData::BackToConfigs), "back_to_requests" => Some(CallbackData::BackToRequests), + "back_to_menu" => Some(CallbackData::BackToMenu), _ => { if let Some(id) = data.strip_prefix("approve:") { Some(CallbackData::ApproveRequest(id.to_string())) @@ -64,7 +75,9 @@ impl CallbackData { } else if let Some(rest) = data.strip_prefix("t:") { let parts: Vec<&str> = rest.split(':').collect(); if parts.len() == 2 { - if let (Some(request_id), Some(server_id)) = (get_full_request_id(parts[0]), get_full_server_id(parts[1])) { + if let (Some(request_id), Some(server_id)) = + (get_full_request_id(parts[0]), get_full_server_id(parts[1])) + { Some(CallbackData::ToggleServer(request_id, server_id)) } else { None @@ -74,6 +87,31 @@ impl CallbackData { } } else if let Some(short_id) = data.strip_prefix("a:") { get_full_request_id(short_id).map(CallbackData::ApplyServerAccess) + } else if let Some(page_str) = data.strip_prefix("request_list:") { + page_str.parse::().ok().map(CallbackData::RequestList) + } else if let Some(page_str) = data.strip_prefix("user_list:") { + page_str.parse::().ok().map(CallbackData::UserList) + } else if let Some(short_user_id) = data.strip_prefix("user_details:") { + get_full_user_id(short_user_id).map(CallbackData::UserDetails) + } else if let Some(short_user_id) = data.strip_prefix("user_manage:") { + get_full_user_id(short_user_id).map(CallbackData::UserManageAccess) + } else if let Some(rest) = data.strip_prefix("user_toggle:") { + let parts: Vec<&str> = rest.split(':').collect(); + if parts.len() == 2 { + if let (Some(user_id), Some(server_id)) = + (get_full_user_id(parts[0]), get_full_server_id(parts[1])) + { + Some(CallbackData::UserToggleServer(user_id, server_id)) + } else { + None + } + } else { + None + } + } else if let Some(short_user_id) = data.strip_prefix("user_apply:") { + get_full_user_id(short_user_id).map(CallbackData::UserApplyAccess) + } else if let Some(page_str) = data.strip_prefix("back_users:") { + page_str.parse::().ok().map(CallbackData::BackToUsers) } else { None } @@ -93,6 +131,10 @@ static REQUEST_COUNTER: OnceLock>> = OnceLock::new(); static SERVER_ID_MAP: OnceLock>>> = OnceLock::new(); static SERVER_COUNTER: OnceLock>> = OnceLock::new(); +// Global storage for user ID mappings (short ID -> full UUID) +static USER_ID_MAP: OnceLock>>> = OnceLock::new(); +static USER_COUNTER: OnceLock>> = OnceLock::new(); + pub fn get_selected_servers() -> &'static Arc>>> { SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) } @@ -113,23 +155,31 @@ pub fn get_server_counter() -> &'static Arc> { SERVER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0))) } +pub fn get_user_id_map() -> &'static Arc>> { + USER_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) +} + +pub fn get_user_counter() -> &'static Arc> { + USER_COUNTER.get_or_init(|| Arc::new(Mutex::new(0))) +} + /// Generate a short ID for a request UUID and store the mapping pub fn generate_short_request_id(request_uuid: &str) -> String { let mut counter = get_request_counter().lock().unwrap(); let mut map = get_request_id_map().lock().unwrap(); - + // Check if we already have a short ID for this UUID for (short_id, uuid) in map.iter() { if uuid == request_uuid { return short_id.clone(); } } - + // Generate new short ID *counter += 1; let short_id = format!("r{}", counter); map.insert(short_id.clone(), request_uuid.to_string()); - + short_id } @@ -143,19 +193,19 @@ pub fn get_full_request_id(short_id: &str) -> Option { pub fn generate_short_server_id(server_uuid: &str) -> String { let mut counter = get_server_counter().lock().unwrap(); let mut map = get_server_id_map().lock().unwrap(); - + // Check if we already have a short ID for this UUID for (short_id, uuid) in map.iter() { if uuid == server_uuid { return short_id.clone(); } } - + // Generate new short ID *counter += 1; let short_id = format!("s{}", counter); map.insert(short_id.clone(), server_uuid.to_string()); - + short_id } @@ -165,6 +215,32 @@ pub fn get_full_server_id(short_id: &str) -> Option { map.get(short_id).cloned() } +/// Generate a short ID for a user UUID and store the mapping +pub fn generate_short_user_id(user_uuid: &str) -> String { + let mut counter = get_user_counter().lock().unwrap(); + let mut map = get_user_id_map().lock().unwrap(); + + // Check if we already have a short ID for this UUID + for (short_id, uuid) in map.iter() { + if uuid == user_uuid { + return short_id.clone(); + } + } + + // Generate new short ID + *counter += 1; + let short_id = format!("u{}", counter); + map.insert(short_id.clone(), user_uuid.to_string()); + + short_id +} + +/// Get full user UUID from short ID +pub fn get_full_user_id(short_id: &str) -> Option { + let map = get_user_id_map().lock().unwrap(); + map.get(short_id).cloned() +} + /// Helper function to get user language from Telegram user data pub fn get_user_language(user: &User) -> Language { Language::from_telegram_code(user.language_code.as_deref()) @@ -173,27 +249,44 @@ pub fn get_user_language(user: &User) -> Language { /// Main keyboard for registered users pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup { let l10n = LocalizationService::new(); - + let mut keyboard = vec![ - vec![InlineKeyboardButton::callback("🔗 Subscription Link", "subscription_link")], - vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "my_configs"), "my_configs")], - vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "support"), "support")], + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "subscription_link"), + "subscription_link", + )], + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "my_configs"), + "my_configs", + )], + vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "support"), + "support", + )], ]; - + if is_admin { - keyboard.push(vec![InlineKeyboardButton::callback(l10n.get(lang, "user_requests"), "admin_requests")]); + keyboard.push(vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "user_requests"), + "admin_requests", + )]); + keyboard.push(vec![InlineKeyboardButton::callback( + l10n.get(lang, "manage_users"), + "manage_users", + )]); } - + InlineKeyboardMarkup::new(keyboard) } /// Keyboard for new users pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup { let l10n = LocalizationService::new(); - - InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "get_vpn_access"), "request_access")], - ]) + + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "get_vpn_access"), + "request_access", + )]]) } /// Restore UUID from compact format (without dashes) @@ -201,7 +294,7 @@ fn restore_uuid(compact: &str) -> Option { if compact.len() != 32 { return None; } - + // Insert dashes at proper positions for UUID format let uuid_str = format!( "{}-{}-{}-{}-{}", @@ -211,6 +304,6 @@ fn restore_uuid(compact: &str) -> Option { &compact[16..20], &compact[20..32] ); - + Some(uuid_str) -} \ No newline at end of file +} diff --git a/src/services/telegram/handlers/user.rs b/src/services/telegram/handlers/user.rs index 024b73d..5138eac 100644 --- a/src/services/telegram/handlers/user.rs +++ b/src/services/telegram/handlers/user.rs @@ -1,11 +1,14 @@ -use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}}; -use base64::{Engine, engine::general_purpose}; +use base64::{engine::general_purpose, Engine}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, +}; -use crate::database::DatabaseManager; -use crate::database::repository::{UserRepository, UserRequestRepository}; +use super::super::localization::{Language, LocalizationService}; +use super::types::{get_main_keyboard, get_new_user_keyboard, get_user_language}; use crate::database::entities::user_request::{CreateUserRequestDto, RequestStatus}; -use super::super::localization::{LocalizationService, Language}; -use super::types::{get_user_language, get_main_keyboard, get_new_user_keyboard}; +use crate::database::repository::{UserRepository, UserRequestRepository}; +use crate::database::DatabaseManager; /// Handle start command and main menu pub async fn handle_start( @@ -28,23 +31,24 @@ pub async fn handle_start_edit( let from = &q.from; let telegram_id = from.id.0 as i64; let user_repo = UserRepository::new(db.connection()); - + if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { let chat_id = regular_msg.chat.id; handle_start_impl( - bot.clone(), - chat_id, - telegram_id, - from, - &user_repo, - db, + bot.clone(), + chat_id, + telegram_id, + from, + &user_repo, + db, Some(regular_msg.id), - Some(q.id.clone()) - ).await?; + Some(q.id.clone()), + ) + .await?; } } - + Ok(()) } @@ -61,37 +65,53 @@ async fn handle_start_impl( ) -> Result<(), Box> { let lang = get_user_language(from); let l10n = LocalizationService::new(); - + // Check if user exists in our database match user_repo.get_by_telegram_id(telegram_id).await { Ok(Some(user)) => { // Check if user is admin - let is_admin = user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false); - + let is_admin = user_repo + .is_telegram_id_admin(telegram_id) + .await + .unwrap_or(false); + // Check if user has any pending requests let request_repo = UserRequestRepository::new(db.connection().clone()); - + // Check for existing requests if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { - if let Some(latest_request) = existing_requests.into_iter() - .filter(|r| r.status == "pending" || r.status == "approved" || r.status == "declined") - .max_by_key(|r| r.created_at) { - + if let Some(latest_request) = existing_requests + .into_iter() + .filter(|r| { + r.status == "pending" || r.status == "approved" || r.status == "declined" + }) + .max_by_key(|r| r.created_at) + { match latest_request.status.as_str() { "pending" => { - let message = l10n.format(lang.clone(), "request_pending", &[ - ("status", "⏳ pending"), - ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) - ]); - + let message = l10n.format( + lang.clone(), + "request_pending", + &[ + ("status", "⏳ pending"), + ( + "date", + &latest_request + .created_at + .format("%Y-%m-%d %H:%M UTC") + .to_string(), + ), + ], + ); + let keyboard = get_new_user_keyboard(lang); - + if let Some(msg_id) = edit_message_id { bot.edit_message_text(chat_id, msg_id, message) .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(keyboard) .await?; - + if let Some(cb_id) = callback_query_id { bot.answer_callback_query(cb_id).await?; } @@ -104,19 +124,29 @@ async fn handle_start_impl( return Ok(()); } "declined" => { - let message = l10n.format(lang.clone(), "request_pending", &[ - ("status", &l10n.get(lang.clone(), "request_declined_status")), - ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) - ]); - + let message = l10n.format( + lang.clone(), + "request_pending", + &[ + ("status", &l10n.get(lang.clone(), "request_declined_status")), + ( + "date", + &latest_request + .created_at + .format("%Y-%m-%d %H:%M UTC") + .to_string(), + ), + ], + ); + let keyboard = get_new_user_keyboard(lang); - + if let Some(msg_id) = edit_message_id { bot.edit_message_text(chat_id, msg_id, message) .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(keyboard) .await?; - + if let Some(cb_id) = callback_query_id { bot.answer_callback_query(cb_id).await?; } @@ -132,16 +162,16 @@ async fn handle_start_impl( } } } - + // Existing user - show main menu let message = l10n.format(lang.clone(), "welcome_back", &[("name", &user.name)]); let keyboard = get_main_keyboard(is_admin, lang); - + if let Some(msg_id) = edit_message_id { bot.edit_message_text(chat_id, msg_id, message) .reply_markup(keyboard) .await?; - + if let Some(cb_id) = callback_query_id { bot.answer_callback_query(cb_id).await?; } @@ -156,12 +186,12 @@ async fn handle_start_impl( let username = from.username.as_deref().unwrap_or("Unknown"); let message = l10n.format(lang.clone(), "welcome_new_user", &[("username", username)]); let keyboard = get_new_user_keyboard(lang); - + if let Some(msg_id) = edit_message_id { bot.edit_message_text(chat_id, msg_id, message) .reply_markup(keyboard) .await?; - + if let Some(cb_id) = callback_query_id { bot.answer_callback_query(cb_id).await?; } @@ -176,7 +206,7 @@ async fn handle_start_impl( bot.send_message(chat_id, "Database error occurred").await?; } } - + Ok(()) } @@ -190,56 +220,73 @@ pub async fn handle_request_access( let lang = get_user_language(from); let l10n = LocalizationService::new(); let telegram_id = from.id.0 as i64; - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + let user_repo = UserRepository::new(db.connection()); let request_repo = UserRequestRepository::new(db.connection().clone()); - + // Check if user already exists - if let Some(_) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + if let Some(_) = user_repo + .get_by_telegram_id(telegram_id) + .await + .unwrap_or(None) + { bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "already_approved")) .await?; return Ok(()); } - + // Check for existing requests if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await { - if let Some(latest_request) = existing_requests.iter() + if let Some(latest_request) = existing_requests + .iter() .filter(|r| r.status == "pending") - .max_by_key(|r| r.created_at) { - + .max_by_key(|r| r.created_at) + { // Show pending status in the message instead of just an alert - let message = l10n.format(lang.clone(), "request_pending", &[ - ("status", "⏳ pending"), - ("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) - ]); - + let message = l10n.format( + lang.clone(), + "request_pending", + &[ + ("status", "⏳ pending"), + ( + "date", + &latest_request + .created_at + .format("%Y-%m-%d %H:%M UTC") + .to_string(), + ), + ], + ); + if let Some(message_ref) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref { - let _ = bot.edit_message_text(chat_id, msg.id, message) + let _ = bot + .edit_message_text(chat_id, msg.id, message) .parse_mode(teloxide::types::ParseMode::Html) - .reply_markup(InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], - ])) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"), + ]])) .await; } } - + bot.answer_callback_query(q.id.clone()).await?; return Ok(()); } - + // Check for declined requests - allow new request after decline - let _has_declined = existing_requests.iter() - .any(|r| r.status == "declined"); + let _has_declined = existing_requests.iter().any(|r| r.status == "declined"); } - + // Create new access request let dto = CreateUserRequestDto { telegram_id, @@ -249,23 +296,28 @@ pub async fn handle_request_access( request_message: Some("Access request via Telegram bot".to_string()), language: lang.code().to_string(), }; - + match request_repo.create(dto).await { Ok(request) => { // Edit message to show success if let Some(message) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message { - let _ = bot.edit_message_text(chat_id, msg.id, l10n.get(lang.clone(), "request_submitted")) - .reply_markup(InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], - ])) + let _ = bot + .edit_message_text( + chat_id, + msg.id, + l10n.get(lang.clone(), "request_submitted"), + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"), + ]])) .await; } } - + // Notify admins notify_admins_new_request(&bot, &request, db).await?; - + bot.answer_callback_query(q.id.clone()).await?; } Err(e) => { @@ -275,7 +327,7 @@ pub async fn handle_request_access( .await?; } } - + Ok(()) } @@ -289,64 +341,83 @@ pub async fn handle_my_configs_edit( let lang = get_user_language(from); let l10n = LocalizationService::new(); let telegram_id = from.id.0 as i64; - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + let user_repo = UserRepository::new(db.connection()); - let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); let uri_service = crate::services::UriGeneratorService::new(); - - if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + + if let Some(user) = user_repo + .get_by_telegram_id(telegram_id) + .await + .unwrap_or(None) + { // Get all active inbound users for this user - let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); - + let inbound_users = inbound_users_repo + .find_by_user_id(user.id) + .await + .unwrap_or_default(); + if inbound_users.is_empty() { // Edit message to show no configs available if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { - bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_configs_available")) - .reply_markup(InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], - ])) - .await?; + bot.edit_message_text( + chat_id, + regular_msg.id, + l10n.get(lang.clone(), "no_configs_available"), + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback(l10n.get(lang, "back"), "back"), + ]])) + .await?; } } bot.answer_callback_query(q.id.clone()).await?; return Ok(()); } - + // Structure to hold config with inbound_id #[derive(Debug, Clone)] struct ConfigWithInbound { client_config: crate::services::uri_generator::ClientConfig, server_inbound_id: uuid::Uuid, } - + // Group configurations by server name - let mut servers: std::collections::HashMap> = std::collections::HashMap::new(); - + let mut servers: std::collections::HashMap> = + std::collections::HashMap::new(); + for inbound_user in inbound_users { if !inbound_user.is_active { continue; } - + // Get client config data for this specific inbound - if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { + if let Ok(Some(config_data)) = inbound_users_repo + .get_client_config_data(user.id, inbound_user.server_inbound_id) + .await + { match uri_service.generate_client_config(user.id, &config_data) { Ok(client_config) => { let config_with_inbound = ConfigWithInbound { client_config: client_config.clone(), server_inbound_id: inbound_user.server_inbound_id, }; - - servers.entry(client_config.server_name.clone()) + + servers + .entry(client_config.server_name.clone()) .or_insert_with(Vec::new) .push(config_with_inbound); - }, + } Err(e) => { tracing::warn!("Failed to generate client config: {}", e); continue; @@ -354,14 +425,14 @@ pub async fn handle_my_configs_edit( } } } - + // Build message with statistics only let mut message_lines = vec![l10n.get(lang.clone(), "your_configurations")]; - + // Calculate statistics let server_count = servers.len(); let total_configs = servers.values().map(|configs| configs.len()).sum::(); - + // Count unique protocols let mut protocols = std::collections::HashSet::new(); for configs in servers.values() { @@ -369,96 +440,122 @@ pub async fn handle_my_configs_edit( protocols.insert(config_with_inbound.client_config.protocol.clone()); } } - + let server_word = match lang { Language::Russian => { - if server_count == 1 { "сервер" } - else if server_count < 5 { "сервера" } - else { "серверов" } - }, + if server_count == 1 { + "сервер" + } else if server_count < 5 { + "сервера" + } else { + "серверов" + } + } Language::English => { - if server_count == 1 { "server" } - else { "servers" } + if server_count == 1 { + "server" + } else { + "servers" + } } }; - + let config_word = match lang { Language::Russian => { - if total_configs == 1 { "конфигурация" } - else if total_configs < 5 { "конфигурации" } - else { "конфигураций" } - }, + if total_configs == 1 { + "конфигурация" + } else if total_configs < 5 { + "конфигурации" + } else { + "конфигураций" + } + } Language::English => { - if total_configs == 1 { "configuration" } - else { "configurations" } + if total_configs == 1 { + "configuration" + } else { + "configurations" + } } }; - + let protocol_word = match lang { Language::Russian => { - if protocols.len() == 1 { "протокол" } - else if protocols.len() < 5 { "протокола" } - else { "протоколов" } - }, + if protocols.len() == 1 { + "протокол" + } else if protocols.len() < 5 { + "протокола" + } else { + "протоколов" + } + } Language::English => { - if protocols.len() == 1 { "protocol" } - else { "protocols" } + if protocols.len() == 1 { + "protocol" + } else { + "protocols" + } } }; - + message_lines.push(format!( "\n📊 {} {} • {} {} • {} {}", - server_count, server_word, - total_configs, config_word, - protocols.len(), protocol_word + server_count, + server_word, + total_configs, + config_word, + protocols.len(), + protocol_word )); - + // Create keyboard with buttons for each server let mut keyboard_buttons = vec![]; - + for (server_name, configs) in servers.iter() { // Encode server name to avoid issues with special characters let encoded_server_name = general_purpose::STANDARD.encode(server_name.as_bytes()); let config_count = configs.len(); - + let config_suffix = match lang { Language::Russian => { - if config_count == 1 { - "" - } else if config_count < 5 { - "а" - } else { - "ов" + if config_count == 1 { + "" + } else if config_count < 5 { + "а" + } else { + "ов" } - }, + } Language::English => { - if config_count == 1 { - "" - } else { - "s" + if config_count == 1 { + "" + } else { + "s" } } }; - + let config_word = match lang { Language::Russian => "конфиг", Language::English => "config", }; - - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback( - format!("🖥️ {} ({} {}{})", server_name, config_count, config_word, config_suffix), - format!("server_configs:{}", encoded_server_name) - ) - ]); + + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + format!( + "🖥️ {} ({} {}{})", + server_name, config_count, config_word, config_suffix + ), + format!("server_configs:{}", encoded_server_name), + )]); } - - keyboard_buttons.push(vec![ - InlineKeyboardButton::callback(l10n.get(lang, "back"), "back") - ]); - + + keyboard_buttons.push(vec![InlineKeyboardButton::callback( + l10n.get(lang, "back"), + "back", + )]); + let message = message_lines.join("\n"); - + // Edit the existing message instead of sending a new one if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { @@ -468,10 +565,10 @@ pub async fn handle_my_configs_edit( .await?; } } - + bot.answer_callback_query(q.id.clone()).await?; } - + Ok(()) } @@ -486,42 +583,55 @@ pub async fn handle_show_server_configs( let lang = get_user_language(from); let l10n = LocalizationService::new(); let telegram_id = from.id.0 as i64; - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - + }) + .ok_or("No chat ID")?; + // Decode server name let server_name = match general_purpose::STANDARD.decode(encoded_server_name) { Ok(bytes) => String::from_utf8(bytes).map_err(|_| "Invalid server name encoding")?, Err(_) => return Ok(()), // Invalid encoding, ignore }; - + let user_repo = UserRepository::new(db.connection()); - let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone()); + let inbound_users_repo = + crate::database::repository::InboundUsersRepository::new(db.connection().clone()); let uri_service = crate::services::UriGeneratorService::new(); - + // Get user from telegram_id - if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + if let Some(user) = user_repo + .get_by_telegram_id(telegram_id) + .await + .unwrap_or(None) + { // Get all active inbound users for this user - let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default(); - + let inbound_users = inbound_users_repo + .find_by_user_id(user.id) + .await + .unwrap_or_default(); + let mut server_configs = Vec::new(); - + for inbound_user in inbound_users { if !inbound_user.is_active { continue; } - + // Get client config data for this specific inbound - if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await { + if let Ok(Some(config_data)) = inbound_users_repo + .get_client_config_data(user.id, inbound_user.server_inbound_id) + .await + { if config_data.server_name == server_name { match uri_service.generate_client_config(user.id, &config_data) { Ok(client_config) => { server_configs.push(client_config); - }, + } Err(e) => { tracing::warn!("Failed to generate client config: {}", e); continue; @@ -530,28 +640,30 @@ pub async fn handle_show_server_configs( } } } - + if server_configs.is_empty() { bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "config_not_found")) .await?; return Ok(()); } - + // Build message with all configs for this server - let mut message_lines = vec![ - l10n.format(lang.clone(), "server_configs_title", &[("server_name", &server_name)]) - ]; - + let mut message_lines = vec![l10n.format( + lang.clone(), + "server_configs_title", + &[("server_name", &server_name)], + )]; + for config in &server_configs { let protocol_emoji = match config.protocol.as_str() { "vless" => "🔵", - "vmess" => "🟢", + "vmess" => "🟢", "trojan" => "🔴", "shadowsocks" => "🟡", - _ => "⚪" + _ => "⚪", }; - + message_lines.push(format!( "\n{} {} - {} ({})", protocol_emoji, @@ -559,17 +671,18 @@ pub async fn handle_show_server_configs( config.template_name, config.protocol.to_uppercase() )); - + message_lines.push(format!("{}", config.uri)); } - + // Create back button - let keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back_to_configs")], - ]); - + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "back"), + "back_to_configs", + )]]); + let message = message_lines.join("\n"); - + // Edit the existing message instead of sending a new one if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { @@ -579,14 +692,14 @@ pub async fn handle_show_server_configs( .await?; } } - + bot.answer_callback_query(q.id.clone()).await?; } else { bot.answer_callback_query(q.id.clone()) .text(l10n.get(lang, "unauthorized")) .await?; } - + Ok(()) } @@ -598,17 +711,20 @@ pub async fn handle_support( let from = &q.from; let lang = get_user_language(from); let l10n = LocalizationService::new(); - let chat_id = q.message.as_ref().and_then(|m| { - match m { + let chat_id = q + .message + .as_ref() + .and_then(|m| match m { teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id), _ => None, - } - }).ok_or("No chat ID")?; - - let keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back")], - ]); - + }) + .ok_or("No chat ID")?; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang.clone(), "back"), + "back", + )]]); + // Edit the existing message instead of sending a new one if let Some(msg) = &q.message { if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg { @@ -618,9 +734,9 @@ pub async fn handle_support( .await?; } } - + bot.answer_callback_query(q.id.clone()).await?; - + Ok(()) } @@ -631,40 +747,61 @@ async fn notify_admins_new_request( db: &DatabaseManager, ) -> Result<(), Box> { let user_repo = UserRepository::new(db.connection()); - + // Get all admins let admins = user_repo.get_telegram_admins().await.unwrap_or_default(); - + if !admins.is_empty() { let lang = Language::English; // Default admin language let l10n = LocalizationService::new(); - - let message = l10n.format(lang.clone(), "new_access_request", &[ - ("first_name", &request.telegram_first_name.as_deref().unwrap_or("")), - ("last_name", &request.telegram_last_name.as_deref().unwrap_or("")), - ("username", &request.telegram_username.as_deref().unwrap_or("unknown")), - ]); - + + let message = l10n.format( + lang.clone(), + "new_access_request", + &[ + ( + "first_name", + &request.telegram_first_name.as_deref().unwrap_or(""), + ), + ( + "last_name", + &request.telegram_last_name.as_deref().unwrap_or(""), + ), + ( + "username", + &request.telegram_username.as_deref().unwrap_or("unknown"), + ), + ], + ); + let keyboard = InlineKeyboardMarkup::new(vec![ vec![ - InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)), - InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)), - ], - vec![ - InlineKeyboardButton::callback("📋 All Requests", "back_to_requests"), + InlineKeyboardButton::callback( + l10n.get(lang.clone(), "approve"), + format!("approve:{}", request.id), + ), + InlineKeyboardButton::callback( + l10n.get(lang.clone(), "decline"), + format!("decline:{}", request.id), + ), ], + vec![InlineKeyboardButton::callback( + "📋 All Requests", + "back_to_requests", + )], ]); - + for admin in admins { if let Some(telegram_id) = admin.telegram_id { - let _ = bot.send_message(ChatId(telegram_id), &message) + let _ = bot + .send_message(ChatId(telegram_id), &message) .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(keyboard.clone()) .await; } } } - + Ok(()) } @@ -685,7 +822,7 @@ pub async fn handle_subscription_link( if let Ok(Some(user)) = user_repo.get_by_telegram_id(telegram_id).await { // Generate subscription URL let subscription_url = format!("{}/sub/{}", app_config.web.base_url, user.id); - + let message = match lang { Language::Russian => { format!( @@ -695,7 +832,7 @@ pub async fn handle_subscription_link( 💡 Эта ссылка содержит все ваши конфигурации и автоматически обновляется при изменениях", subscription_url ) - }, + } Language::English => { format!( "🔗 Your Subscription Link\n\n\ @@ -707,9 +844,10 @@ pub async fn handle_subscription_link( } }; - let keyboard = InlineKeyboardMarkup::new(vec![ - vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")], - ]); + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + l10n.get(lang, "back"), + "back", + )]]); // Edit the existing message if let Some(msg) = &q.message { @@ -731,4 +869,4 @@ pub async fn handle_subscription_link( bot.answer_callback_query(q.id.clone()).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/services/telegram/localization/mod.rs b/src/services/telegram/localization/mod.rs index 76b0da5..c126bd5 100644 --- a/src/services/telegram/localization/mod.rs +++ b/src/services/telegram/localization/mod.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Language { @@ -37,20 +37,20 @@ pub struct Translations { pub back: String, pub approve: String, pub decline: String, - + // Request handling pub already_pending: String, pub already_approved: String, pub already_declined: String, pub request_submitted: String, pub request_submit_failed: String, - + // Approval/Decline messages pub request_approved: String, pub request_declined: String, pub request_approved_notification: String, pub request_declined_notification: String, - + // Admin messages pub new_access_request: String, pub no_pending_requests: String, @@ -59,22 +59,22 @@ pub struct Translations { pub request_approved_admin: String, pub request_declined_admin: String, pub user_creation_failed: String, - + // Support pub support_info: String, - + // Stats pub statistics: String, pub total_users: String, pub total_servers: String, pub total_inbounds: String, pub pending_requests: String, - + // Broadcast pub broadcast_complete: String, pub sent: String, pub failed: String, - + // Configs pub configs_coming_soon: String, pub your_configurations: String, @@ -83,7 +83,28 @@ pub struct Translations { pub config_copied: String, pub config_not_found: String, pub server_configs_title: String, - + + // Subscription + pub subscription_link: String, + + // User Management + pub manage_users: String, + pub user_list: String, + pub user_details: String, + pub manage_access: String, + pub remove_access: String, + pub grant_access: String, + pub user_info: String, + pub no_users_found: String, + pub page_info: String, + pub next_page: String, + pub prev_page: String, + pub back_to_users: String, + pub back_to_menu: String, + pub access_updated: String, + pub access_removed: String, + pub access_granted: String, + // Errors pub error_occurred: String, pub admin_not_found: String, @@ -98,20 +119,22 @@ pub struct LocalizationService { impl LocalizationService { pub fn new() -> Self { let mut translations = HashMap::new(); - + // Load English translations translations.insert(Language::English, Self::load_english()); - + // Load Russian translations translations.insert(Language::Russian, Self::load_russian()); - + Self { translations } } pub fn get(&self, lang: Language, key: &str) -> String { - let translations = self.translations.get(&lang) + let translations = self + .translations + .get(&lang) .unwrap_or_else(|| self.translations.get(&Language::English).unwrap()); - + match key { "welcome_new_user" => translations.welcome_new_user.clone(), "welcome_back" => translations.welcome_back.clone(), @@ -157,6 +180,23 @@ impl LocalizationService { "config_copied" => translations.config_copied.clone(), "config_not_found" => translations.config_not_found.clone(), "server_configs_title" => translations.server_configs_title.clone(), + "subscription_link" => translations.subscription_link.clone(), + "manage_users" => translations.manage_users.clone(), + "user_list" => translations.user_list.clone(), + "user_details" => translations.user_details.clone(), + "manage_access" => translations.manage_access.clone(), + "remove_access" => translations.remove_access.clone(), + "grant_access" => translations.grant_access.clone(), + "user_info" => translations.user_info.clone(), + "no_users_found" => translations.no_users_found.clone(), + "page_info" => translations.page_info.clone(), + "next_page" => translations.next_page.clone(), + "prev_page" => translations.prev_page.clone(), + "back_to_users" => translations.back_to_users.clone(), + "back_to_menu" => translations.back_to_menu.clone(), + "access_updated" => translations.access_updated.clone(), + "access_removed" => translations.access_removed.clone(), + "access_granted" => translations.access_granted.clone(), "error_occurred" => translations.error_occurred.clone(), "admin_not_found" => translations.admin_not_found.clone(), "request_not_found" => translations.request_not_found.clone(), @@ -183,7 +223,7 @@ impl LocalizationService { get_vpn_access: "🚀 Get VPN Access".to_string(), my_configs: "📋 My Configs".to_string(), support: "💬 Support".to_string(), - user_requests: "👥 User Requests".to_string(), + user_requests: "❔ User Requests".to_string(), back: "🔙 Back".to_string(), approve: "✅ Approve".to_string(), decline: "❌ Decline".to_string(), @@ -201,13 +241,13 @@ impl LocalizationService { new_access_request: "🔔 New Access Request\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(), no_pending_requests: "No pending access requests".to_string(), - access_request_details: "📋 Access Request\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(), + access_request_details: "❔ Access Request\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(), unauthorized: "❌ You are not authorized to use this command".to_string(), request_approved_admin: "✅ Request approved".to_string(), request_declined_admin: "❌ Request declined".to_string(), user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(), - support_info: "💬 Support Information\n\nIf you need help, please contact the administrators.\n\nYou can also check the documentation at:\nhttps://github.com/OutFleet".to_string(), + support_info: "💬 Support Information\n\n📱 How to connect:\n1. Download v2raytun app for Android or iOS from:\n https://v2raytun.com/\n\n2. Add your subscription link from \"🔗 Subscription Link\" menu\n OR\n Add individual server links from \"📋 My Configs\"\n\n3. Connect and enjoy secure VPN!\n\n❓ If you need help, please contact the administrators.".to_string(), statistics: "📊 Statistics\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(), total_users: "👥 Total Users".to_string(), @@ -227,6 +267,25 @@ impl LocalizationService { config_not_found: "❌ Configuration not found".to_string(), server_configs_title: "🖥️ {server_name} - Connection Links".to_string(), + subscription_link: "🔗 Subscription Link".to_string(), + + manage_users: "👥 Manage Users".to_string(), + user_list: "👥 User List".to_string(), + user_details: "👤 User Details".to_string(), + manage_access: "🔧 Manage Access".to_string(), + remove_access: "❌ Remove Access".to_string(), + grant_access: "✅ Grant Access".to_string(), + user_info: "User Information".to_string(), + no_users_found: "No users found".to_string(), + page_info: "Page {page} of {total}".to_string(), + next_page: "Next →".to_string(), + prev_page: "← Previous".to_string(), + back_to_users: "👥 Back to Users".to_string(), + back_to_menu: "🏠 Main Menu".to_string(), + access_updated: "✅ Access updated successfully".to_string(), + access_removed: "❌ Access removed successfully".to_string(), + access_granted: "✅ Access granted successfully".to_string(), + error_occurred: "An error occurred".to_string(), admin_not_found: "Admin not found".to_string(), request_not_found: "Request not found".to_string(), @@ -244,7 +303,7 @@ impl LocalizationService { get_vpn_access: "🚀 Получить доступ к VPN".to_string(), my_configs: "📋 Мои конфигурации".to_string(), support: "💬 Поддержка".to_string(), - user_requests: "👥 Запросы пользователей".to_string(), + user_requests: "❔ Запросы пользователей".to_string(), back: "🔙 Назад".to_string(), approve: "✅ Одобрить".to_string(), decline: "❌ Отклонить".to_string(), @@ -262,13 +321,13 @@ impl LocalizationService { new_access_request: "🔔 Новый запрос на доступ\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(), no_pending_requests: "Нет ожидающих запросов на доступ".to_string(), - access_request_details: "📋 Запрос на доступ\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(), + access_request_details: "❔ Запрос на доступ\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(), unauthorized: "❌ У вас нет прав для использования этой команды".to_string(), request_approved_admin: "✅ Запрос одобрен".to_string(), request_declined_admin: "❌ Запрос отклонен".to_string(), user_creation_failed: "❌ Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(), - support_info: "💬 Информация о поддержке\n\nЕсли вам нужна помощь, пожалуйста, свяжитесь с администраторами.\n\nВы также можете ознакомиться с документацией по адресу:\nhttps://github.com/OutFleet".to_string(), + support_info: "💬 Информация о поддержке\n\n📱 Как подключиться:\n1. Скачайте приложение v2raytun для Android или iOS с сайта:\n https://v2raytun.com/\n\n2. Добавьте ссылку подписки из меню \"🔗 Ссылка подписки\"\n ИЛИ\n Добавьте отдельные ссылки серверов из \"📋 Мои конфигурации\"\n\n3. Подключайтесь и наслаждайтесь безопасным VPN!\n\n❓ Если нужна помощь, обратитесь к администраторам.".to_string(), statistics: "📊 Статистика\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(), total_users: "👥 Всего пользователей".to_string(), @@ -288,10 +347,29 @@ impl LocalizationService { config_not_found: "❌ Конфигурация не найдена".to_string(), server_configs_title: "🖥️ {server_name} - Ссылки для подключения".to_string(), + subscription_link: "🔗 Ссылка подписки".to_string(), + + manage_users: "👥 Управление пользователями".to_string(), + user_list: "👥 Список пользователей".to_string(), + user_details: "👤 Данные пользователя".to_string(), + manage_access: "🔧 Управление доступом".to_string(), + remove_access: "❌ Убрать доступ".to_string(), + grant_access: "✅ Предоставить доступ".to_string(), + user_info: "Информация о пользователе".to_string(), + no_users_found: "Пользователи не найдены".to_string(), + page_info: "Страница {page} из {total}".to_string(), + next_page: "Далее →".to_string(), + prev_page: "← Назад".to_string(), + back_to_users: "👥 К пользователям".to_string(), + back_to_menu: "🏠 Главное меню".to_string(), + access_updated: "✅ Доступ успешно обновлен".to_string(), + access_removed: "❌ Доступ успешно убран".to_string(), + access_granted: "✅ Доступ успешно предоставлен".to_string(), + error_occurred: "Произошла ошибка".to_string(), admin_not_found: "Администратор не найден".to_string(), request_not_found: "Запрос не найден".to_string(), invalid_request_id: "Неверный ID запроса".to_string(), } } -} \ No newline at end of file +} diff --git a/src/services/telegram/mod.rs b/src/services/telegram/mod.rs index 579d28d..05e7c5f 100644 --- a/src/services/telegram/mod.rs +++ b/src/services/telegram/mod.rs @@ -1,17 +1,17 @@ use anyhow::Result; use std::sync::Arc; -use teloxide::{Bot, prelude::*}; +use teloxide::{prelude::*, Bot}; use tokio::sync::RwLock; use uuid::Uuid; -use crate::database::DatabaseManager; -use crate::database::repository::TelegramConfigRepository; -use crate::database::entities::telegram_config::Model as TelegramConfig; use crate::config::AppConfig; +use crate::database::entities::telegram_config::Model as TelegramConfig; +use crate::database::repository::TelegramConfigRepository; +use crate::database::DatabaseManager; pub mod bot; -pub mod handlers; pub mod error; +pub mod handlers; pub mod localization; pub use error::TelegramError; @@ -40,12 +40,12 @@ impl TelegramService { /// Initialize and start the bot if active configuration exists pub async fn initialize(&self) -> Result<()> { let repo = TelegramConfigRepository::new(self.db.connection()); - + // Get active configuration if let Some(config) = repo.get_active().await? { self.start_with_config(config).await?; } - + Ok(()) } @@ -56,7 +56,7 @@ impl TelegramService { // Create new bot instance let bot = Bot::new(&config.bot_token); - + // Verify token by calling getMe match bot.get_me().await { Ok(me) => { @@ -87,7 +87,7 @@ impl TelegramService { let db = self.db.clone(); let app_config = self.app_config.clone(); - + // Spawn polling task tokio::spawn(async move { bot::run_polling(bot, db, app_config, rx).await; @@ -114,7 +114,7 @@ impl TelegramService { /// Update configuration and restart if needed pub async fn update_config(&self, config_id: Uuid) -> Result<()> { let repo = TelegramConfigRepository::new(self.db.connection()); - + if let Some(config) = repo.find_by_id(config_id).await? { if config.is_active { self.start_with_config(config).await?; @@ -122,7 +122,7 @@ impl TelegramService { self.stop().await?; } } - + Ok(()) } @@ -130,7 +130,7 @@ impl TelegramService { pub async fn get_status(&self) -> BotStatus { let bot_guard = self.bot.read().await; let config_guard = self.config.read().await; - + BotStatus { is_running: bot_guard.is_some(), config: config_guard.clone(), @@ -140,7 +140,7 @@ impl TelegramService { /// Send message to user pub async fn send_message(&self, chat_id: i64, text: String) -> Result<()> { let bot_guard = self.bot.read().await; - + if let Some(bot) = bot_guard.as_ref() { bot.send_message(ChatId(chat_id), text).await?; Ok(()) @@ -148,11 +148,16 @@ impl TelegramService { Err(anyhow::anyhow!("Bot is not running")) } } - + /// Send message to user with inline keyboard - pub async fn send_message_with_keyboard(&self, chat_id: i64, text: String, keyboard: teloxide::types::InlineKeyboardMarkup) -> Result<()> { + pub async fn send_message_with_keyboard( + &self, + chat_id: i64, + text: String, + keyboard: teloxide::types::InlineKeyboardMarkup, + ) -> Result<()> { let bot_guard = self.bot.read().await; - + if let Some(bot) = bot_guard.as_ref() { bot.send_message(ChatId(chat_id), text) .parse_mode(teloxide::types::ParseMode::Html) @@ -167,11 +172,11 @@ impl TelegramService { /// Send message to all admins pub async fn broadcast_to_admins(&self, text: String) -> Result<()> { let bot_guard = self.bot.read().await; - + if let Some(bot) = bot_guard.as_ref() { let user_repo = crate::database::repository::UserRepository::new(self.db.connection()); let admins = user_repo.get_telegram_admins().await?; - + for admin in admins { if let Some(telegram_id) = admin.telegram_id { if let Err(e) = bot.send_message(ChatId(telegram_id), text.clone()).await { @@ -179,7 +184,7 @@ impl TelegramService { } } } - + Ok(()) } else { Err(anyhow::anyhow!("Bot is not running")) @@ -192,4 +197,4 @@ impl TelegramService { pub struct BotStatus { pub is_running: bool, pub config: Option, -} \ No newline at end of file +} diff --git a/src/services/uri_generator/builders/mod.rs b/src/services/uri_generator/builders/mod.rs index fe94c28..1c86355 100644 --- a/src/services/uri_generator/builders/mod.rs +++ b/src/services/uri_generator/builders/mod.rs @@ -1,30 +1,36 @@ -use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; +use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData}; +pub mod shadowsocks; +pub mod trojan; pub mod vless; pub mod vmess; -pub mod trojan; -pub mod shadowsocks; +pub use shadowsocks::ShadowsocksUriBuilder; +pub use trojan::TrojanUriBuilder; pub use vless::VlessUriBuilder; pub use vmess::VmessUriBuilder; -pub use trojan::TrojanUriBuilder; -pub use shadowsocks::ShadowsocksUriBuilder; /// Common trait for all URI builders pub trait UriBuilder { /// Build URI string from client configuration data fn build_uri(&self, config: &ClientConfigData) -> Result; - + /// Validate configuration for this protocol fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { if config.hostname.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "hostname".to_string(), + )); } if config.port <= 0 || config.port > 65535 { - return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + return Err(UriGeneratorError::InvalidConfiguration( + "Invalid port number".to_string(), + )); } if config.xray_user_id.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "xray_user_id".to_string(), + )); } Ok(()) } @@ -32,28 +38,28 @@ pub trait UriBuilder { /// Helper functions for URI building pub mod utils { - use std::collections::HashMap; - use serde_json::Value; use crate::services::uri_generator::error::UriGeneratorError; - + use serde_json::Value; + use std::collections::HashMap; + /// URL encode a string safely pub fn url_encode(input: &str) -> String { urlencoding::encode(input).to_string() } - + /// Build query string from parameters pub fn build_query_string(params: &HashMap) -> String { let mut query_parts: Vec = Vec::new(); - + for (key, value) in params { if !value.is_empty() { query_parts.push(format!("{}={}", url_encode(key), url_encode(value))); } } - + query_parts.join("&") } - + /// Extract transport type from stream settings pub fn extract_transport_type(stream_settings: &Value) -> String { stream_settings @@ -62,7 +68,7 @@ pub mod utils { .unwrap_or("tcp") .to_string() } - + /// Extract security type from stream settings pub fn extract_security_type(stream_settings: &Value, has_certificate: bool) -> String { if has_certificate { @@ -75,7 +81,7 @@ pub mod utils { "none".to_string() } } - + /// Extract WebSocket path from stream settings pub fn extract_ws_path(stream_settings: &Value) -> Option { stream_settings @@ -84,7 +90,7 @@ pub mod utils { .and_then(|p| p.as_str()) .map(|s| s.to_string()) } - + /// Extract WebSocket host from stream settings pub fn extract_ws_host(stream_settings: &Value) -> Option { stream_settings @@ -94,7 +100,7 @@ pub mod utils { .and_then(|host| host.as_str()) .map(|s| s.to_string()) } - + /// Extract gRPC service name from stream settings pub fn extract_grpc_service_name(stream_settings: &Value) -> Option { stream_settings @@ -103,23 +109,27 @@ pub mod utils { .and_then(|name| name.as_str()) .map(|s| s.to_string()) } - + /// Extract TLS SNI from stream settings - pub fn extract_tls_sni(stream_settings: &Value, certificate_domain: Option<&str>) -> Option { + pub fn extract_tls_sni( + stream_settings: &Value, + certificate_domain: Option<&str>, + ) -> Option { // Try stream settings first if let Some(sni) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("serverName")) - .and_then(|sni| sni.as_str()) { + .and_then(|sni| sni.as_str()) + { return Some(sni.to_string()); } - + // Fall back to certificate domain certificate_domain.map(|s| s.to_string()) } - + /// Determine alias for the URI pub fn generate_alias(server_name: &str, template_name: &str) -> String { format!("{} - {}", server_name, template_name) } -} \ No newline at end of file +} diff --git a/src/services/uri_generator/builders/shadowsocks.rs b/src/services/uri_generator/builders/shadowsocks.rs index 98ac154..321cdbd 100644 --- a/src/services/uri_generator/builders/shadowsocks.rs +++ b/src/services/uri_generator/builders/shadowsocks.rs @@ -1,8 +1,8 @@ -use base64::{Engine as _, engine::general_purpose}; +use base64::{engine::general_purpose, Engine as _}; use serde_json::Value; -use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; -use super::{UriBuilder, utils}; +use super::{utils, UriBuilder}; +use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData}; pub struct ShadowsocksUriBuilder; @@ -10,54 +10,56 @@ impl ShadowsocksUriBuilder { pub fn new() -> Self { Self } - + /// Map Xray cipher type to Shadowsocks method name fn map_xray_cipher_to_shadowsocks_method(&self, cipher: &str) -> &str { match cipher { // AES GCM variants "AES_256_GCM" | "aes-256-gcm" => "aes-256-gcm", "AES_128_GCM" | "aes-128-gcm" => "aes-128-gcm", - - // ChaCha20 variants - "CHACHA20_POLY1305" | "chacha20-ietf-poly1305" | "chacha20-poly1305" => "chacha20-ietf-poly1305", - + + // ChaCha20 variants + "CHACHA20_POLY1305" | "chacha20-ietf-poly1305" | "chacha20-poly1305" => { + "chacha20-ietf-poly1305" + } + // AES CFB variants "AES_256_CFB" | "aes-256-cfb" => "aes-256-cfb", "AES_128_CFB" | "aes-128-cfb" => "aes-128-cfb", - + // Legacy ciphers "RC4_MD5" | "rc4-md5" => "rc4-md5", "AES_256_CTR" | "aes-256-ctr" => "aes-256-ctr", "AES_128_CTR" | "aes-128-ctr" => "aes-128-ctr", - + // Default to most secure and widely supported _ => "aes-256-gcm", } } - } impl UriBuilder for ShadowsocksUriBuilder { fn build_uri(&self, config: &ClientConfigData) -> Result { self.validate_config(config)?; - + // Get cipher type from base_settings and map to Shadowsocks method - let cipher = config.base_settings + let cipher = config + .base_settings .get("cipherType") .and_then(|c| c.as_str()) .or_else(|| config.base_settings.get("method").and_then(|m| m.as_str())) .unwrap_or("AES_256_GCM"); - + let method = self.map_xray_cipher_to_shadowsocks_method(cipher); - + // Shadowsocks SIP002 format: ss://base64(method:password)@hostname:port#remark // Use xray_user_id as password (following Marzban approach) let credentials = format!("{}:{}", method, config.xray_user_id); let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes()); - + // Generate alias for the URI let alias = utils::generate_alias(&config.server_name, &config.template_name); - + // Build simple SIP002 URI (no plugin parameters for standard Shadowsocks) let uri = format!( "ss://{}@{}:{}#{}", @@ -66,24 +68,30 @@ impl UriBuilder for ShadowsocksUriBuilder { config.port, utils::url_encode(&alias) ); - + Ok(uri) } - + fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { // Basic validation if config.hostname.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "hostname".to_string(), + )); } if config.port <= 0 || config.port > 65535 { - return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + return Err(UriGeneratorError::InvalidConfiguration( + "Invalid port number".to_string(), + )); } if config.xray_user_id.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "xray_user_id".to_string(), + )); } - + // Shadowsocks uses xray_user_id as password, already validated above - + Ok(()) } } @@ -93,4 +101,3 @@ impl Default for ShadowsocksUriBuilder { Self::new() } } - diff --git a/src/services/uri_generator/builders/trojan.rs b/src/services/uri_generator/builders/trojan.rs index e18c34a..657be4b 100644 --- a/src/services/uri_generator/builders/trojan.rs +++ b/src/services/uri_generator/builders/trojan.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; use serde_json::Value; +use std::collections::HashMap; -use crate::services::uri_generator::{ClientConfigData, error::UriGeneratorError}; -use super::{UriBuilder, utils}; +use super::{utils, UriBuilder}; +use crate::services::uri_generator::{error::UriGeneratorError, ClientConfigData}; pub struct TrojanUriBuilder; @@ -15,32 +15,32 @@ impl TrojanUriBuilder { impl UriBuilder for TrojanUriBuilder { fn build_uri(&self, config: &ClientConfigData) -> Result { self.validate_config(config)?; - + // Trojan uses xray_user_id as password let password = &config.xray_user_id; - + // Apply variable substitution to stream settings let stream_settings = if !config.variable_values.is_null() { apply_variables(&config.stream_settings, &config.variable_values)? } else { config.stream_settings.clone() }; - + let mut params = HashMap::new(); - + // Determine security layer (Trojan typically uses TLS) let has_certificate = config.certificate_domain.is_some(); let security = utils::extract_security_type(&stream_settings, has_certificate); - + // Trojan usually requires TLS, but allow other security types if security != "none" { params.insert("security".to_string(), security.clone()); } - + // Transport type - always specify explicitly let transport_type = utils::extract_transport_type(&stream_settings); params.insert("type".to_string(), transport_type.clone()); - + // Transport-specific parameters match transport_type.as_str() { "ws" => { @@ -50,48 +50,53 @@ impl UriBuilder for TrojanUriBuilder { if let Some(host) = utils::extract_ws_host(&stream_settings) { params.insert("host".to_string(), host); } - }, + } "grpc" => { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { params.insert("serviceName".to_string(), service_name); } // gRPC mode for Trojan params.insert("mode".to_string(), "gun".to_string()); - }, + } "tcp" => { // Check for HTTP header type if let Some(header_type) = stream_settings .get("tcpSettings") .and_then(|tcp| tcp.get("header")) .and_then(|header| header.get("type")) - .and_then(|t| t.as_str()) { + .and_then(|t| t.as_str()) + { if header_type != "none" { params.insert("headerType".to_string(), header_type.to_string()); } } - }, + } _ => {} // Other transport types } - + // TLS/Security specific parameters if security == "tls" || security == "reality" { - if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + if let Some(sni) = + utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) + { params.insert("sni".to_string(), sni); } - + // TLS fingerprint if let Some(fp) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("fingerprint")) - .and_then(|fp| fp.as_str()) { + .and_then(|fp| fp.as_str()) + { params.insert("fp".to_string(), fp.to_string()); } - + // ALPN if let Some(alpn) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("alpn")) - .and_then(|alpn| alpn.as_array()) { + .and_then(|alpn| alpn.as_array()) + { let alpn_str = alpn .iter() .filter_map(|v| v.as_str()) @@ -101,46 +106,47 @@ impl UriBuilder for TrojanUriBuilder { params.insert("alpn".to_string(), alpn_str); } } - + // Allow insecure connections (optional) if let Some(allow_insecure) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("allowInsecure")) - .and_then(|ai| ai.as_bool()) { + .and_then(|ai| ai.as_bool()) + { if allow_insecure { params.insert("allowInsecure".to_string(), "1".to_string()); } } - + // REALITY specific parameters if security == "reality" { if let Some(pbk) = stream_settings .get("realitySettings") .and_then(|reality| reality.get("publicKey")) - .and_then(|pbk| pbk.as_str()) { + .and_then(|pbk| pbk.as_str()) + { params.insert("pbk".to_string(), pbk.to_string()); } - + if let Some(sid) = stream_settings .get("realitySettings") .and_then(|reality| reality.get("shortId")) - .and_then(|sid| sid.as_str()) { + .and_then(|sid| sid.as_str()) + { params.insert("sid".to_string(), sid.to_string()); } } } - + // Flow control for XTLS (if supported) - if let Some(flow) = stream_settings - .get("flow") - .and_then(|f| f.as_str()) { + if let Some(flow) = stream_settings.get("flow").and_then(|f| f.as_str()) { params.insert("flow".to_string(), flow.to_string()); } - + // Build the URI let query_string = utils::build_query_string(¶ms); let alias = utils::generate_alias(&config.server_name, &config.template_name); - + let uri = if query_string.is_empty() { format!( "trojan://{}@{}:{}#{}", @@ -159,24 +165,30 @@ impl UriBuilder for TrojanUriBuilder { utils::url_encode(&alias) ) }; - + Ok(uri) } - + fn validate_config(&self, config: &ClientConfigData) -> Result<(), UriGeneratorError> { // Basic validation if config.hostname.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("hostname".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "hostname".to_string(), + )); } if config.port <= 0 || config.port > 65535 { - return Err(UriGeneratorError::InvalidConfiguration("Invalid port number".to_string())); + return Err(UriGeneratorError::InvalidConfiguration( + "Invalid port number".to_string(), + )); } if config.xray_user_id.is_empty() { - return Err(UriGeneratorError::MissingRequiredField("xray_user_id".to_string())); + return Err(UriGeneratorError::MissingRequiredField( + "xray_user_id".to_string(), + )); } - + // Trojan uses xray_user_id as password, already validated above - + Ok(()) } } @@ -191,7 +203,7 @@ impl Default for TrojanUriBuilder { fn apply_variables(template: &Value, variables: &Value) -> Result { let template_str = template.to_string(); let mut result = template_str; - + if let Value::Object(var_map) = variables { for (key, value) in var_map { let placeholder = format!("${{{}}}", key); @@ -204,7 +216,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result Result { self.validate_config(config)?; - + // Apply variable substitution to stream settings let stream_settings = if !config.variable_values.is_null() { // Simple variable substitution for stream settings @@ -23,23 +23,23 @@ impl UriBuilder for VlessUriBuilder { } else { config.stream_settings.clone() }; - + let mut params = HashMap::new(); - + // VLESS always uses no encryption params.insert("encryption".to_string(), "none".to_string()); - + // Determine security layer let has_certificate = config.certificate_domain.is_some(); let security = utils::extract_security_type(&stream_settings, has_certificate); if security != "none" { params.insert("security".to_string(), security.clone()); } - + // Transport type - always specify explicitly let transport_type = utils::extract_transport_type(&stream_settings); params.insert("type".to_string(), transport_type.clone()); - + // Transport-specific parameters match transport_type.as_str() { "ws" => { @@ -49,72 +49,76 @@ impl UriBuilder for VlessUriBuilder { if let Some(host) = utils::extract_ws_host(&stream_settings) { params.insert("host".to_string(), host); } - }, + } "grpc" => { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { params.insert("serviceName".to_string(), service_name); } // Default gRPC mode params.insert("mode".to_string(), "gun".to_string()); - }, + } "tcp" => { // Check for HTTP header type if let Some(header_type) = stream_settings .get("tcpSettings") .and_then(|tcp| tcp.get("header")) .and_then(|header| header.get("type")) - .and_then(|t| t.as_str()) { + .and_then(|t| t.as_str()) + { if header_type != "none" { params.insert("headerType".to_string(), header_type.to_string()); } } - }, + } _ => {} // Other transport types can be added as needed } - + // TLS/Security specific parameters if security == "tls" || security == "reality" { - if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + if let Some(sni) = + utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) + { params.insert("sni".to_string(), sni); } - + // TLS fingerprint if let Some(fp) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("fingerprint")) - .and_then(|fp| fp.as_str()) { + .and_then(|fp| fp.as_str()) + { params.insert("fp".to_string(), fp.to_string()); } - + // REALITY specific parameters if security == "reality" { if let Some(pbk) = stream_settings .get("realitySettings") .and_then(|reality| reality.get("publicKey")) - .and_then(|pbk| pbk.as_str()) { + .and_then(|pbk| pbk.as_str()) + { params.insert("pbk".to_string(), pbk.to_string()); } - + if let Some(sid) = stream_settings .get("realitySettings") .and_then(|reality| reality.get("shortId")) - .and_then(|sid| sid.as_str()) { + .and_then(|sid| sid.as_str()) + { params.insert("sid".to_string(), sid.to_string()); } } } - + // Flow control for XTLS - if let Some(flow) = stream_settings - .get("flow") - .and_then(|f| f.as_str()) { + if let Some(flow) = stream_settings.get("flow").and_then(|f| f.as_str()) { params.insert("flow".to_string(), flow.to_string()); } - + // Build the URI let query_string = utils::build_query_string(¶ms); let alias = utils::generate_alias(&config.server_name, &config.template_name); - + let uri = if query_string.is_empty() { format!( "vless://{}@{}:{}#{}", @@ -133,7 +137,7 @@ impl UriBuilder for VlessUriBuilder { utils::url_encode(&alias) ) }; - + Ok(uri) } } @@ -148,7 +152,7 @@ impl Default for VlessUriBuilder { fn apply_variables(template: &Value, variables: &Value) -> Result { let template_str = template.to_string(); let mut result = template_str; - + if let Value::Object(var_map) = variables { for (key, value) in var_map { let placeholder = format!("${{{}}}", key); @@ -161,7 +165,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result Self { Self } - + /// Build VMess URI in Base64 JSON format (following Marzban approach) - fn build_base64_json_uri(&self, config: &ClientConfigData) -> Result { + fn build_base64_json_uri( + &self, + config: &ClientConfigData, + ) -> Result { // Apply variable substitution to stream settings let stream_settings = if !config.variable_values.is_null() { apply_variables(&config.stream_settings, &config.variable_values)? } else { config.stream_settings.clone() }; - + let transport_type = utils::extract_transport_type(&stream_settings); let has_certificate = config.certificate_domain.is_some(); let security = utils::extract_security_type(&stream_settings, has_certificate); - + // Build VMess JSON configuration following Marzban structure let mut vmess_config = json!({ "add": config.hostname, @@ -40,7 +43,7 @@ impl VmessUriBuilder { "type": "none", "v": "2" }); - + // Transport-specific settings match transport_type.as_str() { "ws" => { @@ -50,23 +53,24 @@ impl VmessUriBuilder { if let Some(host) = utils::extract_ws_host(&stream_settings) { vmess_config["host"] = Value::String(host); } - }, + } "grpc" => { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { vmess_config["path"] = Value::String(service_name); } // For gRPC in VMess, use "gun" type vmess_config["type"] = Value::String("gun".to_string()); - }, + } "tcp" => { // Check for HTTP header type if let Some(header_type) = stream_settings .get("tcpSettings") .and_then(|tcp| tcp.get("header")) .and_then(|header| header.get("type")) - .and_then(|t| t.as_str()) { + .and_then(|t| t.as_str()) + { vmess_config["type"] = Value::String(header_type.to_string()); - + // If HTTP headers, get host and path if header_type == "http" { if let Some(host) = stream_settings @@ -77,10 +81,11 @@ impl VmessUriBuilder { .and_then(|headers| headers.get("Host")) .and_then(|host| host.as_array()) .and_then(|arr| arr.first()) - .and_then(|h| h.as_str()) { + .and_then(|h| h.as_str()) + { vmess_config["host"] = Value::String(host.to_string()); } - + if let Some(path) = stream_settings .get("tcpSettings") .and_then(|tcp| tcp.get("header")) @@ -88,34 +93,39 @@ impl VmessUriBuilder { .and_then(|request| request.get("path")) .and_then(|path| path.as_array()) .and_then(|arr| arr.first()) - .and_then(|p| p.as_str()) { + .and_then(|p| p.as_str()) + { vmess_config["path"] = Value::String(path.to_string()); } } } - }, + } _ => {} // Other transport types } - + // TLS settings if security != "none" { - if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + if let Some(sni) = + utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) + { vmess_config["sni"] = Value::String(sni); } - + // TLS fingerprint if let Some(fp) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("fingerprint")) - .and_then(|fp| fp.as_str()) { + .and_then(|fp| fp.as_str()) + { vmess_config["fp"] = Value::String(fp.to_string()); } - + // ALPN if let Some(alpn) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("alpn")) - .and_then(|alpn| alpn.as_array()) { + .and_then(|alpn| alpn.as_array()) + { let alpn_str = alpn .iter() .filter_map(|v| v.as_str()) @@ -126,41 +136,44 @@ impl VmessUriBuilder { } } } - + // Convert to JSON string and encode in Base64 let json_string = vmess_config.to_string(); let encoded = general_purpose::STANDARD.encode(json_string.as_bytes()); - + Ok(format!("vmess://{}", encoded)) } - + /// Build VMess URI in query parameter format (alternative) - fn build_query_param_uri(&self, config: &ClientConfigData) -> Result { + fn build_query_param_uri( + &self, + config: &ClientConfigData, + ) -> Result { // Apply variable substitution to stream settings let stream_settings = if !config.variable_values.is_null() { apply_variables(&config.stream_settings, &config.variable_values)? } else { config.stream_settings.clone() }; - + let mut params = HashMap::new(); - + // VMess uses auto encryption params.insert("encryption".to_string(), "auto".to_string()); - + // Determine security layer let has_certificate = config.certificate_domain.is_some(); let security = utils::extract_security_type(&stream_settings, has_certificate); if security != "none" { params.insert("security".to_string(), security.clone()); } - + // Transport type let transport_type = utils::extract_transport_type(&stream_settings); if transport_type != "tcp" { params.insert("type".to_string(), transport_type.clone()); } - + // Transport-specific parameters match transport_type.as_str() { "ws" => { @@ -170,34 +183,37 @@ impl VmessUriBuilder { if let Some(host) = utils::extract_ws_host(&stream_settings) { params.insert("host".to_string(), host); } - }, + } "grpc" => { if let Some(service_name) = utils::extract_grpc_service_name(&stream_settings) { params.insert("serviceName".to_string(), service_name); } params.insert("mode".to_string(), "gun".to_string()); - }, + } _ => {} } - + // TLS specific parameters if security != "none" { - if let Some(sni) = utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) { + if let Some(sni) = + utils::extract_tls_sni(&stream_settings, config.certificate_domain.as_deref()) + { params.insert("sni".to_string(), sni); } - + if let Some(fp) = stream_settings .get("tlsSettings") .and_then(|tls| tls.get("fingerprint")) - .and_then(|fp| fp.as_str()) { + .and_then(|fp| fp.as_str()) + { params.insert("fp".to_string(), fp.to_string()); } } - + // Build the URI let query_string = utils::build_query_string(¶ms); let alias = utils::generate_alias(&config.server_name, &config.template_name); - + let uri = if query_string.is_empty() { format!( "vmess://{}@{}:{}#{}", @@ -216,7 +232,7 @@ impl VmessUriBuilder { utils::url_encode(&alias) ) }; - + Ok(uri) } } @@ -224,7 +240,7 @@ impl VmessUriBuilder { impl UriBuilder for VmessUriBuilder { fn build_uri(&self, config: &ClientConfigData) -> Result { self.validate_config(config)?; - + // Prefer Base64 JSON format as it's more widely supported self.build_base64_json_uri(config) } @@ -240,7 +256,7 @@ impl Default for VmessUriBuilder { fn apply_variables(template: &Value, variables: &Value) -> Result { let template_str = template.to_string(); let mut result = template_str; - + if let Value::Object(var_map) = variables { for (key, value) in var_map { let placeholder = format!("${{{}}}", key); @@ -253,7 +269,7 @@ fn apply_variables(template: &Value, variables: &Value) -> Result for UriGeneratorError { // fn from(err: urlencoding::EncodingError) -> Self { // UriGeneratorError::UriEncoding(err.to_string()) // } -// } \ No newline at end of file +// } diff --git a/src/services/uri_generator/mod.rs b/src/services/uri_generator/mod.rs index 7b16775..067bd0b 100644 --- a/src/services/uri_generator/mod.rs +++ b/src/services/uri_generator/mod.rs @@ -6,7 +6,9 @@ use uuid::Uuid; pub mod builders; pub mod error; -use builders::{UriBuilder, VlessUriBuilder, VmessUriBuilder, TrojanUriBuilder, ShadowsocksUriBuilder}; +use builders::{ + ShadowsocksUriBuilder, TrojanUriBuilder, UriBuilder, VlessUriBuilder, VmessUriBuilder, +}; use error::UriGeneratorError; /// Complete client configuration data aggregated from database @@ -17,23 +19,23 @@ pub struct ClientConfigData { pub xray_user_id: String, pub password: Option, pub level: i32, - + // Server connection pub hostname: String, pub port: i32, - + // Protocol & transport pub protocol: String, pub stream_settings: Value, pub base_settings: Value, - + // Security pub certificate_domain: Option, pub requires_tls: bool, - + // Variable substitution pub variable_values: Value, - + // Metadata pub server_name: String, pub inbound_tag: String, @@ -60,36 +62,40 @@ impl UriGeneratorService { pub fn new() -> Self { Self } - + /// Generate URI for specific protocol and configuration pub fn generate_uri(&self, config: &ClientConfigData) -> Result { let protocol = config.protocol.as_str(); - + match protocol { "vless" => { let builder = VlessUriBuilder::new(); builder.build_uri(config) - }, + } "vmess" => { let builder = VmessUriBuilder::new(); builder.build_uri(config) - }, + } "trojan" => { let builder = TrojanUriBuilder::new(); builder.build_uri(config) - }, + } "shadowsocks" => { let builder = ShadowsocksUriBuilder::new(); builder.build_uri(config) - }, + } _ => Err(UriGeneratorError::UnsupportedProtocol(protocol.to_string())), } } - + /// Generate complete client configuration - pub fn generate_client_config(&self, user_id: Uuid, config: &ClientConfigData) -> Result { + pub fn generate_client_config( + &self, + user_id: Uuid, + config: &ClientConfigData, + ) -> Result { let uri = self.generate_uri(config)?; - + Ok(ClientConfig { user_id, server_name: config.server_name.clone(), @@ -100,12 +106,16 @@ impl UriGeneratorService { qr_code: None, // TODO: Implement QR code generation if needed }) } - + /// Apply variable substitution to JSON values - pub fn apply_variable_substitution(&self, template: &Value, variables: &Value) -> Result { + pub fn apply_variable_substitution( + &self, + template: &Value, + variables: &Value, + ) -> Result { let template_str = template.to_string(); let mut result = template_str; - + if let Value::Object(var_map) = variables { for (key, value) in var_map { let placeholder = format!("${{{}}}", key); @@ -118,7 +128,7 @@ impl UriGeneratorService { result = result.replace(&placeholder, &replacement); } } - + serde_json::from_str(&result) .map_err(|e| UriGeneratorError::VariableSubstitution(e.to_string())) } @@ -128,4 +138,4 @@ impl Default for UriGeneratorService { fn default() -> Self { Self::new() } -} \ No newline at end of file +} diff --git a/src/services/xray/client.rs b/src/services/xray/client.rs index 091ea24..71ab219 100644 --- a/src/services/xray/client.rs +++ b/src/services/xray/client.rs @@ -1,12 +1,12 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use serde_json::Value; -use xray_core::Client; use std::sync::Arc; use tokio::time::{timeout, Duration}; +use xray_core::Client; // Import submodules from the same directory -use super::stats::StatsClient; use super::inbounds::InboundClient; +use super::stats::StatsClient; use super::users::UserClient; /// Xray gRPC client wrapper @@ -22,20 +22,17 @@ impl XrayClient { pub async fn connect(endpoint: &str) -> Result { // Apply a 5-second timeout to the connection attempt let connect_future = Client::from_url(endpoint); - + match timeout(Duration::from_secs(5), connect_future).await { - Ok(Ok(client)) => { - Ok(Self { - endpoint: endpoint.to_string(), - client: Arc::new(client), - }) - }, - Ok(Err(e)) => { - Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)) - }, - Err(_) => { - Err(anyhow!("Connection to Xray at {} timed out after 5 seconds", endpoint)) - } + Ok(Ok(client)) => Ok(Self { + endpoint: endpoint.to_string(), + client: Arc::new(client), + }), + Ok(Err(e)) => Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e)), + Err(_) => Err(anyhow!( + "Connection to Xray at {} timed out after 5 seconds", + endpoint + )), } } @@ -52,7 +49,10 @@ impl XrayClient { } /// Restart Xray with new configuration - pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> { + pub async fn restart_with_config( + &self, + config: &crate::services::xray::XrayConfig, + ) -> Result<()> { let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); inbound_client.restart_with_config(config).await } @@ -64,15 +64,30 @@ impl XrayClient { } /// Add inbound configuration with TLS certificate - pub async fn add_inbound_with_certificate(&self, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { + pub async fn add_inbound_with_certificate( + &self, + inbound: &Value, + cert_pem: Option<&str>, + key_pem: Option<&str>, + ) -> Result<()> { let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); - inbound_client.add_inbound_with_certificate(inbound, None, cert_pem, key_pem).await + inbound_client + .add_inbound_with_certificate(inbound, None, cert_pem, key_pem) + .await } /// Add inbound configuration with users and TLS certificate - pub async fn add_inbound_with_users_and_certificate(&self, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { + pub async fn add_inbound_with_users_and_certificate( + &self, + inbound: &Value, + users: &[Value], + cert_pem: Option<&str>, + key_pem: Option<&str>, + ) -> Result<()> { let inbound_client = InboundClient::new(self.endpoint.clone(), &*self.client); - inbound_client.add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem).await + inbound_client + .add_inbound_with_certificate(inbound, Some(users), cert_pem, key_pem) + .await } /// Remove inbound by tag @@ -97,4 +112,4 @@ impl XrayClient { pub fn endpoint(&self) -> &str { &self.endpoint } -} \ No newline at end of file +} diff --git a/src/services/xray/config.rs b/src/services/xray/config.rs index d4d292d..091a43e 100644 --- a/src/services/xray/config.rs +++ b/src/services/xray/config.rs @@ -171,25 +171,26 @@ impl XrayConfig { dns: None, routing: Some(RoutingConfig { domain_strategy: Some("IPIfNonMatch".to_string()), - rules: vec![ - RoutingRule { - rule_type: "field".to_string(), - domain: None, - ip: Some(vec!["geoip:private".to_string()]), - port: None, - outbound_tag: "direct".to_string(), - } - ], + rules: vec![RoutingRule { + rule_type: "field".to_string(), + domain: None, + ip: Some(vec!["geoip:private".to_string()]), + port: None, + outbound_tag: "direct".to_string(), + }], }), policy: Some(PolicyConfig { levels: { let mut levels = HashMap::new(); - levels.insert("0".to_string(), PolicyLevel { - handshake_timeout: Some(4), - conn_idle: Some(300), - uplink_only: Some(2), - downlink_only: Some(5), - }); + levels.insert( + "0".to_string(), + PolicyLevel { + handshake_timeout: Some(4), + conn_idle: Some(300), + uplink_only: Some(2), + downlink_only: Some(5), + }, + ); levels }, system: Some(SystemPolicy { @@ -282,4 +283,4 @@ impl Default for XrayConfig { fn default() -> Self { Self::new() } -} \ No newline at end of file +} diff --git a/src/services/xray/inbounds.rs b/src/services/xray/inbounds.rs index 9be52a1..65e5966 100644 --- a/src/services/xray/inbounds.rs +++ b/src/services/xray/inbounds.rs @@ -1,42 +1,44 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; +use prost::Message; use serde_json::Value; use uuid; use xray_core::{ - tonic::Request, app::proxyman::command::{AddInboundRequest, RemoveInboundRequest}, - core::InboundHandlerConfig, - common::serial::TypedMessage, - common::protocol::User, app::proxyman::ReceiverConfig, - common::net::{PortList, PortRange, IpOrDomain, ip_or_domain::Address, Network}, - transport::internet::StreamConfig, - transport::internet::tls::{Config as TlsConfig, Certificate as TlsCertificate}, + common::net::{ip_or_domain::Address, IpOrDomain, Network, PortList, PortRange}, + common::protocol::User, + common::serial::TypedMessage, + core::InboundHandlerConfig, + prost_types, + proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig, + proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType}, + proxy::trojan::Account as TrojanAccount, + proxy::trojan::ServerConfig as TrojanServerConfig, proxy::vless::inbound::Config as VlessInboundConfig, proxy::vless::Account as VlessAccount, proxy::vmess::inbound::Config as VmessInboundConfig, proxy::vmess::Account as VmessAccount, - proxy::trojan::ServerConfig as TrojanServerConfig, - proxy::trojan::Account as TrojanAccount, - proxy::shadowsocks::ServerConfig as ShadowsocksServerConfig, - proxy::shadowsocks::{Account as ShadowsocksAccount, CipherType}, + tonic::Request, + transport::internet::tls::{Certificate as TlsCertificate, Config as TlsConfig}, + transport::internet::StreamConfig, Client, - prost_types, }; -use prost::Message; /// Convert PEM format to DER (x509) format fn pem_to_der(pem_data: &str) -> Result> { // Remove PEM headers and whitespace, then decode base64 - let base64_data: String = pem_data.lines() + let base64_data: String = pem_data + .lines() .filter(|line| !line.starts_with("-----") && !line.trim().is_empty()) .map(|line| line.trim()) .collect::>() .join(""); - + tracing::debug!("PEM to DER conversion: {} bytes", base64_data.len()); - - use base64::{Engine as _, engine::general_purpose}; - general_purpose::STANDARD.decode(&base64_data) + + use base64::{engine::general_purpose, Engine as _}; + general_purpose::STANDARD + .decode(&base64_data) .map_err(|e| anyhow!("Failed to decode base64 PEM data: {}", e)) } @@ -52,22 +54,32 @@ impl<'a> InboundClient<'a> { /// Add inbound configuration pub async fn add_inbound(&self, inbound: &Value) -> Result<()> { - self.add_inbound_with_certificate(inbound, None, None, None).await + self.add_inbound_with_certificate(inbound, None, None, None) + .await } /// Add inbound configuration with TLS certificate and users - pub async fn add_inbound_with_certificate(&self, inbound: &Value, users: Option<&[Value]>, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { + pub async fn add_inbound_with_certificate( + &self, + inbound: &Value, + users: Option<&[Value]>, + cert_pem: Option<&str>, + key_pem: Option<&str>, + ) -> Result<()> { let tag = inbound["tag"].as_str().unwrap_or("").to_string(); let port = inbound["port"].as_u64().unwrap_or(8080) as u32; let protocol = inbound["protocol"].as_str().unwrap_or("vless"); let _user_count = users.map_or(0, |u| u.len()); - + tracing::info!( - "Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}", - tag, protocol, port, cert_pem.is_some(), key_pem.is_some() + "Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}", + tag, + protocol, + port, + cert_pem.is_some(), + key_pem.is_some() ); - - + // Create receiver configuration (port binding) - use simple port number let port_list = PortList { range: vec![PortRange { @@ -80,39 +92,42 @@ impl<'a> InboundClient<'a> { let stream_settings = if cert_pem.is_some() && key_pem.is_some() { let cert_pem = cert_pem.unwrap(); let key_pem = key_pem.unwrap(); - + // Create TLS certificate exactly like working example - PEM content as bytes let tls_cert = TlsCertificate { certificate: cert_pem.as_bytes().to_vec(), // PEM content as bytes like working example key: key_pem.as_bytes().to_vec(), // PEM content as bytes like working example usage: 0, - ocsp_stapling: 3600, // From working example + ocsp_stapling: 3600, // From working example one_time_loading: true, // From working example build_chain: false, certificate_path: "".to_string(), // Empty paths since we use content - key_path: "".to_string(), // Empty paths since we use content + key_path: "".to_string(), // Empty paths since we use content }; - + // Create TLS config with proper fields like working example let mut tls_config = TlsConfig::default(); tls_config.certificate = vec![tls_cert]; tls_config.next_protocol = vec!["h2".to_string(), "http/1.1".to_string()]; // From working example tls_config.server_name = "localhost".to_string(); // From working example tls_config.min_version = "1.2".to_string(); // From Marzban examples - + // Create TypedMessage for TLS config let tls_message = TypedMessage { r#type: "xray.transport.internet.tls.Config".to_string(), value: tls_config.encode_to_vec(), }; - - tracing::debug!("TLS config: server_name={}, protocols={:?}", - tls_config.server_name, tls_config.next_protocol); - + + tracing::debug!( + "TLS config: server_name={}, protocols={:?}", + tls_config.server_name, + tls_config.next_protocol + ); + // Create StreamConfig like working example Some(StreamConfig { address: None, // No address in streamSettings according to working example - port: 0, // No port in working example streamSettings + port: 0, // No port in working example streamSettings protocol_name: "tcp".to_string(), transport_settings: vec![], security_type: "xray.transport.internet.tls.Config".to_string(), // Full type like working example @@ -125,8 +140,8 @@ impl<'a> InboundClient<'a> { let receiver_config = ReceiverConfig { port_list: Some(port_list), - listen: Some(IpOrDomain { - address: Some(Address::Ip(vec![0, 0, 0, 0])) // "0.0.0.0" as IPv4 bytes + listen: Some(IpOrDomain { + address: Some(Address::Ip(vec![0, 0, 0, 0])), // "0.0.0.0" as IPv4 bytes }), allocation_strategy: None, stream_settings: stream_settings, @@ -138,7 +153,7 @@ impl<'a> InboundClient<'a> { r#type: "xray.app.proxyman.ReceiverConfig".to_string(), value: receiver_config.encode_to_vec(), }; - + // Create proxy configuration based on protocol with users let proxy_message = match protocol { "vless" => { @@ -148,7 +163,7 @@ impl<'a> InboundClient<'a> { let user_id = user["id"].as_str().unwrap_or("").to_string(); let email = user["email"].as_str().unwrap_or("").to_string(); let level = user["level"].as_u64().unwrap_or(0) as u32; - + if !user_id.is_empty() && !email.is_empty() { let account = VlessAccount { id: user_id, @@ -166,7 +181,7 @@ impl<'a> InboundClient<'a> { } } } - + let vless_config = VlessInboundConfig { clients, decryption: "none".to_string(), @@ -176,7 +191,7 @@ impl<'a> InboundClient<'a> { r#type: "xray.proxy.vless.inbound.Config".to_string(), value: vless_config.encode_to_vec(), } - }, + } "vmess" => { let mut vmess_users = vec![]; if let Some(users) = users { @@ -184,18 +199,18 @@ impl<'a> InboundClient<'a> { let user_id = user["id"].as_str().unwrap_or("").to_string(); let email = user["email"].as_str().unwrap_or("").to_string(); let level = user["level"].as_u64().unwrap_or(0) as u32; - + // Validate required fields if user_id.is_empty() || email.is_empty() { tracing::warn!("Skipping VMess user: missing id or email"); continue; } - + // Validate UUID format if uuid::Uuid::parse_str(&user_id).is_err() { tracing::warn!("VMess user '{}' has invalid UUID format", user_id); } - + if !user_id.is_empty() && !email.is_empty() { let account = VmessAccount { id: user_id.clone(), @@ -203,7 +218,7 @@ impl<'a> InboundClient<'a> { tests_enabled: "".to_string(), // Keep empty as in examples }; let account_bytes = account.encode_to_vec(); - + vmess_users.push(User { email: email.clone(), level, @@ -215,7 +230,7 @@ impl<'a> InboundClient<'a> { } } } - + let vmess_config = VmessInboundConfig { user: vmess_users, default: None, @@ -225,19 +240,21 @@ impl<'a> InboundClient<'a> { r#type: "xray.proxy.vmess.inbound.Config".to_string(), value: vmess_config.encode_to_vec(), } - }, + } "trojan" => { let mut trojan_users = vec![]; if let Some(users) = users { for user in users { - let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string(); + let password = user["password"] + .as_str() + .or_else(|| user["id"].as_str()) + .unwrap_or("") + .to_string(); let email = user["email"].as_str().unwrap_or("").to_string(); let level = user["level"].as_u64().unwrap_or(0) as u32; - + if !password.is_empty() && !email.is_empty() { - let account = TrojanAccount { - password, - }; + let account = TrojanAccount { password }; trojan_users.push(User { email, level, @@ -249,7 +266,7 @@ impl<'a> InboundClient<'a> { } } } - + let trojan_config = TrojanServerConfig { users: trojan_users, fallbacks: vec![], @@ -258,21 +275,24 @@ impl<'a> InboundClient<'a> { r#type: "xray.proxy.trojan.ServerConfig".to_string(), value: trojan_config.encode_to_vec(), } - }, + } "shadowsocks" => { let mut ss_users = vec![]; if let Some(users) = users { for user in users { - let password = user["password"].as_str().or_else(|| user["id"].as_str()).unwrap_or("").to_string(); + let password = user["password"] + .as_str() + .or_else(|| user["id"].as_str()) + .unwrap_or("") + .to_string(); let email = user["email"].as_str().unwrap_or("").to_string(); let level = user["level"].as_u64().unwrap_or(0) as u32; - - + if !password.is_empty() && !email.is_empty() { let account = ShadowsocksAccount { password, cipher_type: CipherType::Aes256Gcm as i32, // Use AES-256-GCM cipher - iv_check: false, // Default IV check + iv_check: false, // Default IV check }; ss_users.push(User { email: email.clone(), @@ -285,7 +305,7 @@ impl<'a> InboundClient<'a> { } } } - + let shadowsocks_config = ShadowsocksServerConfig { users: ss_users, network: vec![Network::Tcp as i32, Network::Udp as i32], // Support TCP and UDP @@ -294,7 +314,7 @@ impl<'a> InboundClient<'a> { r#type: "xray.proxy.shadowsocks.ServerConfig".to_string(), value: shadowsocks_config.encode_to_vec(), } - }, + } _ => { return Err(anyhow!("Unsupported protocol: {}", protocol)); } @@ -328,12 +348,12 @@ impl<'a> InboundClient<'a> { let request = Request::new(RemoveInboundRequest { tag: tag.to_string(), }); - + match handler_client.remove_inbound(request).await { Ok(_) => { tracing::info!("Removed inbound '{}' from {}", tag, self.endpoint); Ok(()) - }, + } Err(e) => { tracing::error!("Failed to remove inbound '{}': {}", tag, e); Err(anyhow!("Failed to remove inbound: {}", e)) @@ -342,11 +362,17 @@ impl<'a> InboundClient<'a> { } /// Restart Xray with new configuration - pub async fn restart_with_config(&self, config: &crate::services::xray::XrayConfig) -> Result<()> { - tracing::debug!("Restarting Xray server at {} with new config", self.endpoint); - + pub async fn restart_with_config( + &self, + config: &crate::services::xray::XrayConfig, + ) -> Result<()> { + tracing::debug!( + "Restarting Xray server at {} with new config", + self.endpoint + ); + // TODO: Implement restart with config using xray-core // For now just return success Ok(()) } -} \ No newline at end of file +} diff --git a/src/services/xray/mod.rs b/src/services/xray/mod.rs index 8dc5b17..dbb03e7 100644 --- a/src/services/xray/mod.rs +++ b/src/services/xray/mod.rs @@ -1,16 +1,16 @@ use anyhow::Result; use serde_json::Value; -use uuid::Uuid; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use tokio::time::{Duration, Instant, timeout}; +use tokio::time::{timeout, Duration, Instant}; use tracing::{error, warn}; +use uuid::Uuid; pub mod client; pub mod config; -pub mod stats; pub mod inbounds; +pub mod stats; pub mod users; pub use client::XrayClient; @@ -30,7 +30,7 @@ impl CachedConnection { created_at: Instant::now(), } } - + fn is_expired(&self, ttl: Duration) -> bool { self.created_at.elapsed() > ttl } @@ -51,7 +51,7 @@ impl XrayService { connection_ttl: Duration::from_secs(300), // 5 minutes TTL } } - + /// Get or create cached client for endpoint async fn get_or_create_client(&self, endpoint: &str) -> Result { // Check cache first @@ -63,21 +63,20 @@ impl XrayService { } } } - + // Create new connection let client = XrayClient::connect(endpoint).await?; let cached_connection = CachedConnection::new(client.clone()); - + // Update cache { let mut cache = self.connection_cache.write().await; cache.insert(endpoint.to_string(), cached_connection); } - + Ok(client) } - /// Test connection to Xray server with timeout pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result { // Apply a 3-second timeout to the entire test operation @@ -85,12 +84,12 @@ impl XrayService { Ok(Ok(_client)) => { // Connection successful Ok(true) - }, + } Ok(Err(e)) => { // Connection failed with error warn!("Failed to connect to Xray at {}: {}", endpoint, e); Ok(false) - }, + } Err(_) => { // Operation timed out warn!("Connection test to Xray at {} timed out", endpoint); @@ -100,7 +99,12 @@ impl XrayService { } /// Apply full configuration to Xray server - pub async fn apply_config(&self, _server_id: Uuid, endpoint: &str, config: &XrayConfig) -> Result<()> { + pub async fn apply_config( + &self, + _server_id: Uuid, + endpoint: &str, + config: &XrayConfig, + ) -> Result<()> { let client = self.get_or_create_client(endpoint).await?; client.restart_with_config(config).await } @@ -124,8 +128,9 @@ impl XrayService { "settings": base_settings, "streamSettings": stream_settings }); - - self.add_inbound(_server_id, endpoint, &inbound_config).await + + self.add_inbound(_server_id, endpoint, &inbound_config) + .await } /// Create inbound from template with TLS certificate @@ -149,26 +154,51 @@ impl XrayService { "settings": base_settings, "streamSettings": stream_settings }); - - self.add_inbound_with_certificate(_server_id, endpoint, &inbound_config, cert_pem, key_pem).await + + self.add_inbound_with_certificate(_server_id, endpoint, &inbound_config, cert_pem, key_pem) + .await } /// Add inbound to running Xray instance - pub async fn add_inbound(&self, _server_id: Uuid, endpoint: &str, inbound: &Value) -> Result<()> { + pub async fn add_inbound( + &self, + _server_id: Uuid, + endpoint: &str, + inbound: &Value, + ) -> Result<()> { let client = self.get_or_create_client(endpoint).await?; client.add_inbound(inbound).await } /// Add inbound with certificate to running Xray instance - pub async fn add_inbound_with_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { + pub async fn add_inbound_with_certificate( + &self, + _server_id: Uuid, + endpoint: &str, + inbound: &Value, + cert_pem: Option<&str>, + key_pem: Option<&str>, + ) -> Result<()> { let client = self.get_or_create_client(endpoint).await?; - client.add_inbound_with_certificate(inbound, cert_pem, key_pem).await + client + .add_inbound_with_certificate(inbound, cert_pem, key_pem) + .await } /// Add inbound with users and certificate to running Xray instance - pub async fn add_inbound_with_users_and_certificate(&self, _server_id: Uuid, endpoint: &str, inbound: &Value, users: &[Value], cert_pem: Option<&str>, key_pem: Option<&str>) -> Result<()> { + pub async fn add_inbound_with_users_and_certificate( + &self, + _server_id: Uuid, + endpoint: &str, + inbound: &Value, + users: &[Value], + cert_pem: Option<&str>, + key_pem: Option<&str>, + ) -> Result<()> { let client = self.get_or_create_client(endpoint).await?; - client.add_inbound_with_users_and_certificate(inbound, users, cert_pem, key_pem).await + client + .add_inbound_with_users_and_certificate(inbound, users, cert_pem, key_pem) + .await } /// Remove inbound from running Xray instance @@ -178,15 +208,20 @@ impl XrayService { } /// Add user to inbound by recreating the inbound with updated user list - pub async fn add_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, user: &Value) -> Result<()> { - + pub async fn add_user( + &self, + _server_id: Uuid, + endpoint: &str, + inbound_tag: &str, + user: &Value, + ) -> Result<()> { // TODO: Implement inbound recreation approach: // 1. Get current inbound configuration from database - // 2. Get existing users from database + // 2. Get existing users from database // 3. Remove old inbound from xray // 4. Create new inbound with all users (existing + new) // For now, return error to indicate this needs to be implemented - + Err(anyhow::anyhow!("User addition requires inbound recreation - not yet implemented. Use web interface to recreate inbound with users.")) } @@ -204,7 +239,6 @@ impl XrayService { cert_pem: Option<&str>, key_pem: Option<&str>, ) -> Result<()> { - // Build inbound configuration with users let mut inbound_config = serde_json::json!({ "tag": tag, @@ -213,37 +247,53 @@ impl XrayService { "settings": base_settings, "streamSettings": stream_settings }); - + // Add users to settings based on protocol if !users.is_empty() { let mut settings = inbound_config["settings"].clone(); match protocol { "vless" | "vmess" => { settings["clients"] = serde_json::Value::Array(users.to_vec()); - }, + } "trojan" => { settings["clients"] = serde_json::Value::Array(users.to_vec()); - }, + } "shadowsocks" => { // For shadowsocks, users are handled differently if let Some(user) = users.first() { settings["password"] = user["password"].clone(); } - }, + } _ => { - return Err(anyhow::anyhow!("Unsupported protocol for users: {}", protocol)); + return Err(anyhow::anyhow!( + "Unsupported protocol for users: {}", + protocol + )); } } inbound_config["settings"] = settings; } - - + // Use the new method with users support - self.add_inbound_with_users_and_certificate(_server_id, endpoint, &inbound_config, users, cert_pem, key_pem).await + self.add_inbound_with_users_and_certificate( + _server_id, + endpoint, + &inbound_config, + users, + cert_pem, + key_pem, + ) + .await } /// Remove user from inbound - pub async fn remove_user(&self, _server_id: Uuid, endpoint: &str, inbound_tag: &str, email: &str) -> Result<()> { + pub async fn remove_user( + &self, + _server_id: Uuid, + endpoint: &str, + inbound_tag: &str, + email: &str, + ) -> Result<()> { let client = self.get_or_create_client(endpoint).await?; client.remove_user(inbound_tag, email).await } @@ -255,11 +305,17 @@ impl XrayService { } /// Query specific statistics - pub async fn query_stats(&self, _server_id: Uuid, endpoint: &str, pattern: &str, reset: bool) -> Result { + pub async fn query_stats( + &self, + _server_id: Uuid, + endpoint: &str, + pattern: &str, + reset: bool, + ) -> Result { let client = self.get_or_create_client(endpoint).await?; client.query_stats(pattern, reset).await } - + /// Sync entire server with batch operations using single client pub async fn sync_server_inbounds_optimized( &self, @@ -269,21 +325,25 @@ impl XrayService { ) -> Result<()> { // Get single client for all operations let client = self.get_or_create_client(endpoint).await?; - + // Perform all operations with the same client for (tag, desired) in desired_inbounds { // Always try to remove inbound first (ignore errors if it doesn't exist) let _ = client.remove_inbound(tag).await; - + // Create inbound with users - let users_json: Vec = desired.users.iter().map(|user| { - serde_json::json!({ - "id": user.id, - "email": user.email, - "level": user.level + let users_json: Vec = desired + .users + .iter() + .map(|user| { + serde_json::json!({ + "id": user.id, + "email": user.email, + "level": user.level + }) }) - }).collect(); - + .collect(); + // Build inbound config let inbound_config = serde_json::json!({ "tag": desired.tag, @@ -292,20 +352,23 @@ impl XrayService { "settings": desired.settings, "streamSettings": desired.stream_settings }); - - match client.add_inbound_with_users_and_certificate( - &inbound_config, - &users_json, - desired.cert_pem.as_deref(), - desired.key_pem.as_deref(), - ).await { + + match client + .add_inbound_with_users_and_certificate( + &inbound_config, + &users_json, + desired.cert_pem.as_deref(), + desired.key_pem.as_deref(), + ) + .await + { Err(e) => { error!("Failed to create inbound {}: {}", tag, e); } _ => {} } } - + Ok(()) } } @@ -314,4 +377,4 @@ impl Default for XrayService { fn default() -> Self { Self::new() } -} \ No newline at end of file +} diff --git a/src/services/xray/stats.rs b/src/services/xray/stats.rs index 1be5880..4049156 100644 --- a/src/services/xray/stats.rs +++ b/src/services/xray/stats.rs @@ -1,8 +1,8 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use serde_json::Value; use xray_core::{ - tonic::Request, app::stats::command::{GetStatsRequest, QueryStatsRequest}, + tonic::Request, Client, }; @@ -19,7 +19,7 @@ impl<'a> StatsClient<'a> { /// Get server statistics pub async fn get_stats(&self) -> Result { tracing::info!("Getting stats from Xray server at {}", self.endpoint); - + let request = Request::new(GetStatsRequest { name: "".to_string(), reset: false, @@ -44,8 +44,13 @@ impl<'a> StatsClient<'a> { /// Query specific statistics with pattern pub async fn query_stats(&self, pattern: &str, reset: bool) -> Result { - tracing::info!("Querying stats with pattern '{}', reset: {} from {}", pattern, reset, self.endpoint); - + tracing::info!( + "Querying stats with pattern '{}', reset: {} from {}", + pattern, + reset, + self.endpoint + ); + let request = Request::new(QueryStatsRequest { pattern: pattern.to_string(), reset, @@ -67,4 +72,4 @@ impl<'a> StatsClient<'a> { } } } -} \ No newline at end of file +} diff --git a/src/services/xray/users.rs b/src/services/xray/users.rs index 7d243f8..2afb86e 100644 --- a/src/services/xray/users.rs +++ b/src/services/xray/users.rs @@ -1,16 +1,16 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; +use prost::Message; use serde_json::Value; use xray_core::{ - tonic::Request, - app::proxyman::command::{AlterInboundRequest, AddUserOperation, RemoveUserOperation}, - common::serial::TypedMessage, + app::proxyman::command::{AddUserOperation, AlterInboundRequest, RemoveUserOperation}, common::protocol::User, + common::serial::TypedMessage, + proxy::trojan::Account as TrojanAccount, proxy::vless::Account as VlessAccount, proxy::vmess::Account as VmessAccount, - proxy::trojan::Account as TrojanAccount, + tonic::Request, Client, }; -use prost::Message; pub struct UserClient<'a> { endpoint: String, @@ -28,11 +28,11 @@ impl<'a> UserClient<'a> { let user_id = user["id"].as_str().unwrap_or("").to_string(); let level = user["level"].as_u64().unwrap_or(0) as u32; let protocol = user["protocol"].as_str().unwrap_or("vless"); - + if email.is_empty() || user_id.is_empty() { return Err(anyhow!("User email and id are required")); } - + // Create user account based on protocol let account_message = match protocol { "vless" => { @@ -45,7 +45,7 @@ impl<'a> UserClient<'a> { r#type: "xray.proxy.vless.Account".to_string(), value: account.encode_to_vec(), } - }, + } "vmess" => { let account = VmessAccount { id: user_id, @@ -56,7 +56,7 @@ impl<'a> UserClient<'a> { r#type: "xray.proxy.vmess.Account".to_string(), value: account.encode_to_vec(), } - }, + } "trojan" => { let account = TrojanAccount { password: user_id, // For trojan, use password instead of UUID @@ -65,36 +65,35 @@ impl<'a> UserClient<'a> { r#type: "xray.proxy.trojan.Account".to_string(), value: account.encode_to_vec(), } - }, + } _ => { return Err(anyhow!("Unsupported protocol for user: {}", protocol)); } }; - + // Create user protobuf message let user_proto = User { level: level, email: email.clone(), account: Some(account_message), }; - + // Build the AddUserOperation let add_user_op = AddUserOperation { user: Some(user_proto), }; - + let typed_message = TypedMessage { r#type: "xray.app.proxyman.command.AddUserOperation".to_string(), value: add_user_op.encode_to_vec(), }; - + // Build the AlterInboundRequest let request = Request::new(AlterInboundRequest { tag: inbound_tag.to_string(), operation: Some(typed_message), }); - - + let mut handler_client = self.client.handler(); match handler_client.alter_inbound(request).await { Ok(response) => { @@ -102,40 +101,57 @@ impl<'a> UserClient<'a> { Ok(()) } Err(e) => { - tracing::error!("gRPC error adding user '{}' to inbound '{}': status={}, message={}", - email, inbound_tag, e.code(), e.message()); - Err(anyhow!("Failed to add user '{}' to inbound '{}': {}", email, inbound_tag, e)) + tracing::error!( + "gRPC error adding user '{}' to inbound '{}': status={}, message={}", + email, + inbound_tag, + e.code(), + e.message() + ); + Err(anyhow!( + "Failed to add user '{}' to inbound '{}': {}", + email, + inbound_tag, + e + )) } } } /// Remove user from inbound pub async fn remove_user(&self, inbound_tag: &str, email: &str) -> Result<()> { - // Build the RemoveUserOperation let remove_user_op = RemoveUserOperation { email: email.to_string(), }; - + let typed_message = TypedMessage { r#type: "xray.app.proxyman.command.RemoveUserOperation".to_string(), value: remove_user_op.encode_to_vec(), }; - + let request = Request::new(AlterInboundRequest { tag: inbound_tag.to_string(), operation: Some(typed_message), }); - + let mut handler_client = self.client.handler(); match handler_client.alter_inbound(request).await { - Ok(_) => { - Ok(()) - } + Ok(_) => Ok(()), Err(e) => { - tracing::error!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e); - Err(anyhow!("Failed to remove user '{}' from inbound '{}': {}", email, inbound_tag, e)) + tracing::error!( + "Failed to remove user '{}' from inbound '{}': {}", + email, + inbound_tag, + e + ); + Err(anyhow!( + "Failed to remove user '{}' from inbound '{}': {}", + email, + inbound_tag, + e + )) } } } -} \ No newline at end of file +} diff --git a/src/web/handlers/certificates.rs b/src/web/handlers/certificates.rs index 8fd7d01..d70450f 100644 --- a/src/web/handlers/certificates.rs +++ b/src/web/handlers/certificates.rs @@ -1,3 +1,8 @@ +use crate::{ + database::{entities::certificate, repository::CertificateRepository}, + services::certificates::CertificateService, + web::AppState, +}; use axum::{ extract::{Path, State}, http::StatusCode, @@ -6,27 +11,17 @@ use axum::{ }; use serde_json::json; use uuid::Uuid; -use crate::{ - database::{ - entities::certificate, - repository::CertificateRepository, - }, - services::certificates::CertificateService, - web::AppState, -}; /// List all certificates pub async fn list_certificates( State(app_state): State, ) -> Result>, StatusCode> { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + match repo.find_all().await { Ok(certificates) => { - let responses: Vec = certificates - .into_iter() - .map(|c| c.into()) - .collect(); + let responses: Vec = + certificates.into_iter().map(|c| c.into()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -39,7 +34,7 @@ pub async fn get_certificate( Path(id): Path, ) -> Result, StatusCode> { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + match repo.find_by_id(id).await { Ok(Some(certificate)) => Ok(Json(certificate.into())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -53,7 +48,7 @@ pub async fn get_certificate_details( Path(id): Path, ) -> Result, StatusCode> { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + match repo.find_by_id(id).await { Ok(Some(certificate)) => Ok(Json(certificate.into())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -69,74 +64,99 @@ pub async fn create_certificate( tracing::info!("Creating certificate: {:?}", cert_data); let repo = CertificateRepository::new(app_state.db.connection().clone()); let cert_service = CertificateService::new(); - + // Generate certificate based on type let (cert_pem, private_key) = match cert_data.cert_type.as_str() { - "self_signed" => { - cert_service.generate_self_signed(&cert_data.domain).await - .map_err(|e| { - tracing::error!("Failed to generate self-signed certificate: {:?}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ + "self_signed" => cert_service + .generate_self_signed(&cert_data.domain) + .await + .map_err(|e| { + tracing::error!("Failed to generate self-signed certificate: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to generate self-signed certificate", "details": format!("{:?}", e) - }))) - })? - } + })), + ) + })?, "letsencrypt" => { // Validate required fields for Let's Encrypt - let dns_provider_id = cert_data.dns_provider_id - .ok_or((StatusCode::BAD_REQUEST, Json(json!({ + let dns_provider_id = cert_data.dns_provider_id.ok_or(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "DNS provider ID is required for Let's Encrypt certificates" - }))))?; - let acme_email = cert_data.acme_email - .as_ref() - .ok_or((StatusCode::BAD_REQUEST, Json(json!({ + })), + ))?; + let acme_email = cert_data.acme_email.as_ref().ok_or(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "ACME email is required for Let's Encrypt certificates" - }))))?; - + })), + ))?; + let cert_service = CertificateService::with_db(app_state.db.connection().clone()); - cert_service.generate_letsencrypt_certificate( - &cert_data.domain, - dns_provider_id, - acme_email, - false // production by default - ).await + cert_service + .generate_letsencrypt_certificate( + &cert_data.domain, + dns_provider_id, + acme_email, + false, // production by default + ) + .await .map_err(|e| { tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e); // Return a more detailed error response - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": "Failed to generate Let's Encrypt certificate", - "details": format!("{:?}", e) - }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "Failed to generate Let's Encrypt certificate", + "details": format!("{:?}", e) + })), + ) })? } "imported" => { // For imported certificates, use provided PEM data if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() { - return Err((StatusCode::BAD_REQUEST, Json(json!({ - "error": "Certificate PEM and private key are required for imported certificates" - })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "Certificate PEM and private key are required for imported certificates" + })), + )); } - (cert_data.certificate_pem.clone(), cert_data.private_key.clone()) + ( + cert_data.certificate_pem.clone(), + cert_data.private_key.clone(), + ) + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported" + })), + )) } - _ => return Err((StatusCode::BAD_REQUEST, Json(json!({ - "error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported" - })))), }; - + // Create certificate with generated data let mut create_dto = cert_data; create_dto.certificate_pem = cert_pem; create_dto.private_key = private_key; - + match repo.create(create_dto).await { Ok(certificate) => Ok(Json(certificate.into())), Err(e) => { tracing::error!("Failed to save certificate to database: {:?}", e); - Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ - "error": "Failed to save certificate to database", - "details": format!("{:?}", e) - })))) + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "Failed to save certificate to database", + "details": format!("{:?}", e) + })), + )) } } } @@ -148,7 +168,7 @@ pub async fn update_certificate( JsonExtractor(cert_data): JsonExtractor, ) -> Result, StatusCode> { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + match repo.update(id, cert_data).await { Ok(certificate) => Ok(Json(certificate.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -161,7 +181,7 @@ pub async fn delete_certificate( Path(id): Path, ) -> Result { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + match repo.delete(id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), Ok(false) => Err(StatusCode::NOT_FOUND), @@ -174,16 +194,14 @@ pub async fn get_expiring_certificates( State(app_state): State, ) -> Result>, StatusCode> { let repo = CertificateRepository::new(app_state.db.connection().clone()); - + // Get certificates expiring in next 30 days match repo.find_expiring_soon(30).await { Ok(certificates) => { - let responses: Vec = certificates - .into_iter() - .map(|c| c.into()) - .collect(); + let responses: Vec = + certificates.into_iter().map(|c| c.into()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } -} \ No newline at end of file +} diff --git a/src/web/handlers/client_configs.rs b/src/web/handlers/client_configs.rs index c9dcb88..ba45152 100644 --- a/src/web/handlers/client_configs.rs +++ b/src/web/handlers/client_configs.rs @@ -34,18 +34,20 @@ pub async fn get_user_inbound_config( ) -> Result, StatusCode> { let repo = InboundUsersRepository::new(app_state.db.connection().clone()); let uri_service = UriGeneratorService::new(); - + // Get client configuration data - let config_data = repo.get_client_config_data(user_id, inbound_id) + let config_data = repo + .get_client_config_data(user_id, inbound_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let config_data = config_data.ok_or(StatusCode::NOT_FOUND)?; - + // Generate URI - let client_config = uri_service.generate_client_config(user_id, &config_data) + let client_config = uri_service + .generate_client_config(user_id, &config_data) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let response = ClientConfigResponse { user_id: client_config.user_id, server_name: client_config.server_name, @@ -54,7 +56,7 @@ pub async fn get_user_inbound_config( uri: client_config.uri, qr_code: client_config.qr_code, }; - + Ok(Json(response)) } @@ -65,14 +67,15 @@ pub async fn get_user_configs( ) -> Result>, StatusCode> { let repo = InboundUsersRepository::new(app_state.db.connection().clone()); let uri_service = UriGeneratorService::new(); - + // Get all client configuration data for user - let configs_data = repo.get_all_client_configs_for_user(user_id) + let configs_data = repo + .get_all_client_configs_for_user(user_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let mut responses = Vec::new(); - + for config_data in configs_data { match uri_service.generate_client_config(user_id, &config_data) { Ok(client_config) => { @@ -84,14 +87,14 @@ pub async fn get_user_configs( uri: client_config.uri, qr_code: client_config.qr_code, }); - }, + } Err(_) => { // Log error but continue with other configs continue; } } } - + Ok(Json(responses)) } @@ -102,17 +105,21 @@ pub async fn get_inbound_configs( ) -> Result>, StatusCode> { let repo = InboundUsersRepository::new(app_state.db.connection().clone()); let uri_service = UriGeneratorService::new(); - + // Get all users for this inbound - let inbound_users = repo.find_active_by_inbound_id(inbound_id) + let inbound_users = repo + .find_active_by_inbound_id(inbound_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let mut responses = Vec::new(); - + for inbound_user in inbound_users { // Get client configuration data for each user - if let Ok(Some(config_data)) = repo.get_client_config_data(inbound_user.user_id, inbound_id).await { + if let Ok(Some(config_data)) = repo + .get_client_config_data(inbound_user.user_id, inbound_id) + .await + { match uri_service.generate_client_config(inbound_user.user_id, &config_data) { Ok(client_config) => { responses.push(ClientConfigResponse { @@ -123,7 +130,7 @@ pub async fn get_inbound_configs( uri: client_config.uri, qr_code: client_config.qr_code, }); - }, + } Err(_) => { // Log error but continue with other configs continue; @@ -131,6 +138,6 @@ pub async fn get_inbound_configs( } } } - + Ok(Json(responses)) -} \ No newline at end of file +} diff --git a/src/web/handlers/dns_providers.rs b/src/web/handlers/dns_providers.rs index 083865b..b79a8fb 100644 --- a/src/web/handlers/dns_providers.rs +++ b/src/web/handlers/dns_providers.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::{ database::{ entities::dns_provider::{ - CreateDnsProviderDto, UpdateDnsProviderDto, DnsProviderResponseDto, + CreateDnsProviderDto, DnsProviderResponseDto, UpdateDnsProviderDto, }, repository::DnsProviderRepository, }, @@ -20,7 +20,7 @@ pub async fn create_dns_provider( Json(dto): Json, ) -> Result, StatusCode> { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.create(dto).await { Ok(provider) => Ok(Json(provider.to_response_dto())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -31,13 +31,11 @@ pub async fn list_dns_providers( State(state): State, ) -> Result>, StatusCode> { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.find_all().await { Ok(providers) => { - let responses: Vec = providers - .into_iter() - .map(|p| p.to_response_dto()) - .collect(); + let responses: Vec = + providers.into_iter().map(|p| p.to_response_dto()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -49,7 +47,7 @@ pub async fn get_dns_provider( Path(id): Path, ) -> Result, StatusCode> { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.find_by_id(id).await { Ok(Some(provider)) => Ok(Json(provider.to_response_dto())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -63,7 +61,7 @@ pub async fn update_dns_provider( Json(dto): Json, ) -> Result, StatusCode> { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.update(id, dto).await { Ok(Some(updated_provider)) => Ok(Json(updated_provider.to_response_dto())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -76,7 +74,7 @@ pub async fn delete_dns_provider( Path(id): Path, ) -> Result { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.delete(id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), Ok(false) => Err(StatusCode::NOT_FOUND), @@ -88,15 +86,13 @@ pub async fn list_active_cloudflare_providers( State(state): State, ) -> Result>, StatusCode> { let repo = DnsProviderRepository::new(state.db.connection().clone()); - + match repo.find_active_by_type("cloudflare").await { Ok(providers) => { - let responses: Vec = providers - .into_iter() - .map(|p| p.to_response_dto()) - .collect(); + let responses: Vec = + providers.into_iter().map(|p| p.to_response_dto()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } -} \ No newline at end of file +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index c9ed68f..95b8003 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -1,21 +1,21 @@ -pub mod users; -pub mod servers; pub mod certificates; -pub mod templates; pub mod client_configs; pub mod dns_providers; +pub mod servers; +pub mod subscription; pub mod tasks; pub mod telegram; +pub mod templates; pub mod user_requests; -pub mod subscription; +pub mod users; -pub use users::*; -pub use servers::*; pub use certificates::*; -pub use templates::*; pub use client_configs::*; pub use dns_providers::*; +pub use servers::*; +pub use subscription::*; pub use tasks::*; pub use telegram::*; +pub use templates::*; pub use user_requests::*; -pub use subscription::*; \ No newline at end of file +pub use users::*; diff --git a/src/web/handlers/servers.rs b/src/web/handlers/servers.rs index fef853b..576a72f 100644 --- a/src/web/handlers/servers.rs +++ b/src/web/handlers/servers.rs @@ -1,3 +1,13 @@ +use crate::{ + database::{ + entities::{server, server_inbound}, + repository::{ + CertificateRepository, InboundTemplateRepository, InboundUsersRepository, + ServerInboundRepository, ServerRepository, UserRepository, + }, + }, + web::AppState, +}; use axum::{ extract::{Path, State}, http::StatusCode, @@ -5,26 +15,17 @@ use axum::{ Json as JsonExtractor, }; use uuid::Uuid; -use crate::{ - database::{ - entities::{server, server_inbound}, - repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, CertificateRepository, InboundUsersRepository, UserRepository}, - }, - web::AppState, -}; /// List all servers pub async fn list_servers( State(app_state): State, ) -> Result>, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + match repo.find_all().await { Ok(servers) => { - let responses: Vec = servers - .into_iter() - .map(|s| s.into()) - .collect(); + let responses: Vec = + servers.into_iter().map(|s| s.into()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -37,7 +38,7 @@ pub async fn get_server( Path(id): Path, ) -> Result, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + match repo.find_by_id(id).await { Ok(Some(server)) => Ok(Json(server.into())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -51,7 +52,7 @@ pub async fn create_server( Json(server_data): Json, ) -> Result, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + match repo.create(server_data).await { Ok(server) => Ok(Json(server.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -65,7 +66,7 @@ pub async fn update_server( Json(server_data): Json, ) -> Result, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + match repo.update(id, server_data).await { Ok(server) => Ok(Json(server.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -78,7 +79,7 @@ pub async fn delete_server( Path(id): Path, ) -> Result { let repo = ServerRepository::new(app_state.db.connection().clone()); - + match repo.delete(id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), Ok(false) => Err(StatusCode::NOT_FOUND), @@ -92,7 +93,7 @@ pub async fn test_server_connection( Path(id): Path, ) -> Result, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + let server = match repo.find_by_id(id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), @@ -100,7 +101,7 @@ pub async fn test_server_connection( }; let endpoint = server.get_grpc_endpoint(); - + match app_state.xray_service.test_connection(id, &endpoint).await { Ok(connected) => { // Update server status based on connection test @@ -114,14 +115,14 @@ pub async fn test_server_connection( default_certificate_id: None, status: Some(new_status.to_string()), }; - + let _ = repo.update(id, update_dto).await; // Ignore update errors for now - + Ok(Json(serde_json::json!({ "connected": connected, "endpoint": endpoint }))) - }, + } Err(e) => { // Update status to error let update_dto = server::UpdateServerDto { @@ -133,15 +134,15 @@ pub async fn test_server_connection( default_certificate_id: None, status: Some("error".to_string()), }; - + let _ = repo.update(id, update_dto).await; // Ignore update errors for now - + Ok(Json(serde_json::json!({ "connected": false, "endpoint": endpoint, "error": e.to_string() }))) - }, + } } } @@ -151,7 +152,7 @@ pub async fn get_server_stats( Path(id): Path, ) -> Result, StatusCode> { let repo = ServerRepository::new(app_state.db.connection().clone()); - + let server = match repo.find_by_id(id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), @@ -159,7 +160,7 @@ pub async fn get_server_stats( }; let endpoint = server.get_grpc_endpoint(); - + match app_state.xray_service.get_stats(id, &endpoint).await { Ok(stats) => Ok(Json(stats)), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -172,7 +173,7 @@ pub async fn list_server_inbounds( Path(server_id): Path, ) -> Result>, StatusCode> { let repo = ServerInboundRepository::new(app_state.db.connection().clone()); - + match repo.find_by_server_id_with_template(server_id).await { Ok(responses) => Ok(Json(responses)), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -186,51 +187,52 @@ pub async fn create_server_inbound( JsonExtractor(inbound_data): JsonExtractor, ) -> Result, StatusCode> { tracing::debug!("Creating server inbound for server {}", server_id); - + let server_repo = ServerRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone()); let cert_repo = CertificateRepository::new(app_state.db.connection().clone()); - + // Get server info let server = match server_repo.find_by_id(server_id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Get template info let template = match template_repo.find_by_id(inbound_data.template_id).await { Ok(Some(template)) => template, Ok(None) => return Err(StatusCode::BAD_REQUEST), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Create inbound in database first with protocol-aware tag - let inbound = match inbound_repo.create_with_protocol(server_id, inbound_data, &template.protocol).await { + let inbound = match inbound_repo + .create_with_protocol(server_id, inbound_data, &template.protocol) + .await + { Ok(inbound) => { // Send sync event for immediate synchronization crate::services::events::send_sync_event( - crate::services::events::SyncEvent::InboundChanged(server_id) + crate::services::events::SyncEvent::InboundChanged(server_id), ); inbound - }, + } Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Try to create inbound on xray server only if it's active let endpoint = server.get_grpc_endpoint(); if inbound.is_active { // Get certificate data if certificate is specified let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id { match cert_repo.find_by_id(cert_id).await { - Ok(Some(cert)) => { - (Some(cert.certificate_pem()), Some(cert.private_key_pem())) - }, + Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())), Ok(None) => { tracing::warn!("Certificate {} not found", cert_id); (None, None) - }, + } Err(e) => { tracing::error!("Error fetching certificate {}: {}", cert_id, e); (None, None) @@ -240,22 +242,31 @@ pub async fn create_server_inbound( (None, None) }; - match app_state.xray_service.create_inbound_with_certificate( - server_id, - &endpoint, - &inbound.tag, - inbound.port_override.unwrap_or(template.default_port), - &template.protocol, - template.base_settings.clone(), - template.stream_settings.clone(), - cert_pem.as_deref(), - key_pem.as_deref(), - ).await { + match app_state + .xray_service + .create_inbound_with_certificate( + server_id, + &endpoint, + &inbound.tag, + inbound.port_override.unwrap_or(template.default_port), + &template.protocol, + template.base_settings.clone(), + template.stream_settings.clone(), + cert_pem.as_deref(), + key_pem.as_deref(), + ) + .await + { Ok(_) => { tracing::info!("Created inbound '{}' on {}", inbound.tag, endpoint); - }, + } Err(e) => { - tracing::error!("Failed to create inbound '{}' on {}: {}", inbound.tag, endpoint, e); + tracing::error!( + "Failed to create inbound '{}' on {}: {}", + inbound.tag, + endpoint, + e + ); // Note: We don't fail the request since the inbound is already in DB // The user can manually sync or retry later } @@ -263,7 +274,7 @@ pub async fn create_server_inbound( } else { tracing::debug!("Inbound '{}' created as inactive", inbound.tag); } - + Ok(Json(inbound.into())) } @@ -273,20 +284,24 @@ pub async fn update_server_inbound( Path((server_id, inbound_id)): Path<(Uuid, Uuid)>, JsonExtractor(inbound_data): JsonExtractor, ) -> Result, StatusCode> { - tracing::debug!("Updating server inbound {} for server {}", inbound_id, server_id); - + tracing::debug!( + "Updating server inbound {} for server {}", + inbound_id, + server_id + ); + let server_repo = ServerRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone()); let cert_repo = CertificateRepository::new(app_state.db.connection().clone()); - + // Get server info let server = match server_repo.find_by_id(server_id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Get current inbound state let current_inbound = match inbound_repo.find_by_id(inbound_id).await { Ok(Some(inbound)) if inbound.server_id == server_id => inbound, @@ -294,48 +309,64 @@ pub async fn update_server_inbound( Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Check if is_active status is changing let old_is_active = current_inbound.is_active; let new_is_active = inbound_data.is_active.unwrap_or(old_is_active); let endpoint = server.get_grpc_endpoint(); - + // Handle xray server changes based on active status change if old_is_active && !new_is_active { // Becoming inactive - remove from xray server - match app_state.xray_service.remove_inbound(server_id, &endpoint, ¤t_inbound.tag).await { + match app_state + .xray_service + .remove_inbound(server_id, &endpoint, ¤t_inbound.tag) + .await + { Ok(_) => { - tracing::info!("Deactivated inbound '{}' on {}", current_inbound.tag, endpoint); - }, + tracing::info!( + "Deactivated inbound '{}' on {}", + current_inbound.tag, + endpoint + ); + } Err(e) => { - tracing::error!("Failed to deactivate inbound '{}': {}", current_inbound.tag, e); + tracing::error!( + "Failed to deactivate inbound '{}': {}", + current_inbound.tag, + e + ); // Continue with database update even if xray removal fails } } } else if !old_is_active && new_is_active { // Becoming active - add to xray server - + // Get template info for recreation let template = match template_repo.find_by_id(current_inbound.template_id).await { Ok(Some(template)) => template, Ok(None) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Use updated port if provided, otherwise keep current - let port = inbound_data.port_override.unwrap_or(current_inbound.port_override.unwrap_or(template.default_port)); - + let port = inbound_data.port_override.unwrap_or( + current_inbound + .port_override + .unwrap_or(template.default_port), + ); + // Get certificate data if certificate is specified (could be updated) - let certificate_id = inbound_data.certificate_id.or(current_inbound.certificate_id); + let certificate_id = inbound_data + .certificate_id + .or(current_inbound.certificate_id); let (cert_pem, key_pem) = if let Some(cert_id) = certificate_id { match cert_repo.find_by_id(cert_id).await { - Ok(Some(cert)) => { - (Some(cert.certificate_pem()), Some(cert.private_key_pem())) - }, + Ok(Some(cert)) => (Some(cert.certificate_pem()), Some(cert.private_key_pem())), Ok(None) => { tracing::warn!("Certificate {} not found", cert_id); (None, None) - }, + } Err(e) => { tracing::error!("Error fetching certificate {}: {}", cert_id, e); (None, None) @@ -344,37 +375,49 @@ pub async fn update_server_inbound( } else { (None, None) }; - - match app_state.xray_service.create_inbound_with_certificate( - server_id, - &endpoint, - ¤t_inbound.tag, - port, - &template.protocol, - template.base_settings.clone(), - template.stream_settings.clone(), - cert_pem.as_deref(), - key_pem.as_deref(), - ).await { + + match app_state + .xray_service + .create_inbound_with_certificate( + server_id, + &endpoint, + ¤t_inbound.tag, + port, + &template.protocol, + template.base_settings.clone(), + template.stream_settings.clone(), + cert_pem.as_deref(), + key_pem.as_deref(), + ) + .await + { Ok(_) => { - tracing::info!("Activated inbound '{}' on {}", current_inbound.tag, endpoint); - }, + tracing::info!( + "Activated inbound '{}' on {}", + current_inbound.tag, + endpoint + ); + } Err(e) => { - tracing::error!("Failed to activate inbound '{}': {}", current_inbound.tag, e); + tracing::error!( + "Failed to activate inbound '{}': {}", + current_inbound.tag, + e + ); // Continue with database update even if xray creation fails } } } - + // Update database match inbound_repo.update(inbound_id, inbound_data).await { Ok(updated_inbound) => { // Send sync event for immediate synchronization crate::services::events::send_sync_event( - crate::services::events::SyncEvent::InboundChanged(server_id) + crate::services::events::SyncEvent::InboundChanged(server_id), ); Ok(Json(updated_inbound.into())) - }, + } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -385,12 +428,10 @@ pub async fn get_server_inbound( Path((server_id, inbound_id)): Path<(Uuid, Uuid)>, ) -> Result, StatusCode> { let repo = ServerInboundRepository::new(app_state.db.connection().clone()); - + // Verify the inbound belongs to the server match repo.find_by_id(inbound_id).await { - Ok(Some(inbound)) if inbound.server_id == server_id => { - Ok(Json(inbound.into())) - } + Ok(Some(inbound)) if inbound.server_id == server_id => Ok(Json(inbound.into())), Ok(Some(_)) => Err(StatusCode::BAD_REQUEST), Ok(None) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -404,14 +445,14 @@ pub async fn delete_server_inbound( ) -> Result { let server_repo = ServerRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); - + // Get server and inbound info let server = match server_repo.find_by_id(server_id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Verify the inbound belongs to the server let inbound = match inbound_repo.find_by_id(inbound_id).await { Ok(Some(inbound)) if inbound.server_id == server_id => inbound, @@ -419,28 +460,37 @@ pub async fn delete_server_inbound( Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Try to remove inbound from xray server first let endpoint = server.get_grpc_endpoint(); - match app_state.xray_service.remove_inbound(server_id, &endpoint, &inbound.tag).await { + match app_state + .xray_service + .remove_inbound(server_id, &endpoint, &inbound.tag) + .await + { Ok(_) => { tracing::info!("Removed inbound '{}' from {}", inbound.tag, endpoint); - }, + } Err(e) => { - tracing::error!("Failed to remove inbound '{}' from {}: {}", inbound.tag, endpoint, e); + tracing::error!( + "Failed to remove inbound '{}' from {}: {}", + inbound.tag, + endpoint, + e + ); // Continue with database deletion even if xray removal fails } } - + // Delete from database match inbound_repo.delete(inbound_id).await { Ok(true) => { // Send sync event for immediate synchronization crate::services::events::send_sync_event( - crate::services::events::SyncEvent::InboundChanged(server_id) + crate::services::events::SyncEvent::InboundChanged(server_id), ); Ok(StatusCode::NO_CONTENT) - }, + } Ok(false) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } @@ -454,42 +504,43 @@ pub async fn add_user_to_inbound( ) -> Result { use crate::database::entities::inbound_users::CreateInboundUserDto; use crate::database::entities::user::CreateUserDto; - + let server_repo = ServerRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); let user_repo = UserRepository::new(app_state.db.connection().clone()); - + // Get server and inbound to validate they exist let _server = match server_repo.find_by_id(server_id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + let inbound = match inbound_repo.find_by_id(inbound_id).await { Ok(Some(inbound)) => inbound, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Verify inbound belongs to server if inbound.server_id != server_id { return Err(StatusCode::BAD_REQUEST); } - + // Extract user data - - let user_name = user_data["name"].as_str() + + let user_name = user_data["name"] + .as_str() .or_else(|| user_data["username"].as_str()) .or_else(|| user_data["email"].as_str()) .map(|s| s.to_string()) - .unwrap_or_else(|| { - format!("user_{}", Uuid::new_v4().to_string()[..8].to_string()) - }); - + .unwrap_or_else(|| format!("user_{}", Uuid::new_v4().to_string()[..8].to_string())); + let level = user_data["level"].as_u64().unwrap_or(0) as i32; - let user_id = user_data["user_id"].as_str().and_then(|s| Uuid::parse_str(s).ok()); - + let user_id = user_data["user_id"] + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()); + // Get or create user let user = if let Some(uid) = user_id { // Use existing user @@ -506,7 +557,7 @@ pub async fn add_user_to_inbound( telegram_id: user_data["telegram_id"].as_i64(), is_telegram_admin: false, }; - + match user_repo.create(create_user_dto).await { Ok(user) => user, Err(e) => { @@ -515,36 +566,43 @@ pub async fn add_user_to_inbound( } } }; - + // Create inbound user repository let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone()); - + // Check if user already has access to this inbound - if inbound_users_repo.user_has_access_to_inbound(user.id, inbound_id).await.unwrap_or(false) { + if inbound_users_repo + .user_has_access_to_inbound(user.id, inbound_id) + .await + .unwrap_or(false) + { tracing::warn!("User '{}' already has access to inbound", user.name); return Err(StatusCode::CONFLICT); } - + // Create inbound access for user let inbound_user_dto = CreateInboundUserDto { user_id: user.id, server_inbound_id: inbound_id, level: Some(level), }; - + // Grant access in database match inbound_users_repo.create(inbound_user_dto).await { Ok(created_access) => { - tracing::info!("Granted user '{}' access to inbound (xray_id={})", - user.name, created_access.xray_user_id); - + tracing::info!( + "Granted user '{}' access to inbound (xray_id={})", + user.name, + created_access.xray_user_id + ); + // Send sync event for immediate synchronization crate::services::events::send_sync_event( - crate::services::events::SyncEvent::UserAccessChanged(server_id) + crate::services::events::SyncEvent::UserAccessChanged(server_id), ); - + Ok(StatusCode::CREATED) - }, + } Err(e) => { tracing::error!("Failed to grant user '{}' access: {}", user.name, e); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -559,25 +617,25 @@ pub async fn remove_user_from_inbound( ) -> Result { let server_repo = ServerRepository::new(app_state.db.connection().clone()); let inbound_repo = ServerInboundRepository::new(app_state.db.connection().clone()); - + // Get server and inbound let server = match server_repo.find_by_id(server_id).await { Ok(Some(server)) => server, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + let inbound = match inbound_repo.find_by_id(inbound_id).await { Ok(Some(inbound)) => inbound, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Verify inbound belongs to server if inbound.server_id != server_id { return Err(StatusCode::BAD_REQUEST); } - + // Get inbound tag let template_repo = InboundTemplateRepository::new(app_state.db.connection().clone()); let template = match template_repo.find_by_id(inbound.template_id).await { @@ -585,18 +643,22 @@ pub async fn remove_user_from_inbound( Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + let inbound_tag = &inbound.tag; - + // Remove user from xray server - match app_state.xray_service.remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email).await { + match app_state + .xray_service + .remove_user(server_id, &server.get_grpc_endpoint(), &inbound_tag, &email) + .await + { Ok(_) => { tracing::info!("Removed user '{}' from inbound", email); Ok(StatusCode::NO_CONTENT) - }, + } Err(e) => { tracing::error!("Failed to remove user '{}' from inbound: {}", email, e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } -} \ No newline at end of file +} diff --git a/src/web/handlers/subscription.rs b/src/web/handlers/subscription.rs index 606ea14..60c82c2 100644 --- a/src/web/handlers/subscription.rs +++ b/src/web/handlers/subscription.rs @@ -1,13 +1,13 @@ use axum::{ extract::{Path, State}, - http::{StatusCode, HeaderMap, HeaderValue}, + http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; -use base64::{Engine, engine::general_purpose}; +use base64::{engine::general_purpose, Engine}; use uuid::Uuid; use crate::{ - database::repository::{UserRepository, InboundUsersRepository}, + database::repository::{InboundUsersRepository, UserRepository}, services::uri_generator::UriGeneratorService, web::AppState, }; @@ -21,23 +21,26 @@ pub async fn get_user_subscription( ) -> Result { let user_repo = UserRepository::new(state.db.connection()); let inbound_users_repo = InboundUsersRepository::new(state.db.connection().clone()); - + // Check if user exists let user = match user_repo.get_by_id(user_id).await { Ok(Some(user)) => user, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Get all client config data for the user (this gets all active inbound accesses) - let all_configs = match inbound_users_repo.get_all_client_configs_for_user(user_id).await { + let all_configs = match inbound_users_repo + .get_all_client_configs_for_user(user_id) + .await + { Ok(configs) => configs, Err(e) => { tracing::error!("Failed to get client configs for user {}: {}", user_id, e); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; - + if all_configs.is_empty() { let response_text = "# No configurations available\n".to_string(); let response_base64 = general_purpose::STANDARD.encode(response_text); @@ -45,29 +48,38 @@ pub async fn get_user_subscription( StatusCode::OK, [("content-type", "text/plain; charset=utf-8")], response_base64, - ).into_response()); + ) + .into_response()); } - + let mut config_lines = Vec::new(); - + // Generate connection strings for each config using existing UriGeneratorService let uri_generator = UriGeneratorService::new(); - + for config_data in all_configs { match uri_generator.generate_client_config(user_id, &config_data) { Ok(client_config) => { config_lines.push(client_config.uri); - tracing::debug!("Generated {} config for user {}: {}", - config_data.protocol.to_uppercase(), user.name, config_data.template_name); + tracing::debug!( + "Generated {} config for user {}: {}", + config_data.protocol.to_uppercase(), + user.name, + config_data.template_name + ); } Err(e) => { - tracing::warn!("Failed to generate connection string for user {} template {}: {}", - user.name, config_data.template_name, e); + tracing::warn!( + "Failed to generate connection string for user {} template {}: {}", + user.name, + config_data.template_name, + e + ); continue; } } } - + if config_lines.is_empty() { let response_text = "# No valid configurations available\n".to_string(); let response_base64 = general_purpose::STANDARD.encode(response_text); @@ -75,35 +87,56 @@ pub async fn get_user_subscription( StatusCode::OK, [("content-type", "text/plain; charset=utf-8")], response_base64, - ).into_response()); + ) + .into_response()); } - + // Join all URIs with newlines (like Django implementation) let response_text = config_lines.join("\n") + "\n"; - + // Encode the entire response in base64 (like Django implementation) let response_base64 = general_purpose::STANDARD.encode(response_text); - + // Build response with subscription headers (like Django) let mut headers = HeaderMap::new(); - + // Add headers required by VPN clients - headers.insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8")); - headers.insert("content-disposition", HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap()); + headers.insert( + "content-type", + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + headers.insert( + "content-disposition", + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap(), + ); headers.insert("cache-control", HeaderValue::from_static("no-cache")); - + // Profile information let profile_title = general_purpose::STANDARD.encode("OutFleet VPN"); - headers.insert("profile-title", HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap()); + headers.insert( + "profile-title", + HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap(), + ); headers.insert("profile-update-interval", HeaderValue::from_static("24")); - headers.insert("profile-web-page-url", HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap()); - headers.insert("support-url", HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap()); - + headers.insert( + "profile-web-page-url", + HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap(), + ); + headers.insert( + "support-url", + HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap(), + ); + // Subscription info (unlimited service) let expire_timestamp = chrono::Utc::now().timestamp() + (365 * 24 * 60 * 60); // 1 year from now - headers.insert("subscription-userinfo", - HeaderValue::from_str(&format!("upload=0; download=0; total=1099511627776; expire={}", expire_timestamp)).unwrap()); - + headers.insert( + "subscription-userinfo", + HeaderValue::from_str(&format!( + "upload=0; download=0; total=1099511627776; expire={}", + expire_timestamp + )) + .unwrap(), + ); + Ok((StatusCode::OK, headers, response_base64).into_response()) } - diff --git a/src/web/handlers/tasks.rs b/src/web/handlers/tasks.rs index bd4cce2..d1bc975 100644 --- a/src/web/handlers/tasks.rs +++ b/src/web/handlers/tasks.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::State, - http::StatusCode, - response::Json, -}; +use axum::{extract::State, http::StatusCode, response::Json}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -45,42 +41,58 @@ pub async fn get_tasks_status( // Get task status from the scheduler // For now, we'll return a mock response since we need to expose the scheduler // In a real implementation, you'd store a reference to the TaskScheduler in AppState - + let mut tasks = HashMap::new(); let mut running_count = 0; let mut success_count = 0; let mut error_count = 0; let mut idle_count = 0; - + // Mock data for demonstration - in real implementation, get from TaskScheduler let xray_sync_task = TaskStatusResponse { name: "Xray Synchronization".to_string(), description: "Synchronizes database state with xray servers".to_string(), schedule: "0 */5 * * * * (every 5 minutes)".to_string(), status: "Success".to_string(), - last_run: Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()), - next_run: Some((chrono::Utc::now() + chrono::Duration::minutes(5)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), + last_run: Some( + chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + ), + next_run: Some( + (chrono::Utc::now() + chrono::Duration::minutes(5)) + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + ), total_runs: 120, success_count: 118, error_count: 2, last_error: None, last_duration_ms: Some(1234), }; - + let cert_renewal_task = TaskStatusResponse { name: "Certificate Renewal".to_string(), description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(), schedule: "0 0 2 * * * (daily at 2 AM)".to_string(), status: "Idle".to_string(), - last_run: Some((chrono::Utc::now() - chrono::Duration::hours(8)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), - next_run: Some((chrono::Utc::now() + chrono::Duration::hours(16)).format("%Y-%m-%d %H:%M:%S UTC").to_string()), + last_run: Some( + (chrono::Utc::now() - chrono::Duration::hours(8)) + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + ), + next_run: Some( + (chrono::Utc::now() + chrono::Duration::hours(16)) + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + ), total_runs: 5, success_count: 5, error_count: 0, last_error: None, last_duration_ms: Some(567), }; - + // Count task statuses match xray_sync_task.status.as_str() { "Running" => running_count += 1, @@ -89,7 +101,7 @@ pub async fn get_tasks_status( "Idle" => idle_count += 1, _ => idle_count += 1, } - + match cert_renewal_task.status.as_str() { "Running" => running_count += 1, "Success" => success_count += 1, @@ -97,10 +109,10 @@ pub async fn get_tasks_status( "Idle" => idle_count += 1, _ => idle_count += 1, } - + tasks.insert("xray_sync".to_string(), xray_sync_task); tasks.insert("cert_renewal".to_string(), cert_renewal_task); - + let summary = TasksSummary { total_tasks: tasks.len(), running_tasks: running_count, @@ -108,9 +120,9 @@ pub async fn get_tasks_status( failed_tasks: error_count, idle_tasks: idle_count, }; - + let response = TasksStatusResponse { tasks, summary }; - + Ok(Json(response)) } @@ -122,14 +134,10 @@ pub async fn trigger_task( // In a real implementation, you'd trigger the actual task // For now, return a success response match task_id.as_str() { - "xray_sync" | "cert_renewal" => { - Ok(Json(serde_json::json!({ - "success": true, - "message": format!("Task '{}' has been triggered", task_id) - }))) - } - _ => { - Err(StatusCode::NOT_FOUND) - } + "xray_sync" | "cert_renewal" => Ok(Json(serde_json::json!({ + "success": true, + "message": format!("Task '{}' has been triggered", task_id) + }))), + _ => Err(StatusCode::NOT_FOUND), } -} \ No newline at end of file +} diff --git a/src/web/handlers/telegram.rs b/src/web/handlers/telegram.rs index 96ba67e..2cdfd84 100644 --- a/src/web/handlers/telegram.rs +++ b/src/web/handlers/telegram.rs @@ -1,14 +1,16 @@ use axum::{ - extract::{State, Path, Json}, + extract::{Json, Path, State}, http::StatusCode, response::IntoResponse, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::database::entities::telegram_config::{ + CreateTelegramConfigDto, UpdateTelegramConfigDto, +}; +use crate::database::repository::{TelegramConfigRepository, UserRepository}; use crate::web::AppState; -use crate::database::repository::{UserRepository, TelegramConfigRepository}; -use crate::database::entities::telegram_config::{CreateTelegramConfigDto, UpdateTelegramConfigDto}; /// Response for Telegram config #[derive(Debug, Serialize)] @@ -27,11 +29,9 @@ pub struct BotInfo { } /// Get current Telegram configuration -pub async fn get_telegram_config( - State(state): State, -) -> impl IntoResponse { +pub async fn get_telegram_config(State(state): State) -> impl IntoResponse { let repo = TelegramConfigRepository::new(state.db.connection()); - + match repo.get_latest().await { Ok(Some(config)) => { let mut response = TelegramConfigResponse { @@ -51,9 +51,7 @@ pub async fn get_telegram_config( Json(response).into_response() } - Ok(None) => { - StatusCode::NOT_FOUND.into_response() - } + Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("Failed to get telegram config: {}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -67,7 +65,7 @@ pub async fn create_telegram_config( Json(dto): Json, ) -> impl IntoResponse { let repo = TelegramConfigRepository::new(state.db.connection()); - + match repo.create(dto).await { Ok(config) => { // Initialize telegram service with new config if active @@ -76,7 +74,7 @@ pub async fn create_telegram_config( let _ = telegram_service.update_config(config.id).await; } } - + (StatusCode::CREATED, Json(config)).into_response() } Err(e) => { @@ -93,19 +91,17 @@ pub async fn update_telegram_config( Json(dto): Json, ) -> impl IntoResponse { let repo = TelegramConfigRepository::new(state.db.connection()); - + match repo.update(id, dto).await { Ok(Some(config)) => { // Update telegram service if let Some(telegram_service) = &state.telegram_service { let _ = telegram_service.update_config(config.id).await; } - + Json(config).into_response() } - Ok(None) => { - StatusCode::NOT_FOUND.into_response() - } + Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("Failed to update telegram config: {}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -119,7 +115,7 @@ pub async fn delete_telegram_config( Path(id): Path, ) -> impl IntoResponse { let repo = TelegramConfigRepository::new(state.db.connection()); - + // Stop bot if this config is active if let Ok(Some(config)) = repo.find_by_id(id).await { if config.is_active { @@ -128,7 +124,7 @@ pub async fn delete_telegram_config( } } } - + match repo.delete(id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => StatusCode::NOT_FOUND.into_response(), @@ -149,7 +145,7 @@ pub struct BotStatusResponse { async fn get_bot_status(state: &AppState) -> Result { if let Some(telegram_service) = &state.telegram_service { let status = telegram_service.get_status().await; - + let bot_info = if status.is_running { // In production, you would get this from the bot API Some(BotInfo { @@ -159,7 +155,7 @@ async fn get_bot_status(state: &AppState) -> Result { } else { None }; - + Ok(BotStatusResponse { is_running: status.is_running, bot_info, @@ -172,9 +168,7 @@ async fn get_bot_status(state: &AppState) -> Result { } } -pub async fn get_telegram_status( - State(state): State, -) -> impl IntoResponse { +pub async fn get_telegram_status(State(state): State) -> impl IntoResponse { match get_bot_status(&state).await { Ok(status) => Json(status).into_response(), Err(e) => { @@ -192,11 +186,9 @@ pub struct TelegramAdmin { pub telegram_id: Option, } -pub async fn get_telegram_admins( - State(state): State, -) -> impl IntoResponse { +pub async fn get_telegram_admins(State(state): State) -> impl IntoResponse { let repo = UserRepository::new(state.db.connection()); - + match repo.get_telegram_admins().await { Ok(admins) => { let response: Vec = admins @@ -207,7 +199,7 @@ pub async fn get_telegram_admins( telegram_id: u.telegram_id, }) .collect(); - + Json(response).into_response() } Err(e) => { @@ -223,24 +215,24 @@ pub async fn add_telegram_admin( Path(user_id): Path, ) -> impl IntoResponse { let repo = UserRepository::new(state.db.connection()); - + match repo.set_telegram_admin(user_id, true).await { Ok(Some(user)) => { // Notify via Telegram if bot is running if let Some(telegram_service) = &state.telegram_service { if let Some(telegram_id) = user.telegram_id { - let _ = telegram_service.send_message( - telegram_id, - "✅ You have been granted admin privileges!".to_string() - ).await; + let _ = telegram_service + .send_message( + telegram_id, + "✅ You have been granted admin privileges!".to_string(), + ) + .await; } } - + Json(user).into_response() } - Ok(None) => { - StatusCode::NOT_FOUND.into_response() - } + Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("Failed to add telegram admin: {}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -254,24 +246,24 @@ pub async fn remove_telegram_admin( Path(user_id): Path, ) -> impl IntoResponse { let repo = UserRepository::new(state.db.connection()); - + match repo.set_telegram_admin(user_id, false).await { Ok(Some(user)) => { // Notify via Telegram if bot is running if let Some(telegram_service) = &state.telegram_service { if let Some(telegram_id) = user.telegram_id { - let _ = telegram_service.send_message( - telegram_id, - "❌ Your admin privileges have been revoked.".to_string() - ).await; + let _ = telegram_service + .send_message( + telegram_id, + "❌ Your admin privileges have been revoked.".to_string(), + ) + .await; } } - + Json(user).into_response() } - Ok(None) => { - StatusCode::NOT_FOUND.into_response() - } + Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("Failed to remove telegram admin: {}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -301,4 +293,4 @@ pub async fn send_test_message( } else { StatusCode::SERVICE_UNAVAILABLE.into_response() } -} \ No newline at end of file +} diff --git a/src/web/handlers/templates.rs b/src/web/handlers/templates.rs index 26819a1..599bf1c 100644 --- a/src/web/handlers/templates.rs +++ b/src/web/handlers/templates.rs @@ -1,3 +1,7 @@ +use crate::{ + database::{entities::inbound_template, repository::InboundTemplateRepository}, + web::AppState, +}; use axum::{ extract::{Path, State}, http::StatusCode, @@ -5,26 +9,17 @@ use axum::{ Json as JsonExtractor, }; use uuid::Uuid; -use crate::{ - database::{ - entities::inbound_template, - repository::InboundTemplateRepository, - }, - web::AppState, -}; /// List all inbound templates pub async fn list_templates( State(app_state): State, ) -> Result>, StatusCode> { let repo = InboundTemplateRepository::new(app_state.db.connection().clone()); - + match repo.find_all().await { Ok(templates) => { - let responses: Vec = templates - .into_iter() - .map(|t| t.into()) - .collect(); + let responses: Vec = + templates.into_iter().map(|t| t.into()).collect(); Ok(Json(responses)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -37,7 +32,7 @@ pub async fn get_template( Path(id): Path, ) -> Result, StatusCode> { let repo = InboundTemplateRepository::new(app_state.db.connection().clone()); - + match repo.find_by_id(id).await { Ok(Some(template)) => Ok(Json(template.into())), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -52,7 +47,7 @@ pub async fn create_template( ) -> Result, StatusCode> { tracing::info!("Creating template: {:?}", template_data); let repo = InboundTemplateRepository::new(app_state.db.connection().clone()); - + match repo.create(template_data).await { Ok(template) => Ok(Json(template.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -66,7 +61,7 @@ pub async fn update_template( JsonExtractor(template_data): JsonExtractor, ) -> Result, StatusCode> { let repo = InboundTemplateRepository::new(app_state.db.connection().clone()); - + match repo.update(id, template_data).await { Ok(template) => Ok(Json(template.into())), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), @@ -79,10 +74,10 @@ pub async fn delete_template( Path(id): Path, ) -> Result { let repo = InboundTemplateRepository::new(app_state.db.connection().clone()); - + match repo.delete(id).await { Ok(true) => Ok(StatusCode::NO_CONTENT), Ok(false) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } -} \ No newline at end of file +} diff --git a/src/web/handlers/user_requests.rs b/src/web/handlers/user_requests.rs index 9527740..c54618a 100644 --- a/src/web/handlers/user_requests.rs +++ b/src/web/handlers/user_requests.rs @@ -1,16 +1,16 @@ use axum::{ extract::{Path, Query, State}, - Json, http::StatusCode, + Json, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - database::entities::user_request::{CreateUserRequestDto, UpdateUserRequestDto, RequestStatus}, + database::entities::user_request::{CreateUserRequestDto, RequestStatus, UpdateUserRequestDto}, database::repository::UserRequestRepository, + services::telegram::localization::{Language, LocalizationService}, web::AppState, - services::telegram::localization::{LocalizationService, Language}, }; #[derive(Debug, Deserialize)] @@ -23,8 +23,12 @@ pub struct RequestsQuery { status: Option, } -fn default_page() -> u64 { 1 } -fn default_per_page() -> u64 { 20 } +fn default_page() -> u64 { + 1 +} +fn default_per_page() -> u64 { + 20 +} #[derive(Debug, Serialize)] pub struct RequestsResponse { @@ -85,11 +89,20 @@ pub async fn get_requests( let (items, total) = if let Some(status) = query.status { // Filter by status match status.as_str() { - "pending" => request_repo.find_pending(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, - _ => request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + "pending" => request_repo + .find_pending(query.page, query.per_page) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + _ => request_repo + .find_all(query.page, query.per_page) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, } } else { - request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + request_repo + .find_all(query.page, query.per_page) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? }; let items: Vec = items.into_iter().map(Into::into).collect(); @@ -108,7 +121,7 @@ pub async fn get_request( Path(id): Path, ) -> Result, StatusCode> { let request_repo = UserRequestRepository::new(state.db.connection()); - + match request_repo.find_by_id(id).await { Ok(Some(request)) => Ok(Json(UserRequestResponse::from(request))), Ok(None) => Err(StatusCode::NOT_FOUND), @@ -129,19 +142,19 @@ pub async fn approve_request( ) -> Result, StatusCode> { let request_repo = UserRequestRepository::new(state.db.connection()); let user_repo = crate::database::repository::UserRepository::new(state.db.connection()); - + // Get the request let request = match request_repo.find_by_id(id).await { Ok(Some(request)) => request, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Check if already processed if request.status != "pending" { return Err(StatusCode::BAD_REQUEST); } - + // Create user account let username = request.telegram_username.as_deref().unwrap_or("Unknown"); let user_dto = crate::database::entities::user::CreateUserDto { @@ -150,7 +163,7 @@ pub async fn approve_request( telegram_id: Some(request.telegram_id), is_telegram_admin: false, }; - + match user_repo.create(user_dto).await { Ok(new_user) => { // Get the first admin user ID (for web approvals we don't have a specific admin) @@ -162,48 +175,66 @@ pub async fn approve_request( Uuid::new_v4() } }; - + // Approve the request - let approved = match request_repo.approve(id, dto.response_message, admin_id).await { + let approved = match request_repo + .approve(id, dto.response_message, admin_id) + .await + { Ok(Some(approved)) => approved, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Send main menu to the user instead of just notification if let Some(telegram_service) = &state.telegram_service { let user_lang = Language::from_telegram_code(Some(&request.get_language())); let l10n = LocalizationService::new(); - + // Check if user is admin (new users are not admins by default) let is_admin = false; - + // Build main menu keyboard let keyboard = if is_admin { vec![ - vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "my_configs"), "my_configs")], - vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "support"), "support")], - vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "user_requests"), "admin_requests")], + vec![teloxide::types::InlineKeyboardButton::callback( + l10n.get(user_lang.clone(), "my_configs"), + "my_configs", + )], + vec![teloxide::types::InlineKeyboardButton::callback( + l10n.get(user_lang.clone(), "support"), + "support", + )], + vec![teloxide::types::InlineKeyboardButton::callback( + l10n.get(user_lang.clone(), "user_requests"), + "admin_requests", + )], ] } else { vec![ - vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "my_configs"), "my_configs")], - vec![teloxide::types::InlineKeyboardButton::callback(l10n.get(user_lang.clone(), "support"), "support")], + vec![teloxide::types::InlineKeyboardButton::callback( + l10n.get(user_lang.clone(), "my_configs"), + "my_configs", + )], + vec![teloxide::types::InlineKeyboardButton::callback( + l10n.get(user_lang.clone(), "support"), + "support", + )], ] }; - + let keyboard_markup = teloxide::types::InlineKeyboardMarkup::new(keyboard); let message = l10n.format(user_lang, "welcome_back", &[("name", &new_user.name)]); - + // Send message with keyboard - let _ = telegram_service.send_message_with_keyboard(request.telegram_id, message, keyboard_markup).await; + let _ = telegram_service + .send_message_with_keyboard(request.telegram_id, message, keyboard_markup) + .await; } - + Ok(Json(UserRequestResponse::from(approved))) } - Err(_) => { - Err(StatusCode::BAD_REQUEST) - } + Err(_) => Err(StatusCode::BAD_REQUEST), } } @@ -219,19 +250,19 @@ pub async fn decline_request( Json(dto): Json, ) -> Result, StatusCode> { let request_repo = UserRequestRepository::new(state.db.connection()); - + // Get the request let request = match request_repo.find_by_id(id).await { Ok(Some(request)) => request, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Check if already processed if request.status != "pending" { return Err(StatusCode::BAD_REQUEST); } - + // Get the first admin user ID (for web declines we don't have a specific admin) let user_repo = crate::database::repository::UserRepository::new(state.db.connection()); let admin_id = match user_repo.get_first_admin().await { @@ -241,24 +272,29 @@ pub async fn decline_request( Uuid::new_v4() } }; - + // Decline the request - let declined = match request_repo.decline(id, dto.response_message, admin_id).await { + let declined = match request_repo + .decline(id, dto.response_message, admin_id) + .await + { Ok(Some(declined)) => declined, Ok(None) => return Err(StatusCode::NOT_FOUND), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; - + // Send Telegram notification to user if let Some(telegram_service) = &state.telegram_service { let user_lang = Language::from_telegram_code(Some(&request.get_language())); let l10n = LocalizationService::new(); let user_message = l10n.get(user_lang, "request_declined_notification"); - + // Send notification (ignore errors - don't fail the request) - let _ = telegram_service.send_message(request.telegram_id, user_message).await; + let _ = telegram_service + .send_message(request.telegram_id, user_message) + .await; } - + Ok(Json(UserRequestResponse::from(declined))) } @@ -268,10 +304,12 @@ pub async fn delete_request( Path(id): Path, ) -> Result, StatusCode> { let request_repo = UserRequestRepository::new(state.db.connection()); - + match request_repo.delete(id).await { - Ok(true) => Ok(Json(serde_json::json!({ "message": "User request deleted" }))), + Ok(true) => Ok(Json( + serde_json::json!({ "message": "User request deleted" }), + )), Ok(false) => Err(StatusCode::NOT_FOUND), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } -} \ No newline at end of file +} diff --git a/src/web/handlers/users.rs b/src/web/handlers/users.rs index b465dd5..449a5de 100644 --- a/src/web/handlers/users.rs +++ b/src/web/handlers/users.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; -use crate::database::entities::user::{CreateUserDto, UpdateUserDto, Model as UserModel}; +use crate::database::entities::user::{CreateUserDto, Model as UserModel, UpdateUserDto}; use crate::database::repository::UserRepository; use crate::web::AppState; @@ -45,8 +45,12 @@ pub struct UserResponse { pub updated_at: chrono::DateTime, } -fn default_page() -> u64 { 1 } -fn default_per_page() -> u64 { 20 } +fn default_page() -> u64 { + 1 +} +fn default_per_page() -> u64 { + 20 +} impl From for UserResponse { fn from(user: UserModel) -> Self { @@ -67,12 +71,14 @@ pub async fn get_users( Query(query): Query, ) -> Result, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - - let users = repo.get_all(query.page, query.per_page) + + let users = repo + .get_all(query.page, query.per_page) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let total = repo.count() + + let total = repo + .count() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -92,7 +98,7 @@ pub async fn search_users( Query(query): Query, ) -> Result>, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - + let users = if let Some(search_query) = query.q { // Search by name, telegram_id, or UUID repo.search(&search_query) @@ -113,8 +119,9 @@ pub async fn get_user( Path(id): Path, ) -> Result, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - - let user = repo.get_by_id(id) + + let user = repo + .get_by_id(id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -130,19 +137,21 @@ pub async fn create_user( JsonExtractor(dto): JsonExtractor, ) -> Result, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - + // Check if telegram ID is already in use if let Some(telegram_id) = dto.telegram_id { - let exists = repo.telegram_id_exists(telegram_id) + let exists = repo + .telegram_id_exists(telegram_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + if exists { return Err(StatusCode::CONFLICT); } } - let user = repo.create(dto) + let user = repo + .create(dto) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -156,18 +165,22 @@ pub async fn update_user( JsonExtractor(dto): JsonExtractor, ) -> Result, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - + // Check if telegram ID is already in use by another user if let Some(telegram_id) = dto.telegram_id { - if let Some(existing_user) = repo.get_by_telegram_id(telegram_id).await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? { + if let Some(existing_user) = repo + .get_by_telegram_id(telegram_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { if existing_user.id != id { return Err(StatusCode::CONFLICT); } } } - let user = repo.update(id, dto) + let user = repo + .update(id, dto) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -183,8 +196,9 @@ pub async fn delete_user( Path(id): Path, ) -> Result, StatusCode> { let repo = UserRepository::new(app_state.db.connection().clone()); - - let deleted = repo.delete(id) + + let deleted = repo + .delete(id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -203,19 +217,19 @@ pub async fn get_user_access( ) -> Result>, StatusCode> { use crate::database::repository::InboundUsersRepository; use crate::services::UriGeneratorService; - + let inbound_users_repo = InboundUsersRepository::new(app_state.db.connection().clone()); - + let access_list = inbound_users_repo .find_by_user_id(user_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let mut response: Vec = Vec::new(); - + if query.include_uris { let uri_service = UriGeneratorService::new(); - + for access in access_list { let mut access_json = serde_json::json!({ "id": access.id, @@ -225,37 +239,43 @@ pub async fn get_user_access( "level": access.level, "is_active": access.is_active, }); - + // Try to get client config and generate URI if access.is_active { if let Ok(Some(config_data)) = inbound_users_repo .get_client_config_data(user_id, access.server_inbound_id) - .await { - - if let Ok(client_config) = uri_service.generate_client_config(user_id, &config_data) { + .await + { + if let Ok(client_config) = + uri_service.generate_client_config(user_id, &config_data) + { access_json["uri"] = serde_json::Value::String(client_config.uri); access_json["protocol"] = serde_json::Value::String(client_config.protocol); - access_json["server_name"] = serde_json::Value::String(client_config.server_name); - access_json["inbound_tag"] = serde_json::Value::String(client_config.inbound_tag); + access_json["server_name"] = + serde_json::Value::String(client_config.server_name); + access_json["inbound_tag"] = + serde_json::Value::String(client_config.inbound_tag); } } } - + response.push(access_json); } } else { response = access_list .into_iter() - .map(|access| serde_json::json!({ - "id": access.id, - "user_id": access.user_id, - "server_inbound_id": access.server_inbound_id, - "xray_user_id": access.xray_user_id, - "level": access.level, - "is_active": access.is_active, - })) + .map(|access| { + serde_json::json!({ + "id": access.id, + "user_id": access.user_id, + "server_inbound_id": access.server_inbound_id, + "xray_user_id": access.xray_user_id, + "level": access.level, + "is_active": access.is_active, + }) + }) .collect(); } - + Ok(Json(response)) -} \ No newline at end of file +} diff --git a/src/web/mod.rs b/src/web/mod.rs index f153e5a..167d162 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,11 +1,5 @@ use anyhow::Result; -use axum::{ - Router, - routing::get, - http::StatusCode, - response::Json, - serve, -}; +use axum::{http::StatusCode, response::Json, routing::get, serve, Router}; use serde_json::{json, Value}; use std::net::SocketAddr; use tokio::net::TcpListener; @@ -13,10 +7,10 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tracing::info; -use std::sync::Arc; -use crate::config::{WebConfig, AppConfig}; +use crate::config::{AppConfig, WebConfig}; use crate::database::DatabaseManager; -use crate::services::{XrayService, TelegramService}; +use crate::services::{TelegramService, XrayService}; +use std::sync::Arc; pub mod handlers; pub mod routes; @@ -33,9 +27,13 @@ pub struct AppState { } /// Start the web server -pub async fn start_server(db: DatabaseManager, config: AppConfig, telegram_service: Option>) -> Result<()> { +pub async fn start_server( + db: DatabaseManager, + config: AppConfig, + telegram_service: Option>, +) -> Result<()> { let xray_service = XrayService::new(); - + let app_state = AppState { db, config: config.clone(), @@ -70,4 +68,4 @@ async fn health_check() -> Result, StatusCode> { "service": "xray-admin", "version": env!("CARGO_PKG_VERSION") }))) -} \ No newline at end of file +} diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index a5f8a50..78a05df 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -1,9 +1,9 @@ use axum::{ + routing::{delete, get, post, put}, Router, - routing::{get, post, put, delete}, }; -use crate::web::{AppState, handlers}; +use crate::web::{handlers, AppState}; pub mod servers; @@ -25,22 +25,37 @@ fn user_routes() -> Router { Router::new() .route("/", get(handlers::get_users).post(handlers::create_user)) .route("/search", get(handlers::search_users)) - .route("/:id", get(handlers::get_user) - .put(handlers::update_user) - .delete(handlers::delete_user)) + .route( + "/:id", + get(handlers::get_user) + .put(handlers::update_user) + .delete(handlers::delete_user), + ) .route("/:id/access", get(handlers::get_user_access)) .route("/:user_id/configs", get(handlers::get_user_configs)) - .route("/:user_id/access/:inbound_id/config", get(handlers::get_user_inbound_config)) + .route( + "/:user_id/access/:inbound_id/config", + get(handlers::get_user_inbound_config), + ) } /// DNS Provider management routes fn dns_provider_routes() -> Router { Router::new() - .route("/", get(handlers::list_dns_providers).post(handlers::create_dns_provider)) - .route("/:id", get(handlers::get_dns_provider) - .put(handlers::update_dns_provider) - .delete(handlers::delete_dns_provider)) - .route("/cloudflare/active", get(handlers::list_active_cloudflare_providers)) + .route( + "/", + get(handlers::list_dns_providers).post(handlers::create_dns_provider), + ) + .route( + "/:id", + get(handlers::get_dns_provider) + .put(handlers::update_dns_provider) + .delete(handlers::delete_dns_provider), + ) + .route( + "/cloudflare/active", + get(handlers::list_active_cloudflare_providers), + ) } /// Task management routes @@ -53,17 +68,22 @@ fn task_routes() -> Router { /// Telegram bot management routes fn telegram_routes() -> Router { Router::new() - .route("/config", get(handlers::get_telegram_config) - .post(handlers::create_telegram_config)) - .route("/config/:id", + .route( + "/config", + get(handlers::get_telegram_config).post(handlers::create_telegram_config), + ) + .route( + "/config/:id", get(handlers::get_telegram_config) - .put(handlers::update_telegram_config) - .delete(handlers::delete_telegram_config)) + .put(handlers::update_telegram_config) + .delete(handlers::delete_telegram_config), + ) .route("/status", get(handlers::get_telegram_status)) .route("/admins", get(handlers::get_telegram_admins)) - .route("/admins/:user_id", - post(handlers::add_telegram_admin) - .delete(handlers::remove_telegram_admin)) + .route( + "/admins/:user_id", + post(handlers::add_telegram_admin).delete(handlers::remove_telegram_admin), + ) .route("/send", post(handlers::send_test_message)) } @@ -71,7 +91,10 @@ fn telegram_routes() -> Router { fn user_request_routes() -> Router { Router::new() .route("/", get(handlers::get_requests)) - .route("/:id", get(handlers::get_request).delete(handlers::delete_request)) + .route( + "/:id", + get(handlers::get_request).delete(handlers::delete_request), + ) .route("/:id/approve", post(handlers::approve_request)) .route("/:id/decline", post(handlers::decline_request)) -} \ No newline at end of file +} diff --git a/src/web/routes/servers.rs b/src/web/routes/servers.rs index 925dbb8..e2f9318 100644 --- a/src/web/routes/servers.rs +++ b/src/web/routes/servers.rs @@ -1,41 +1,77 @@ +use crate::web::{handlers, AppState}; use axum::{ routing::{get, post}, Router, }; -use crate::{ - web::{AppState, handlers}, -}; pub fn server_routes() -> Router { Router::new() // Server management - .route("/", get(handlers::list_servers).post(handlers::create_server)) - .route("/:id", get(handlers::get_server).put(handlers::update_server).delete(handlers::delete_server)) + .route( + "/", + get(handlers::list_servers).post(handlers::create_server), + ) + .route( + "/:id", + get(handlers::get_server) + .put(handlers::update_server) + .delete(handlers::delete_server), + ) .route("/:id/test", post(handlers::test_server_connection)) .route("/:id/stats", get(handlers::get_server_stats)) - // Server inbounds - .route("/:server_id/inbounds", get(handlers::list_server_inbounds).post(handlers::create_server_inbound)) - .route("/:server_id/inbounds/:inbound_id", get(handlers::get_server_inbound).put(handlers::update_server_inbound).delete(handlers::delete_server_inbound)) - + .route( + "/:server_id/inbounds", + get(handlers::list_server_inbounds).post(handlers::create_server_inbound), + ) + .route( + "/:server_id/inbounds/:inbound_id", + get(handlers::get_server_inbound) + .put(handlers::update_server_inbound) + .delete(handlers::delete_server_inbound), + ) // User management for inbounds - .route("/:server_id/inbounds/:inbound_id/users", post(handlers::add_user_to_inbound)) - .route("/:server_id/inbounds/:inbound_id/users/:email", axum::routing::delete(handlers::remove_user_from_inbound)) - + .route( + "/:server_id/inbounds/:inbound_id/users", + post(handlers::add_user_to_inbound), + ) + .route( + "/:server_id/inbounds/:inbound_id/users/:email", + axum::routing::delete(handlers::remove_user_from_inbound), + ) // Client configurations for inbounds - .route("/:server_id/inbounds/:inbound_id/configs", get(handlers::get_inbound_configs)) + .route( + "/:server_id/inbounds/:inbound_id/configs", + get(handlers::get_inbound_configs), + ) } pub fn certificate_routes() -> Router { Router::new() - .route("/", get(handlers::list_certificates).post(handlers::create_certificate)) - .route("/:id", get(handlers::get_certificate).put(handlers::update_certificate).delete(handlers::delete_certificate)) + .route( + "/", + get(handlers::list_certificates).post(handlers::create_certificate), + ) + .route( + "/:id", + get(handlers::get_certificate) + .put(handlers::update_certificate) + .delete(handlers::delete_certificate), + ) .route("/:id/details", get(handlers::get_certificate_details)) .route("/expiring", get(handlers::get_expiring_certificates)) } pub fn template_routes() -> Router { Router::new() - .route("/", get(handlers::list_templates).post(handlers::create_template)) - .route("/:id", get(handlers::get_template).put(handlers::update_template).delete(handlers::delete_template)) -} \ No newline at end of file + .route( + "/", + get(handlers::list_templates).post(handlers::create_template), + ) + .route( + "/:id", + get(handlers::get_template) + .put(handlers::update_template) + .delete(handlers::delete_template), + ) +}