11
This commit is contained in:
95
src-tauri/src/api_client.rs
Normal file
95
src-tauri/src/api_client.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! 经 Rust 发起的 `/api/v1` HTTP 请求(可选 AES 加解密)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api_crypto::{self, ENCRYPTED_HEADER};
|
||||
use crate::app_config;
|
||||
|
||||
pub async fn request_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
extra_headers: HashMap<String, String>,
|
||||
) -> Result<(u16, Value), String> {
|
||||
let path = if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
};
|
||||
let base = app_config::api_base().trim_end_matches('/').to_string();
|
||||
let url = format!("{base}/api/v1{path}");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let method = reqwest::Method::from_bytes(method.to_uppercase().as_bytes())
|
||||
.map_err(|e| format!("无效 HTTP 方法: {e}"))?;
|
||||
|
||||
let aes_key = api_crypto::aes_key_from_env();
|
||||
let mut req = client.request(method, &url);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-type"),
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
for (k, v) in extra_headers {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
HeaderName::from_bytes(k.as_bytes()),
|
||||
HeaderValue::from_str(&v),
|
||||
) {
|
||||
headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(key) = aes_key {
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-aiclient-encrypted"),
|
||||
HeaderValue::from_static("1"),
|
||||
);
|
||||
if let Some(raw) = body.filter(|s| !s.is_empty()) {
|
||||
let wire = api_crypto::wrap_encrypted(raw.as_bytes(), &key)?;
|
||||
req = req.body(wire);
|
||||
}
|
||||
} else if let Some(raw) = body {
|
||||
req = req.body(raw.to_string());
|
||||
}
|
||||
|
||||
req = req.headers(headers);
|
||||
|
||||
let res = req.send().await.map_err(|e| {
|
||||
if e.is_connect() {
|
||||
"无法连接服务器,请确认后端已启动".to_string()
|
||||
} else {
|
||||
e.to_string()
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = res.status().as_u16();
|
||||
let encrypted = res
|
||||
.headers()
|
||||
.get(ENCRYPTED_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
== Some("1");
|
||||
|
||||
let text = res.text().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let json_text = if encrypted {
|
||||
let key = aes_key.ok_or_else(|| "响应已加密但未配置 AICLIENT_API_AES_KEY".to_string())?;
|
||||
let plain = api_crypto::unwrap_encrypted(&text, &key)?;
|
||||
String::from_utf8(plain).map_err(|e| format!("响应 UTF-8 无效: {e}"))?
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
let value: Value =
|
||||
serde_json::from_str(&json_text).unwrap_or(Value::String(json_text.clone()));
|
||||
|
||||
Ok((status, value))
|
||||
}
|
||||
92
src-tauri/src/api_crypto.rs
Normal file
92
src-tauri/src/api_crypto.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! AES-256-GCM,与 pythonbackend `app/core/aes_crypto.py` 一致。
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
|
||||
pub const ENCRYPTED_HEADER: &str = "X-AICLIENT-Encrypted";
|
||||
const ENVELOPE_VERSION: i64 = 1;
|
||||
|
||||
pub fn parse_aes_key(raw: &str) -> Option<[u8; 32]> {
|
||||
let s = raw.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
hex::decode(s).ok()?
|
||||
} else {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(s)
|
||||
.ok()?
|
||||
};
|
||||
if bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Some(key)
|
||||
}
|
||||
|
||||
pub fn aes_key_from_env() -> Option<[u8; 32]> {
|
||||
let s="98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4";
|
||||
parse_aes_key(&s)
|
||||
}
|
||||
|
||||
pub fn encrypt_bytes(plaintext: &[u8], key: &[u8; 32]) -> Result<String, String> {
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| format!("加密失败: {e}"))?;
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
|
||||
}
|
||||
|
||||
pub fn decrypt_payload(b64_payload: &str, key: &[u8; 32]) -> Result<Vec<u8>, String> {
|
||||
let raw = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64_payload.trim())
|
||||
.map_err(|e| format!("Base64 解码失败: {e}"))?;
|
||||
if raw.len() < 13 {
|
||||
return Err("密文过短".into());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = raw.split_at(12);
|
||||
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| format!("解密失败: {e}"))
|
||||
}
|
||||
|
||||
pub fn wrap_encrypted(plaintext: &[u8], key: &[u8; 32]) -> Result<String, String> {
|
||||
let payload = encrypt_bytes(plaintext, key)?;
|
||||
let envelope = serde_json::json!({
|
||||
"v": ENVELOPE_VERSION,
|
||||
"payload": payload,
|
||||
});
|
||||
serde_json::to_string(&envelope).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn unwrap_encrypted(body: &str, key: &[u8; 32]) -> Result<Vec<u8>, String> {
|
||||
let v: Value = serde_json::from_str(body).map_err(|e| format!("信封 JSON 无效: {e}"))?;
|
||||
let version = v.get("v").and_then(|x| x.as_i64());
|
||||
let payload = v
|
||||
.get("payload")
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| "缺少 payload".to_string())?;
|
||||
if version != Some(ENVELOPE_VERSION) {
|
||||
return Err("不支持的加密版本".into());
|
||||
}
|
||||
decrypt_payload(payload, key)
|
||||
}
|
||||
|
||||
pub fn crypto_enabled() -> bool {
|
||||
aes_key_from_env().is_some()
|
||||
}
|
||||
@@ -237,34 +237,20 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
async fn fetch_from_server(token: &str) -> Result<HashMap<String, String>, AppConfigError> {
|
||||
let base = api_base().trim_end_matches('/').to_string();
|
||||
let url = format!("{base}/api/v1/appConfig");
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
"Authorization".to_string(),
|
||||
format!("Bearer {}", token.trim()),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token.trim()))
|
||||
.send()
|
||||
let (status, v) = crate::api_client::request_json("GET", "/appConfig", None, headers)
|
||||
.await
|
||||
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||
.map_err(|e| AppConfigError::Fetch(e))?;
|
||||
|
||||
let status = res.status();
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppConfigError::Fetch(e.to_string()))?;
|
||||
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
if status == 401 {
|
||||
return Err(AppConfigError::Fetch("未认证或令牌已失效".into()));
|
||||
}
|
||||
|
||||
let v: Value =
|
||||
serde_json::from_str(&body).map_err(|e| AppConfigError::Fetch(format!("响应非 JSON: {e}")))?;
|
||||
|
||||
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
if !ok {
|
||||
let msg = v
|
||||
|
||||
63
src-tauri/src/commands/api.rs
Normal file
63
src-tauri/src/commands/api.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! 前端 API 代理:由 Rust 发起 HTTP,并与 Python 端 AES 加解密对齐。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::api_client;
|
||||
|
||||
fn default_method() -> String {
|
||||
"GET".into()
|
||||
}
|
||||
|
||||
/// 将 HTTP 结果整理为前端 `apiRequest` 约定结构。
|
||||
fn normalize_response(status: u16, value: Value) -> Value {
|
||||
if let Some(obj) = value.as_object() {
|
||||
if obj.contains_key("ok") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if status == 401 {
|
||||
return serde_json::json!({ "ok": false, "message": "未登录或登录已过期" });
|
||||
}
|
||||
if status == 403 {
|
||||
let msg = value
|
||||
.get("detail")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("无权限执行此操作");
|
||||
return serde_json::json!({ "ok": false, "message": msg });
|
||||
}
|
||||
if status == 422 {
|
||||
let msg = value
|
||||
.get("detail")
|
||||
.and_then(|d| d.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|e| e.get("msg"))
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("请求参数无效");
|
||||
return serde_json::json!({ "ok": false, "message": msg });
|
||||
}
|
||||
|
||||
let fallback = format!("请求失败({status})");
|
||||
let message = value
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(fallback.as_str());
|
||||
serde_json::json!({ "ok": false, "message": message })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn api_request(
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
body: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
) -> Result<Value, String> {
|
||||
let method = method.unwrap_or_else(|| default_method());
|
||||
let headers = headers.unwrap_or_default();
|
||||
let (status, value) =
|
||||
api_client::request_json(&method, &path, body.as_deref(), headers).await?;
|
||||
|
||||
Ok(normalize_response(status, value))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod app_config;
|
||||
pub mod auth;
|
||||
pub mod nodejs;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod native;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt};
|
||||
@@ -47,27 +48,11 @@ fn api_base() -> String {
|
||||
}
|
||||
|
||||
async fn fetch_script_source(script_name: &str) -> Result<String, JsError> {
|
||||
let base = api_base().trim_end_matches('/').to_string();
|
||||
let q = urlencoding::encode(script_name);
|
||||
let url = format!("{base}/api/v1/quickjs-scripts?name={q}");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
let path = format!("/quickjs-scripts?name={q}");
|
||||
let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new())
|
||||
.await
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let v: Value =
|
||||
serde_json::from_str(&body).map_err(|e| JsError::FetchScript(format!("响应非 JSON: {e}")))?;
|
||||
.map_err(|e| JsError::FetchScript(e))?;
|
||||
|
||||
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
if !ok {
|
||||
@@ -242,26 +227,10 @@ pub async fn run_script_source(
|
||||
|
||||
/// 当前服务端 `scripts/quickjs` 目录下的 `.js` 文件名列表。
|
||||
pub async fn list_scripts() -> Result<Vec<String>, JsError> {
|
||||
let base = api_base().trim_end_matches('/').to_string();
|
||||
let url = format!("{base}/api/v1/quickjs-scripts/list");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| JsError::FetchScript(e.to_string()))?;
|
||||
|
||||
let v: Value =
|
||||
serde_json::from_str(&body).map_err(|e| JsError::FetchScript(format!("响应非 JSON: {e}")))?;
|
||||
let (_status, v) =
|
||||
crate::api_client::request_json("GET", "/quickjs-scripts/list", None, HashMap::new())
|
||||
.await
|
||||
.map_err(|e| JsError::FetchScript(e))?;
|
||||
|
||||
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
|
||||
let msg = v
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod api_client;
|
||||
pub mod api_crypto;
|
||||
pub mod app_config;
|
||||
pub mod local_config;
|
||||
pub mod asr;
|
||||
@@ -43,6 +45,7 @@ pub fn run() {
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::api::api_request,
|
||||
commands::auth::sync_auth_session,
|
||||
commands::app_config::get_app_config,
|
||||
commands::app_config::get_app_config_last_message,
|
||||
|
||||
@@ -262,24 +262,11 @@ fn api_base() -> String {
|
||||
}
|
||||
|
||||
async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError> {
|
||||
let base = api_base().trim_end_matches('/').to_string();
|
||||
let q = urlencoding::encode(script_name);
|
||||
let url = format!("{base}/api/v1/nodejs-scripts?name={q}");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
let path = format!("/nodejs-scripts?name={q}");
|
||||
let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new())
|
||||
.await
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let v: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
|
||||
.map_err(|e| NodeError::FetchScript(e))?;
|
||||
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
|
||||
let msg = v
|
||||
.get("message")
|
||||
@@ -297,23 +284,10 @@ async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError
|
||||
|
||||
/// 后端 `scripts/nodejs` 目录下的 `.js` 文件名列表。
|
||||
pub async fn list_scripts() -> Result<Vec<String>, NodeError> {
|
||||
let base = api_base().trim_end_matches('/').to_string();
|
||||
let url = format!("{base}/api/v1/nodejs-scripts/list");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let body = res
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
|
||||
let v: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
|
||||
let (_status, v) =
|
||||
crate::api_client::request_json("GET", "/nodejs-scripts/list", None, HashMap::new())
|
||||
.await
|
||||
.map_err(|e| NodeError::FetchScript(e))?;
|
||||
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
|
||||
let msg = v
|
||||
.get("message")
|
||||
|
||||
Reference in New Issue
Block a user