Files
EasyCLIProxyAPI/src-tauri/src/plugins.rs
T
lzt404 d29a1af69b feat: add plugin management and redesign dashboard and usage analytics
- Add plugin store, version selection, configuration, and OAuth login
- Support Muse (Meta) OAuth device authorization
- Redesign home dashboard and add batch model health checks
- Support per-credential provider editing, removal, and reordering
- Improve usage charts and track actual response models
- Preserve installed plugins and runtime data during portable rebuilds
- Update translations, documentation, and related tests
2026-10-04 17:31:52 +08:00

225 lines
7.9 KiB
Rust

use super::{
core_origin, current_core_tls_settings, format_management_request_error,
management_authorization, management_endpoint, management_http_client, read_management_value,
GuiConfigFile, GuiConfigState,
};
#[tauri::command]
pub(crate) async fn get_plugin_support(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<bool, String> {
let config = gui_config_state.snapshot()?;
let response = request_plugins(&config).await?;
read_plugin_support(response).await
}
#[tauri::command]
pub(crate) async fn get_plugin_resource_url(
gui_config_state: tauri::State<'_, GuiConfigState>,
plugin_id: String,
menu_index: usize,
) -> Result<String, String> {
let config = gui_config_state.snapshot()?;
let response = request_plugins(&config).await?;
let plugins = read_management_value(response).await?;
let path = active_plugin_menu_path(&plugins, &plugin_id, menu_index)?;
let origin = core_origin(
&config.host,
config.port,
current_core_tls_settings()?.enabled,
);
resolve_plugin_resource_url(&origin, path)
}
async fn request_plugins(config: &GuiConfigFile) -> Result<reqwest::Response, String> {
management_http_client()?
.get(management_endpoint(config, "plugins")?)
.header("Authorization", management_authorization(config)?)
.send()
.await
.map_err(|error| format_management_request_error("Failed to query plugins", &error))
}
async fn read_plugin_support(response: reqwest::Response) -> Result<bool, String> {
if let Some(supported) = response
.headers()
.get("X-CPA-SUPPORT-PLUGIN")
.and_then(|value| value.to_str().ok())
.and_then(parse_plugin_support_header)
{
return Ok(supported);
}
if matches!(response.status().as_u16(), 404 | 501) {
return Ok(false);
}
let value = read_management_value(response).await?;
if value
.get("plugins")
.is_some_and(serde_json::Value::is_array)
{
return Ok(true);
}
Err("Kernel returned an invalid plugin capability response".to_string())
}
fn parse_plugin_support_header(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn active_plugin_menu_path<'a>(
payload: &'a serde_json::Value,
plugin_id: &str,
menu_index: usize,
) -> Result<&'a str, String> {
let plugin = payload
.get("plugins")
.and_then(serde_json::Value::as_array)
.and_then(|plugins| {
plugins.iter().find(|plugin| {
plugin.get("id").and_then(serde_json::Value::as_str) == Some(plugin_id)
&& plugin
.get("effective_enabled")
.and_then(serde_json::Value::as_bool)
== Some(true)
})
})
.ok_or_else(|| "Plugin is unavailable or disabled".to_string())?;
plugin
.get("menus")
.and_then(serde_json::Value::as_array)
.and_then(|menus| menus.get(menu_index))
.and_then(|menu| menu.get("path"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.ok_or_else(|| "Plugin page is unavailable".to_string())
}
fn resolve_plugin_resource_url(origin: &str, path: &str) -> Result<String, String> {
// Plugin resources are public, browser-navigable routes. Preserve the declared
// versioned path and never add the management key to the iframe URL.
if !path.starts_with('/')
|| path.starts_with("//")
|| path.contains('\\')
|| path.chars().any(char::is_control)
{
return Err("Invalid plugin resource path".to_string());
}
let base = reqwest::Url::parse(origin).map_err(|_| "Invalid kernel origin".to_string())?;
let url = base
.join(path)
.map_err(|_| "Invalid plugin resource path".to_string())?;
if url.origin() != base.origin() {
return Err("Plugin resources must use the kernel origin".to_string());
}
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_support_respects_build_capability_headers() {
for value in ["1", "TRUE", " yes ", "on"] {
assert_eq!(parse_plugin_support_header(value), Some(true));
}
for value in ["0", "FALSE", " no ", "off"] {
assert_eq!(parse_plugin_support_header(value), Some(false));
}
assert_eq!(parse_plugin_support_header("unknown"), None);
assert_eq!(parse_plugin_support_header(""), None);
}
#[tokio::test]
async fn plugin_support_uses_headers_before_list_fallback() {
let response = reqwest::Response::from(
tauri::http::Response::builder()
.status(200)
.header("X-CPA-SUPPORT-PLUGIN", "false")
.body(r#"{"plugins":[]}"#)
.unwrap(),
);
assert!(!read_plugin_support(response).await.unwrap());
let response = reqwest::Response::from(
tauri::http::Response::builder()
.status(200)
.body(r#"{"plugins":[]}"#)
.unwrap(),
);
assert!(read_plugin_support(response).await.unwrap());
for status in [404, 501] {
let response = reqwest::Response::from(
tauri::http::Response::builder()
.status(status)
.body("")
.unwrap(),
);
assert!(!read_plugin_support(response).await.unwrap());
}
for (status, body) in [
(401, r#"{"error":"unauthorized"}"#),
(200, "<html>error</html>"),
] {
let response = reqwest::Response::from(
tauri::http::Response::builder()
.status(status)
.body(body)
.unwrap(),
);
assert!(read_plugin_support(response).await.is_err());
}
}
#[test]
fn plugin_pages_require_a_current_enabled_menu_declaration() {
let payload = serde_json::json!({"plugins": [
{"id": "enabled", "effective_enabled": true, "menus": [
{"path": "/v0/resource/plugins/enabled/status"},
{"path": "/v9/resource/plugins/enabled/settings"}
]},
{"id": "disabled", "effective_enabled": false, "menus": [
{"path": "/v0/resource/plugins/disabled/status"}
]}
]});
assert_eq!(
active_plugin_menu_path(&payload, "enabled", 1).unwrap(),
"/v9/resource/plugins/enabled/settings"
);
assert!(active_plugin_menu_path(&payload, "enabled", 2).is_err());
assert!(active_plugin_menu_path(&payload, "disabled", 0).is_err());
assert!(active_plugin_menu_path(&payload, "deleted", 0).is_err());
}
#[test]
fn plugin_resource_urls_preserve_declared_paths_and_local_tls_origin() {
for (origin, path, expected) in [
(
"http://127.0.0.1:8317",
"/v0/resource/plugins/demo/page",
"http://127.0.0.1:8317/v0/resource/plugins/demo/page",
),
(
"https://[::1]:8443",
"/v9/resource/plugins/demo/page?mode=full#section",
"https://[::1]:8443/v9/resource/plugins/demo/page?mode=full#section",
),
] {
assert_eq!(resolve_plugin_resource_url(origin, path).unwrap(), expected);
}
for path in [
"https://example.com/page",
"//example.com/page",
"/\\example.com/page",
"javascript:alert(1)",
"/page\n",
] {
assert!(resolve_plugin_resource_url("http://127.0.0.1:8317", path).is_err());
}
}
}