Made subs

This commit is contained in:
AB from home.homenet
2025-10-19 05:06:38 +03:00
parent d972f10f83
commit d80ac56b83
11 changed files with 585 additions and 50 deletions

169
API.md
View File

@@ -19,6 +19,29 @@ Complete API documentation for OutFleet - a web admin panel for managing xray-co
}
```
### User Subscription
- `GET /sub/{user_id}` - Get all user configuration links (subscription endpoint)
**Description:** Returns all VPN configuration links for a specific user, one per line. This endpoint is designed for VPN clients that support subscription URLs for automatic configuration updates.
**Path Parameters:**
- `user_id` (UUID) - The user's unique identifier
**Response:**
- **Content-Type:** `text/plain; charset=utf-8`
- **Success (200):** Plain text with configuration URIs, one per line
- **Not Found (404):** User doesn't exist
- **No Content:** Returns comment if no configurations available
**Example Response:**
```
vmess://eyJ2IjoiMiIsInBzIjoiU2VydmVyMSIsImFkZCI6IjEyNy4wLjAuMSIsInBvcnQiOiI0NDMiLCJpZCI6IjEyMzQ1Njc4LTEyMzQtNTY3OC05YWJjLTEyMzQ1Njc4OWFiYyIsImFpZCI6IjAiLCJzY3kiOiJhdXRvIiwibmV0IjoidGNwIiwidHlwZSI6Im5vbmUiLCJob3N0IjoiIiwicGF0aCI6IiIsInRscyI6InRscyIsInNuaSI6IiJ9
vless://uuid@hostname:port?encryption=none&security=tls&type=tcp&headerType=none#ServerName
ss://YWVzLTI1Ni1nY21AcGFzc3dvcmQ6MTI3LjAuMC4xOjgwODA=#Server2
```
**Usage:** This endpoint is intended for VPN client applications that support subscription URLs. Users can add this URL to their VPN client to automatically receive all their configurations and get updates when configurations change.
## API Endpoints
All API endpoints are prefixed with `/api`.
@@ -504,4 +527,148 @@ All API endpoints are prefixed with `/api`.
"code": "ERROR_CODE",
"details": "Additional error details"
}
```
```
## Telegram Bot Integration
OutFleet includes a Telegram bot for user management and configuration access.
### User Management Endpoints
#### List User Requests
- `GET /api/user-requests` - Get all user access requests
- `GET /api/user-requests?status=pending` - Get pending requests only
**Response:**
```json
{
"items": [
{
"id": "uuid",
"user_id": "uuid|null",
"telegram_id": 123456789,
"telegram_username": "username",
"telegram_first_name": "John",
"telegram_last_name": "Doe",
"full_name": "John Doe",
"telegram_link": "@username",
"status": "pending|approved|declined",
"request_message": "Access request message",
"response_message": "Admin response",
"processed_by_user_id": "uuid|null",
"processed_at": "timestamp|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
],
"total": 50,
"page": 1,
"per_page": 20
}
```
#### Get User Request
- `GET /api/user-requests/{id}` - Get specific user request
#### Approve User Request
- `POST /api/user-requests/{id}/approve` - Approve user access request
**Request:**
```json
{
"response_message": "Welcome! Your access has been approved."
}
```
**Response:** Updated user request object
**Side Effects:**
- Creates a new user account
- Sends Telegram notification with main menu to the user
#### Decline User Request
- `POST /api/user-requests/{id}/decline` - Decline user access request
**Request:**
```json
{
"response_message": "Sorry, your request has been declined."
}
```
**Response:** Updated user request object
**Side Effects:**
- Sends Telegram notification to the user
#### Delete User Request
- `DELETE /api/user-requests/{id}` - Delete user request
### Telegram Bot Configuration
#### Get Telegram Status
- `GET /api/telegram/status` - Get bot status and configuration
**Response:**
```json
{
"is_running": true,
"config": {
"id": "uuid",
"name": "Bot Name",
"bot_token": "masked",
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
}
```
#### Create/Update Telegram Config
- `POST /api/telegram/config` - Create new bot configuration
- `PUT /api/telegram/config/{id}` - Update bot configuration
**Request:**
```json
{
"name": "OutFleet Bot",
"bot_token": "bot_token_from_botfather",
"is_active": true
}
```
#### Telegram Admin Management
- `GET /api/telegram/admins` - Get all Telegram admins
- `POST /api/telegram/admins/{user_id}` - Add user as Telegram admin
- `DELETE /api/telegram/admins/{user_id}` - Remove user from Telegram admins
### Telegram Bot Features
#### User Flow
1. **Request Access**: Users send `/start` to the bot and request VPN access
2. **Admin Approval**: Admins receive notifications and can approve/decline via Telegram or web interface
3. **Configuration Access**: Approved users get access to:
- **🔗 Subscription Link**: Personal subscription URL (`/sub/{user_id}`)
- **⚙️ My Configs**: Individual configuration management
- **💬 Support**: Contact support
#### Admin Features
- **📋 User Requests**: View and manage pending access requests
- **📊 Statistics**: View system statistics
- **📢 Broadcast**: Send messages to all users
- **Approval Workflow**: Approve/decline requests with server selection
#### Subscription Link Integration
When users click "🔗 Subscription Link" in the Telegram bot, they receive:
- Personal subscription URL: `{BASE_URL}/sub/{user_id}`
- Instructions in their preferred language (Russian/English)
- Automatic updates when configurations change
**Environment Variables:**
- `BASE_URL` - Base URL for subscription links (default: `http://localhost:8080`)
### Bot Commands
- `/start` - Start bot and show main menu
- `/requests` - [Admin] View pending user requests
- `/stats` - [Admin] Show system statistics
- `/broadcast <message>` - [Admin] Send message to all users

View File

@@ -168,6 +168,16 @@ impl UserRepository {
.await?;
Ok(admins)
}
/// Get the first admin user (for system operations)
pub async fn get_first_admin(&self) -> Result<Option<Model>> {
let admin = User::find()
.filter(Column::IsTelegramAdmin.eq(true))
.one(&self.db)
.await?;
Ok(admin)
}
}
#[cfg(test)]

View File

@@ -5,7 +5,8 @@ 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;
use super::types::{get_selected_servers, generate_short_request_id, get_full_request_id, generate_short_server_id, get_full_server_id};
use super::user::handle_start;
/// Handle admin requests edit (show list of recent requests)
pub async fn handle_admin_requests_edit(
@@ -168,12 +169,14 @@ pub async fn handle_approve_request(
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
if let Some(text) = msg.text() {
let updated_text = format!("{}\n\n✅ <b>APPROVED</b> by {}\n\n📋 Select servers to grant access:", text, admin.name);
let request_id_compact = request_id.to_string().replace("-", "");
let callback_data = format!("s:{}", request_id_compact);
tracing::info!("Callback data length: {} bytes, data: '{}'", callback_data.len(), callback_data);
// 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());
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)
@@ -184,13 +187,32 @@ pub async fn handle_approve_request(
}
}
// Notify the user using their saved language preference
// Send main menu to the user instead of just notification
let user_lang = Language::from_telegram_code(Some(&request.get_language()));
let user_message = l10n.format(user_lang, "request_approved_notification", &[("user_id", &new_user.id.to_string())]);
let user_repo_for_user = UserRepository::new(db.connection());
let is_admin = false; // New users are not admins by default
bot.send_message(ChatId(request.telegram_id), user_message)
.parse_mode(teloxide::types::ParseMode::Html)
.await?;
// Create a fake user object for language detection
let fake_user = teloxide::types::User {
id: teloxide::types::UserId(request.telegram_id as u64),
is_bot: false,
first_name: request.telegram_first_name.clone().unwrap_or_default(),
last_name: request.telegram_last_name.clone(),
username: request.telegram_username.clone(),
language_code: Some(request.get_language()),
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,
&fake_user,
&user_repo_for_user,
db
).await?;
bot.answer_callback_query(q.id.clone())
.text(l10n.get(lang, "request_approved_admin"))
@@ -457,7 +479,7 @@ pub async fn handle_broadcast(
pub async fn handle_select_server_access(
bot: Bot,
q: &CallbackQuery,
request_id: &str,
short_request_id: &str,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let lang = Language::English; // Default admin language
@@ -481,17 +503,23 @@ pub async fn handle_select_server_access(
return Ok(());
}
// 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);
// Initialize selected servers for this request (empty initially)
{
let mut selected = get_selected_servers().lock().unwrap();
selected.insert(request_id.to_string(), Vec::new());
selected.insert(request_id.clone(), Vec::new());
}
// Build keyboard with server toggle buttons
let mut keyboard_buttons = vec![];
let selected_servers = {
let selected = get_selected_servers().lock().unwrap();
selected.get(request_id).cloned().unwrap_or_default()
selected.get(&request_id).cloned().unwrap_or_default()
};
for server in &servers {
@@ -502,17 +530,18 @@ pub async fn handle_select_server_access(
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,
format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", ""))
)
InlineKeyboardButton::callback(button_text, callback_data)
]);
}
// Add apply and back buttons
keyboard_buttons.push(vec![
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))),
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", short_request_id)),
InlineKeyboardButton::callback("🔙 Back", "back_to_requests"),
]);
@@ -536,8 +565,8 @@ pub async fn handle_select_server_access(
pub async fn handle_toggle_server(
bot: Bot,
q: &CallbackQuery,
request_id: &str,
server_id: &str,
short_request_id: &str,
short_server_id: &str,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chat_id = q.message.as_ref().and_then(|m| {
@@ -547,15 +576,23 @@ pub async fn handle_toggle_server(
}
}).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")?;
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.to_string()).or_insert_with(Vec::new);
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) {
if let Some(pos) = server_list.iter().position(|x| x == &server_id) {
server_list.remove(pos);
} else {
server_list.push(server_id.to_string());
server_list.push(server_id.clone());
}
}
@@ -566,7 +603,7 @@ pub async fn handle_toggle_server(
let mut keyboard_buttons = vec![];
let selected_servers = {
let selected = get_selected_servers().lock().unwrap();
selected.get(request_id).cloned().unwrap_or_default()
selected.get(&request_id).cloned().unwrap_or_default()
};
for server in &servers {
@@ -577,17 +614,17 @@ pub async fn handle_toggle_server(
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,
format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", ""))
)
InlineKeyboardButton::callback(button_text, callback_data)
]);
}
// Add apply and back buttons
keyboard_buttons.push(vec![
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))),
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", short_request_id)),
InlineKeyboardButton::callback("🔙 Back", "back_to_requests"),
]);
@@ -613,7 +650,7 @@ pub async fn handle_toggle_server(
pub async fn handle_apply_server_access(
bot: Bot,
q: &CallbackQuery,
request_id: &str,
short_request_id: &str,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let lang = Language::English; // Default admin language
@@ -625,10 +662,14 @@ pub async fn handle_apply_server_access(
}
}).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")?;
// Get selected servers
let selected_server_ids = {
let selected = get_selected_servers().lock().unwrap();
selected.get(request_id).cloned().unwrap_or_default()
selected.get(&request_id).cloned().unwrap_or_default()
};
if selected_server_ids.is_empty() {
@@ -645,7 +686,7 @@ pub async fn handle_apply_server_access(
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_uuid = Uuid::parse_str(&request_id).map_err(|_| "Invalid request ID")?;
let request = request_repo.find_by_id(request_uuid).await
.unwrap_or(None)
.ok_or("Request not found")?;
@@ -690,7 +731,7 @@ pub async fn handle_apply_server_access(
// Clean up selected servers storage
{
let mut selected = get_selected_servers().lock().unwrap();
selected.remove(request_id);
selected.remove(&request_id);
}
// Update message with success

View File

@@ -105,6 +105,9 @@ pub async fn handle_callback_query(
CallbackData::MyConfigs => {
handle_my_configs_edit(bot, &q, &db).await?;
}
CallbackData::SubscriptionLink => {
handle_subscription_link(bot, &q, &db).await?;
}
CallbackData::Support => {
handle_support(bot, &q).await?;
}
@@ -124,13 +127,20 @@ pub async fn handle_callback_query(
handle_show_server_configs(bot, &q, &encoded_server_name, &db).await?;
}
CallbackData::SelectServerAccess(request_id) => {
handle_select_server_access(bot, &q, &request_id, &db).await?;
// 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) => {
handle_toggle_server(bot, &q, &request_id, &server_id, &db).await?;
// 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) => {
handle_apply_server_access(bot, &q, &request_id, &db).await?;
// 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

View File

@@ -24,6 +24,7 @@ pub enum Command {
pub enum CallbackData {
RequestAccess,
MyConfigs,
SubscriptionLink,
Support,
AdminRequests,
ApproveRequest(String), // request_id
@@ -43,6 +44,7 @@ impl CallbackData {
match data {
"request_access" => Some(CallbackData::RequestAccess),
"my_configs" => Some(CallbackData::MyConfigs),
"subscription_link" => Some(CallbackData::SubscriptionLink),
"support" => Some(CallbackData::Support),
"admin_requests" => Some(CallbackData::AdminRequests),
"back" => Some(CallbackData::Back),
@@ -57,12 +59,12 @@ impl CallbackData {
Some(CallbackData::ViewRequest(id.to_string()))
} else if let Some(server_name) = data.strip_prefix("server_configs:") {
Some(CallbackData::ShowServerConfigs(server_name.to_string()))
} else if let Some(id) = data.strip_prefix("s:") {
restore_uuid(id).map(CallbackData::SelectServerAccess)
} else if let Some(short_id) = data.strip_prefix("s:") {
get_full_request_id(short_id).map(CallbackData::SelectServerAccess)
} 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)) = (restore_uuid(parts[0]), restore_uuid(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
@@ -70,8 +72,8 @@ impl CallbackData {
} else {
None
}
} else if let Some(id) = data.strip_prefix("a:") {
restore_uuid(id).map(CallbackData::ApplyServerAccess)
} else if let Some(short_id) = data.strip_prefix("a:") {
get_full_request_id(short_id).map(CallbackData::ApplyServerAccess)
} else {
None
}
@@ -83,10 +85,86 @@ impl CallbackData {
// Global storage for selected servers per request
static SELECTED_SERVERS: OnceLock<Arc<Mutex<HashMap<String, Vec<String>>>>> = OnceLock::new();
// Global storage for request ID mappings (short ID -> full UUID)
static REQUEST_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static REQUEST_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
// Global storage for server ID mappings (short ID -> full UUID)
static SERVER_ID_MAP: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
static SERVER_COUNTER: OnceLock<Arc<Mutex<u32>>> = OnceLock::new();
pub fn get_selected_servers() -> &'static Arc<Mutex<HashMap<String, Vec<String>>>> {
SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_request_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
REQUEST_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_request_counter() -> &'static Arc<Mutex<u32>> {
REQUEST_COUNTER.get_or_init(|| Arc::new(Mutex::new(0)))
}
pub fn get_server_id_map() -> &'static Arc<Mutex<HashMap<String, String>>> {
SERVER_ID_MAP.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
pub fn get_server_counter() -> &'static Arc<Mutex<u32>> {
SERVER_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
}
/// Get full UUID from short ID
pub fn get_full_request_id(short_id: &str) -> Option<String> {
let map = get_request_id_map().lock().unwrap();
map.get(short_id).cloned()
}
/// Generate a short ID for a server UUID and store the mapping
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
}
/// Get full server UUID from short ID
pub fn get_full_server_id(short_id: &str) -> Option<String> {
let map = get_server_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())
@@ -97,6 +175,7 @@ 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")],
];

View File

@@ -665,5 +665,70 @@ async fn notify_admins_new_request(
}
}
Ok(())
}
/// Handle subscription link request
pub async fn handle_subscription_link(
bot: Bot,
q: &CallbackQuery,
db: &DatabaseManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = q.from.clone();
let telegram_id = from.id.0 as i64;
let lang = get_user_language(&from);
let l10n = LocalizationService::new();
// Get user from database
let user_repo = UserRepository::new(db.connection());
if let Ok(Some(user)) = user_repo.get_by_telegram_id(telegram_id).await {
// Generate subscription URL
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let subscription_url = format!("{}/sub/{}", base_url, user.id);
let message = match lang {
Language::Russian => {
format!(
"🔗 <b>Ваша ссылка подписки</b>\n\n\
Скопируйте эту ссылку и добавьте её в ваш VPN-клиент:\n\n\
<code>{}</code>\n\n\
💡 <i>Эта ссылка содержит все ваши конфигурации и автоматически обновляется при изменениях</i>",
subscription_url
)
},
Language::English => {
format!(
"🔗 <b>Your Subscription Link</b>\n\n\
Copy this link and add it to your VPN client:\n\n\
<code>{}</code>\n\n\
💡 <i>This link contains all your configurations and updates automatically when changes are made</i>",
subscription_url
)
}
};
let keyboard = InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")],
]);
// Edit the existing message
if let Some(msg) = &q.message {
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
let chat_id = regular_msg.chat.id;
bot.edit_message_text(chat_id, regular_msg.id, message)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_markup(keyboard)
.await?;
}
}
} else {
// User not found - this shouldn't happen for registered users
bot.answer_callback_query(q.id.clone())
.text("User not found")
.await?;
return Ok(());
}
bot.answer_callback_query(q.id.clone()).await?;
Ok(())
}

