From 9bad4008f2bf0e7df89045f9075d5a1995c0da7c Mon Sep 17 00:00:00 2001 From: haex Date: Tue, 11 Nov 2025 13:54:55 +0100 Subject: [PATCH] Implement web requests on Rust backend to avoid CORS - Add web module in src-tauri/src/extension/web/mod.rs - Implement extension_web_fetch Tauri command using reqwest - Add WebError variant to ExtensionError enum - Update frontend handler to call Rust backend via Tauri IPC - Web requests now run in native context without CORS restrictions --- src-tauri/src/extension/error.rs | 5 + src-tauri/src/extension/mod.rs | 1 + src-tauri/src/extension/web/mod.rs | 157 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/composables/handlers/web.ts | 79 +++++---------- 5 files changed, 191 insertions(+), 52 deletions(-) create mode 100644 src-tauri/src/extension/web/mod.rs diff --git a/src-tauri/src/extension/error.rs b/src-tauri/src/extension/error.rs index 1943882..7a6bc85 100644 --- a/src-tauri/src/extension/error.rs +++ b/src-tauri/src/extension/error.rs @@ -16,6 +16,7 @@ pub enum ExtensionErrorCode { Filesystem = 2001, FilesystemWithPath = 2004, Http = 2002, + Web = 2005, Shell = 2003, Manifest = 3000, Validation = 3001, @@ -83,6 +84,9 @@ pub enum ExtensionError { #[error("HTTP request failed: {reason}")] Http { reason: String }, + #[error("Web request failed: {reason}")] + WebError { reason: String }, + #[error("Shell command failed: {reason}")] Shell { reason: String, @@ -131,6 +135,7 @@ impl ExtensionError { ExtensionError::Filesystem { .. } => ExtensionErrorCode::Filesystem, ExtensionError::FilesystemWithPath { .. } => ExtensionErrorCode::FilesystemWithPath, ExtensionError::Http { .. } => ExtensionErrorCode::Http, + ExtensionError::WebError { .. } => ExtensionErrorCode::Web, ExtensionError::Shell { .. } => ExtensionErrorCode::Shell, ExtensionError::ManifestError { .. } => ExtensionErrorCode::Manifest, ExtensionError::ValidationError { .. } => ExtensionErrorCode::Validation, diff --git a/src-tauri/src/extension/mod.rs b/src-tauri/src/extension/mod.rs index 6fec940..2e868e5 100644 --- a/src-tauri/src/extension/mod.rs +++ b/src-tauri/src/extension/mod.rs @@ -13,6 +13,7 @@ pub mod database; pub mod error; pub mod filesystem; pub mod permissions; +pub mod web; #[tauri::command] pub fn get_extension_info( diff --git a/src-tauri/src/extension/web/mod.rs b/src-tauri/src/extension/web/mod.rs new file mode 100644 index 0000000..14426a1 --- /dev/null +++ b/src-tauri/src/extension/web/mod.rs @@ -0,0 +1,157 @@ +// src-tauri/src/extension/web/mod.rs + +use crate::extension::error::ExtensionError; +use crate::AppState; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use tauri::State; +use tauri_plugin_http::reqwest; + +/// Request structure matching the SDK's WebRequestOptions +#[derive(Debug, Deserialize)] +pub struct WebFetchRequest { + pub url: String, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub headers: Option>, + #[serde(default)] + pub body: Option, // Base64 encoded + #[serde(default)] + pub timeout: Option, // milliseconds +} + +/// Response structure matching the SDK's WebResponse +#[derive(Debug, Serialize)] +pub struct WebFetchResponse { + pub status: u16, + pub status_text: String, + pub headers: HashMap, + pub body: String, // Base64 encoded + pub url: String, +} + +#[tauri::command] +pub async fn extension_web_fetch( + url: String, + method: Option, + headers: Option>, + body: Option, + timeout: Option, + public_key: String, + name: String, + state: State<'_, AppState>, +) -> Result { + // Get extension to validate it exists + let _extension = state + .extension_manager + .get_extension_by_public_key_and_name(&public_key, &name)? + .ok_or_else(|| ExtensionError::NotFound { + public_key: public_key.clone(), + name: name.clone(), + })?; + + // TODO: Add permission check for web requests once permission system is complete + // For now, extensions are allowed to make web requests + // Use _extension for permission validation when implemented + + let request = WebFetchRequest { + url, + method, + headers, + body, + timeout, + }; + + fetch_web_request(request).await +} + +/// Performs the actual HTTP request without CORS restrictions +async fn fetch_web_request(request: WebFetchRequest) -> Result { + let method_str = request.method.as_deref().unwrap_or("GET"); + let timeout_ms = request.timeout.unwrap_or(30000); + + // Build reqwest client with timeout + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build() + .map_err(|e| ExtensionError::WebError { + reason: format!("Failed to create HTTP client: {}", e), + })?; + + // Build request + let mut req_builder = match method_str.to_uppercase().as_str() { + "GET" => client.get(&request.url), + "POST" => client.post(&request.url), + "PUT" => client.put(&request.url), + "DELETE" => client.delete(&request.url), + "PATCH" => client.patch(&request.url), + "HEAD" => client.head(&request.url), + "OPTIONS" => client.request(reqwest::Method::OPTIONS, &request.url), + _ => { + return Err(ExtensionError::WebError { + reason: format!("Unsupported HTTP method: {}", method_str), + }) + } + }; + + // Add headers + if let Some(headers) = request.headers { + for (key, value) in headers { + req_builder = req_builder.header(key, value); + } + } + + // Add body if present (decode from base64) + if let Some(body_base64) = request.body { + let body_bytes = STANDARD.decode(&body_base64).map_err(|e| { + ExtensionError::WebError { + reason: format!("Failed to decode request body from base64: {}", e), + } + })?; + req_builder = req_builder.body(body_bytes); + } + + // Execute request + let response = req_builder.send().await.map_err(|e| { + if e.is_timeout() { + ExtensionError::WebError { + reason: format!("Request timeout after {}ms", timeout_ms), + } + } else { + ExtensionError::WebError { + reason: format!("Request failed: {}", e), + } + } + })?; + + // Extract response data + let status = response.status().as_u16(); + let status_text = response.status().canonical_reason().unwrap_or("").to_string(); + let final_url = response.url().to_string(); + + // Extract headers + let mut response_headers = HashMap::new(); + for (key, value) in response.headers() { + if let Ok(value_str) = value.to_str() { + response_headers.insert(key.to_string(), value_str.to_string()); + } + } + + // Read body and encode to base64 + let body_bytes = response.bytes().await.map_err(|e| ExtensionError::WebError { + reason: format!("Failed to read response body: {}", e), + })?; + + let body_base64 = STANDARD.encode(&body_bytes); + + Ok(WebFetchResponse { + status, + status_text, + headers: response_headers, + body: body_base64, + url: final_url, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86f9b36..031d8f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -78,6 +78,7 @@ pub fn run() { database::vault_exists, extension::database::extension_sql_execute, extension::database::extension_sql_select, + extension::web::extension_web_fetch, extension::get_all_dev_extensions, extension::get_all_extensions, extension::get_extension_info, diff --git a/src/composables/handlers/web.ts b/src/composables/handlers/web.ts index 59a7b4f..61f80ed 100644 --- a/src/composables/handlers/web.ts +++ b/src/composables/handlers/web.ts @@ -1,5 +1,6 @@ import type { IHaexHubExtension } from '~/types/haexhub' import type { ExtensionRequest } from './types' +import { invoke } from '@tauri-apps/api/core' export async function handleWebMethodAsync( request: ExtensionRequest, @@ -9,84 +10,58 @@ export async function handleWebMethodAsync( throw new Error('Extension not found') } - // TODO: Add permission check for web requests - // This should verify that the extension has permission to make web requests - // before proceeding with the fetch operation - const { method, params } = request if (method === 'haextension.web.fetch') { - return await handleWebFetchAsync(params) + return await handleWebFetchAsync(params, extension) } throw new Error(`Unknown web method: ${method}`) } -async function handleWebFetchAsync(params: Record) { +async function handleWebFetchAsync( + params: Record, + extension: IHaexHubExtension, +) { const url = params.url as string - const method = (params.method as string) || 'GET' - const headers = (params.headers as Record) || {} + const method = (params.method as string) || undefined + const headers = (params.headers as Record) || undefined const body = params.body as string | undefined - const timeout = (params.timeout as number) || 30000 + const timeout = (params.timeout as number) || undefined if (!url) { throw new Error('URL is required') } try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) - - const fetchOptions: RequestInit = { + // Call Rust backend through Tauri IPC to avoid CORS restrictions + const response = await invoke<{ + status: number + status_text: string + headers: Record + body: string + url: string + }>('extension_web_fetch', { + url, method, headers, - signal: controller.signal, - } - - // Convert base64 body back to binary if present - if (body) { - const binaryString = atob(body) - const bytes = new Uint8Array(binaryString.length) - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i) - } - fetchOptions.body = bytes - } - - const response = await fetch(url, fetchOptions) - clearTimeout(timeoutId) - - // Read response as ArrayBuffer - const responseBody = await response.arrayBuffer() - - // Convert ArrayBuffer to base64 - const bytes = new Uint8Array(responseBody) - let binary = '' - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]) - } - const base64Body = btoa(binary) - - // Convert headers to plain object - const responseHeaders: Record = {} - response.headers.forEach((value, key) => { - responseHeaders[key] = value + body, + timeout, + publicKey: extension.publicKey, + name: extension.name, }) return { status: response.status, - statusText: response.statusText, - headers: responseHeaders, - body: base64Body, + statusText: response.status_text, + headers: response.headers, + body: response.body, url: response.url, } } catch (error) { if (error instanceof Error) { - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`) - } - throw new Error(`Fetch failed: ${error.message}`) + throw new Error(`Web request failed: ${error.message}`) } - throw new Error('Fetch failed with unknown error') + throw new Error('Web request failed with unknown error') } }