use crate::error::{AppError, Result}; use crate::identity::Identity; use crate::menu::{MenuItem, MenuRoot}; use reqwest::Client; use serde_path_to_error as path_to_error; pub async fn fetch_menu(server_url: &str, timeout_sec: u64, identity: Option<&Identity>) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(timeout_sec)) .build()?; let mut req = client.get(server_url); if let Some(id) = identity { let timestamp = unix_now(); let path = url_path(server_url); req = req .header("X-Public-Key", id.public_key_b64()) .header("X-Timestamp", timestamp.to_string()) .header("X-Signature", id.sign("GET", &path, timestamp)); } let resp = req.send().await?; if resp.status() == reqwest::StatusCode::FORBIDDEN { return Err(parse_access_denied(resp).await); } let text = resp.text().await?; parse_menu_body(&text) } /// Sends an enrollment request to `{server_url}/access-request`. /// The server stores the public key + source IP for admin approval. /// Returns a human-readable status string on success. pub async fn request_access(server_url: &str, timeout_sec: u64, identity: &Identity) -> Result { let access_url = enrollment_url(server_url); let client = Client::builder() .timeout(std::time::Duration::from_secs(timeout_sec)) .build()?; let resp = client .post(&access_url) .json(&serde_json::json!({ "public_key": identity.public_key_b64(), "hostname": crate::identity::current_hostname(), })) .send() .await?; let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let json_status = serde_json::from_str::(&body) .ok() .and_then(|v| v["status"].as_str().map(|s| s.to_string())); if status == reqwest::StatusCode::FORBIDDEN { let msg = match json_status.as_deref() { Some("banned") => "Ваш IP заблокирован администратором.".to_string(), _ => format!("Сервер вернул {}: {}", status, body), }; return Ok(msg); } if !status.is_success() { return Err(AppError::Config(format!("Сервер вернул {}: {}", status, body))); } let msg = match json_status.as_deref() { Some("pending") => "Запрос отправлен. Ожидайте одобрения администратора.".to_string(), Some("already_pending") => "Запрос уже был отправлен ранее. Ожидайте одобрения.".to_string(), Some("already_authorized") => "Ключ уже авторизован. Нажмите R для обновления меню.".to_string(), _ => format!("Ответ сервера: {}", body), }; Ok(msg) } // ── Helpers ────────────────────────────────────────────────────────────────── fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() } /// Builds the enrollment URL: `access-request.php` in the same directory as the menu URL. /// `https://host/api/menu/index.php` → `https://host/api/menu/access-request.php` /// `https://host/api/menu/` → `https://host/api/menu/access-request.php` fn enrollment_url(server_url: &str) -> String { reqwest::Url::parse(server_url) .map(|mut u| { let path = u.path().to_string(); let trimmed = path.trim_end_matches('/'); // If the last path segment looks like a file (contains '.'), use its parent dir. let dir = match trimmed.rfind('/') { Some(pos) if trimmed[pos + 1..].contains('.') => &trimmed[..pos], _ => trimmed, }; u.set_path(&format!("{}/access-request.php", dir)); u.set_query(None); u.to_string() }) .unwrap_or_else(|_| format!("{}/access-request.php", server_url.trim_end_matches('/'))) } /// Extracts just the path component from a URL string. fn url_path(url: &str) -> String { reqwest::Url::parse(url) .map(|u| u.path().to_string()) .unwrap_or_else(|_| "/".to_string()) } /// Returns `AppError::AccessDenied` when the 403 body contains `{"error":"access_denied"}`, /// otherwise a generic config error. async fn parse_access_denied(resp: reqwest::Response) -> AppError { #[derive(serde::Deserialize)] struct ErrBody { error: String } if let Ok(body) = resp.json::().await { if body.error == "access_denied" { return AppError::AccessDenied; } } AppError::Config("Сервер вернул 403 Forbidden".to_string()) } fn parse_menu_body(text: &str) -> Result { let mut de = serde_json::Deserializer::from_str(text); match path_to_error::deserialize(&mut de) { Ok(root) => Ok(root), Err(e) => match serde_json::from_str::>(text) { Ok(items) => Ok(MenuRoot { version: "1.0".to_string(), menu: items }), Err(_) => Err(AppError::Json(e.into_inner())), }, } }