View File

@@ -144,6 +144,21 @@ 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<()> {
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)
.reply_markup(keyboard)
.await?;
Ok(())
} else {
Err(anyhow::anyhow!("Bot is not running"))
}
}
/// Send message to all admins
pub async fn broadcast_to_admins(&self, text: String) -> Result<()> {

View File

@@ -7,6 +7,7 @@ pub mod dns_providers;
pub mod tasks;
pub mod telegram;
pub mod user_requests;
pub mod subscription;
pub use users::*;
pub use servers::*;
@@ -16,4 +17,5 @@ pub use client_configs::*;
pub use dns_providers::*;
pub use tasks::*;
pub use telegram::*;
pub use user_requests::*;
pub use user_requests::*;
pub use subscription::*;

View File

@@ -0,0 +1,92 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use uuid::Uuid;
use crate::{
database::repository::{UserRepository, InboundUsersRepository},
services::uri_generator::UriGeneratorService,
web::AppState,
};
/// Get subscription links for a user by their ID
/// Returns all configuration links for the user, one per line
pub async fn get_user_subscription(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<Response, StatusCode> {
let user_repo = UserRepository::new(state.db.connection());
let inbound_users_repo = InboundUsersRepository::new(state.db.connection().clone());
let uri_generator = UriGeneratorService::new();
// 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 inbound accesses for this user
let user_inbounds = match inbound_users_repo.find_by_user_id(user_id).await {
Ok(inbounds) => inbounds,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if user_inbounds.is_empty() {
let response = "# No configurations available\n".to_string();
return Ok((
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
response,
).into_response());
}
let mut config_lines = Vec::new();
// Generate URI for each inbound access
for user_inbound in user_inbounds {
// Get client configuration data using the existing repository method
match inbound_users_repo.get_client_config_data(user_id, user_inbound.server_inbound_id).await {
Ok(Some(config_data)) => {
// Generate URI
match uri_generator.generate_client_config(user_id, &config_data) {
Ok(client_config) => {
config_lines.push(client_config.uri);
}
Err(e) => {
tracing::warn!("Failed to generate URI for user {} inbound {}: {}", user_id, user_inbound.server_inbound_id, e);
continue;
}
}
}
Ok(None) => {
tracing::debug!("No config data found for user {} inbound {}", user_id, user_inbound.server_inbound_id);
continue;
}
Err(e) => {
tracing::warn!("Failed to get config data for user {} inbound {}: {}", user_id, user_inbound.server_inbound_id, e);
continue;
}
}
}
if config_lines.is_empty() {
let response = "# No valid configurations available\n".to_string();
return Ok((
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
response,
).into_response());
}
// Join all URIs with newlines
let response = config_lines.join("\n") + "\n";
Ok((
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
response,
).into_response())
}

View File

@@ -10,6 +10,7 @@ use crate::{
database::entities::user_request::{CreateUserRequestDto, UpdateUserRequestDto, RequestStatus},
database::repository::UserRequestRepository,
web::AppState,
services::telegram::localization::{LocalizationService, Language},
};
#[derive(Debug, Deserialize)]
@@ -152,14 +153,51 @@ pub async fn approve_request(
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)
// In a real application, this would come from the authenticated session
let admin_id = match user_repo.get_first_admin().await {
Ok(Some(admin)) => admin.id,
_ => {
// Use a default ID if no admin found
Uuid::new_v4()
}
};
// Approve the request
let approved = match request_repo.approve(id, dto.response_message, new_user.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),
};
// TODO: Send Telegram notification to user
// 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")],
]
} 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")],
]
};
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;
}
Ok(Json(UserRequestResponse::from(approved)))
}
@@ -194,17 +232,32 @@ pub async fn decline_request(
return Err(StatusCode::BAD_REQUEST);
}
// Use a default user ID for declined requests (we can set it to the first admin user)
let dummy_user_id = Uuid::new_v4();
// 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 {
Ok(Some(admin)) => admin.id,
_ => {
// Use a default ID if no admin found
Uuid::new_v4()
}
};
// Decline the request
let declined = match request_repo.decline(id, dto.response_message, dummy_user_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),
};
// TODO: Send Telegram notification to user
// 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;
}
Ok(Json(UserRequestResponse::from(declined)))
}

View File

@@ -49,6 +49,7 @@ pub async fn start_server(db: DatabaseManager, config: WebConfig, telegram_servi
let app = Router::new()
.route("/health", get(health_check))
.route("/sub/:user_id", get(handlers::get_user_subscription))
.nest("/api", api_routes())
.nest_service("/", serve_dir)
.layer(CorsLayer::permissive())