4 Commits

Author SHA1 Message Date
9583e2f44b Rename Http to Web and implement permission checks
- Rename ResourceType::Http to ResourceType::Web
- Rename HttpAction to WebAction
- Rename HttpConstraints to WebConstraints
- Rename Action::Http to Action::Web
- Add check_web_permission method to PermissionManager
- Optimize permission loading (only fetch web permissions)
- Add permission checks to extension_web_fetch and extension_web_open
- Update manifest.rs to use Web instead of Http
2025-11-11 14:37:47 +01:00
d886fbd8bd Add web.openAsync method to open URLs in browser
- Add extension_web_open Tauri command
- Validate URL format and allow only http/https
- Use tauri-plugin-opener to open URL in default browser
- Add handleWebOpenAsync handler in frontend
2025-11-11 14:02:41 +01:00
9bad4008f2 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
2025-11-11 13:54:55 +01:00
203f81e775 Add WebAPI handler for extensions
- Rename http.ts to web.ts handler
- Implement handleWebMethodAsync with haextension.web.fetch support
- Add base64 body encoding/decoding
- Add timeout support with AbortController
- Convert response headers and body to proper format
- Update message handler to route haextension.web.* methods
- Add TODO for permission checks

This enables extensions to make web requests through the host app,
bypassing iframe CORS restrictions.
2025-11-11 13:27:53 +01:00
11 changed files with 412 additions and 94 deletions

View File

@ -1,6 +1,6 @@
use crate::extension::error::ExtensionError;
use crate::extension::permissions::types::{
Action, DbAction, ExtensionPermission, FsAction, HttpAction, PermissionConstraints,
Action, DbAction, ExtensionPermission, FsAction, WebAction, PermissionConstraints,
PermissionStatus, ResourceType, ShellAction,
};
use serde::{Deserialize, Serialize};
@ -117,7 +117,7 @@ impl ExtensionPermissions {
}
if let Some(entries) = &self.http {
for p in entries {
if let Some(perm) = Self::create_internal(extension_id, ResourceType::Http, p) {
if let Some(perm) = Self::create_internal(extension_id, ResourceType::Web, p) {
permissions.push(perm);
}
}
@ -146,7 +146,7 @@ impl ExtensionPermissions {
ResourceType::Fs => FsAction::from_str(operation_str)
.ok()
.map(Action::Filesystem),
ResourceType::Http => HttpAction::from_str(operation_str).ok().map(Action::Http),
ResourceType::Web => WebAction::from_str(operation_str).ok().map(Action::Web),
ResourceType::Shell => ShellAction::from_str(operation_str).ok().map(Action::Shell),
};

View File

@ -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,

View File

@ -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(

View File

@ -4,7 +4,7 @@ use crate::database::core::with_connection;
use crate::database::error::DatabaseError;
use crate::extension::database::executor::SqlExecutor;
use crate::extension::error::ExtensionError;
use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionStatus, ResourceType};
use crate::extension::permissions::types::{Action, ExtensionPermission, PermissionConstraints, PermissionStatus, ResourceType};
use tauri::State;
use crate::database::generated::HaexExtensionPermissions;
use rusqlite::params;
@ -245,6 +245,74 @@ impl PermissionManager {
Ok(())
}
/// Prüft Web-Berechtigungen für Requests
pub async fn check_web_permission(
app_state: &State<'_, AppState>,
extension_id: &str,
method: &str,
url: &str,
) -> Result<(), ExtensionError> {
// Optimiert: Lade nur Web-Permissions aus der Datenbank
let permissions = with_connection(&app_state.db, |conn| {
let sql = format!(
"SELECT * FROM {TABLE_EXTENSION_PERMISSIONS} WHERE extension_id = ? AND resource_type = 'web'"
);
let mut stmt = conn.prepare(&sql).map_err(DatabaseError::from)?;
let perms_iter = stmt.query_map(params![extension_id], |row| {
crate::database::generated::HaexExtensionPermissions::from_row(row)
})?;
let permissions: Vec<ExtensionPermission> = perms_iter
.filter_map(Result::ok)
.map(Into::into)
.collect();
Ok(permissions)
})?;
let url_parsed = url::Url::parse(url).map_err(|e| ExtensionError::ValidationError {
reason: format!("Invalid URL: {}", e),
})?;
let domain = url_parsed.host_str().ok_or_else(|| ExtensionError::ValidationError {
reason: "URL does not contain a valid host".to_string(),
})?;
let has_permission = permissions
.iter()
.filter(|perm| perm.status == PermissionStatus::Granted)
.any(|perm| {
let domain_matches = perm.target == "*"
|| perm.target == domain
|| domain.ends_with(&format!(".{}", perm.target));
if !domain_matches {
return false;
}
if let Some(PermissionConstraints::Web(constraints)) = &perm.constraints {
if let Some(methods) = &constraints.methods {
if !methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
return false;
}
}
}
true
});
if !has_permission {
return Err(ExtensionError::permission_denied(
extension_id,
method,
&format!("web request to '{}'", url),
));
}
Ok(())
}
/* /// Prüft Dateisystem-Berechtigungen
pub async fn check_filesystem_permission(
app_state: &State<'_, AppState>,
@ -293,56 +361,6 @@ impl PermissionManager {
Ok(())
}
/// Prüft HTTP-Berechtigungen
pub async fn check_http_permission(
app_state: &State<'_, AppState>,
extension_id: &str,
method: &str,
url: &str,
) -> Result<(), ExtensionError> {
let permissions = Self::get_permissions(app_state, extension_id).await?;
let url_parsed = Url::parse(url).map_err(|e| ExtensionError::ValidationError {
reason: format!("Invalid URL: {}", e),
})?;
let domain = url_parsed.host_str().unwrap_or("");
let has_permission = permissions
.iter()
.filter(|perm| perm.status == PermissionStatus::Granted)
.filter(|perm| perm.resource_type == ResourceType::Http)
.any(|perm| {
let domain_matches = perm.target == "*"
|| perm.target == domain
|| domain.ends_with(&format!(".{}", perm.target));
if !domain_matches {
return false;
}
if let Some(PermissionConstraints::Http(constraints)) = &perm.constraints {
if let Some(methods) = &constraints.methods {
if !methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
return false;
}
}
}
true
});
if !has_permission {
return Err(ExtensionError::permission_denied(
extension_id,
method,
&format!("HTTP request to '{}'", url),
));
}
Ok(())
}
/// Prüft Shell-Berechtigungen
pub async fn check_shell_permission(
app_state: &State<'_, AppState>,
@ -410,7 +428,7 @@ impl PermissionManager {
pub fn parse_resource_type(s: &str) -> Result<ResourceType, DatabaseError> {
match s {
"fs" => Ok(ResourceType::Fs),
"http" => Ok(ResourceType::Http),
"web" => Ok(ResourceType::Web),
"db" => Ok(ResourceType::Db),
"shell" => Ok(ResourceType::Shell),
_ => Err(DatabaseError::SerializationError {

View File

@ -86,11 +86,11 @@ impl FromStr for FsAction {
}
}
/// Definiert Aktionen (HTTP-Methoden), die auf HTTP-Anfragen angewendet werden können.
/// Definiert Aktionen (HTTP-Methoden), die auf Web-Anfragen angewendet werden können.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "UPPERCASE")]
#[ts(export)]
pub enum HttpAction {
pub enum WebAction {
Get,
Post,
Put,
@ -100,20 +100,20 @@ pub enum HttpAction {
All,
}
impl FromStr for HttpAction {
impl FromStr for WebAction {
type Err = ExtensionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"GET" => Ok(HttpAction::Get),
"POST" => Ok(HttpAction::Post),
"PUT" => Ok(HttpAction::Put),
"PATCH" => Ok(HttpAction::Patch),
"DELETE" => Ok(HttpAction::Delete),
"*" => Ok(HttpAction::All),
"GET" => Ok(WebAction::Get),
"POST" => Ok(WebAction::Post),
"PUT" => Ok(WebAction::Put),
"PATCH" => Ok(WebAction::Patch),
"DELETE" => Ok(WebAction::Delete),
"*" => Ok(WebAction::All),
_ => Err(ExtensionError::InvalidActionString {
input: s.to_string(),
resource_type: "http".to_string(),
resource_type: "web".to_string(),
}),
}
}
@ -149,7 +149,7 @@ impl FromStr for ShellAction {
pub enum Action {
Database(DbAction),
Filesystem(FsAction),
Http(HttpAction),
Web(WebAction),
Shell(ShellAction),
}
@ -173,7 +173,7 @@ pub struct ExtensionPermission {
#[ts(export)]
pub enum ResourceType {
Fs,
Http,
Web,
Db,
Shell,
}
@ -195,7 +195,7 @@ pub enum PermissionStatus {
pub enum PermissionConstraints {
Database(DbConstraints),
Filesystem(FsConstraints),
Http(HttpConstraints),
Web(WebConstraints),
Shell(ShellConstraints),
}
@ -223,7 +223,7 @@ pub struct FsConstraints {
#[derive(Serialize, Deserialize, Clone, Debug, Default, TS)]
#[ts(export)]
pub struct HttpConstraints {
pub struct WebConstraints {
#[serde(skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -254,7 +254,7 @@ impl ResourceType {
pub fn as_str(&self) -> &str {
match self {
ResourceType::Fs => "fs",
ResourceType::Http => "http",
ResourceType::Web => "web",
ResourceType::Db => "db",
ResourceType::Shell => "shell",
}
@ -263,7 +263,7 @@ impl ResourceType {
pub fn from_str(s: &str) -> Result<Self, ExtensionError> {
match s {
"fs" => Ok(ResourceType::Fs),
"http" => Ok(ResourceType::Http),
"web" => Ok(ResourceType::Web),
"db" => Ok(ResourceType::Db),
"shell" => Ok(ResourceType::Shell),
_ => Err(ExtensionError::ValidationError {
@ -284,7 +284,7 @@ impl Action {
.unwrap_or_default()
.trim_matches('"')
.to_string(),
Action::Http(action) => serde_json::to_string(action)
Action::Web(action) => serde_json::to_string(action)
.unwrap_or_default()
.trim_matches('"')
.to_string(),
@ -299,15 +299,15 @@ impl Action {
match resource_type {
ResourceType::Db => Ok(Action::Database(DbAction::from_str(s)?)),
ResourceType::Fs => Ok(Action::Filesystem(FsAction::from_str(s)?)),
ResourceType::Http => {
let action: HttpAction =
ResourceType::Web => {
let action: WebAction =
serde_json::from_str(&format!("\"{s}\"")).map_err(|_| {
ExtensionError::InvalidActionString {
input: s.to_string(),
resource_type: "http".to_string(),
resource_type: "web".to_string(),
}
})?;
Ok(Action::Http(action))
Ok(Action::Web(action))
}
ResourceType::Shell => Ok(Action::Shell(ShellAction::from_str(s)?)),
}

View File

@ -0,0 +1,210 @@
// 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<String>,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
#[serde(default)]
pub body: Option<String>, // Base64 encoded
#[serde(default)]
pub timeout: Option<u64>, // milliseconds
}
/// Response structure matching the SDK's WebResponse
#[derive(Debug, Serialize)]
pub struct WebFetchResponse {
pub status: u16,
pub status_text: String,
pub headers: HashMap<String, String>,
pub body: String, // Base64 encoded
pub url: String,
}
#[tauri::command]
pub async fn extension_web_open(
url: String,
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<(), ExtensionError> {
// 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(),
})?;
// Validate URL format
let parsed_url = url::Url::parse(&url).map_err(|e| ExtensionError::WebError {
reason: format!("Invalid URL: {}", e),
})?;
// Only allow http and https URLs
let scheme = parsed_url.scheme();
if scheme != "http" && scheme != "https" {
return Err(ExtensionError::WebError {
reason: format!("Unsupported URL scheme: {}. Only http and https are allowed.", scheme),
});
}
// Check web permissions (open uses GET method for permission check)
crate::extension::permissions::manager::PermissionManager::check_web_permission(
&state,
&extension.id,
"GET",
&url,
)
.await?;
// Open URL in default browser using tauri-plugin-opener
tauri_plugin_opener::open_url(&url, None::<&str>).map_err(|e| ExtensionError::WebError {
reason: format!("Failed to open URL in browser: {}", e),
})?;
Ok(())
}
#[tauri::command]
pub async fn extension_web_fetch(
url: String,
method: Option<String>,
headers: Option<HashMap<String, String>>,
body: Option<String>,
timeout: Option<u64>,
public_key: String,
name: String,
state: State<'_, AppState>,
) -> Result<WebFetchResponse, ExtensionError> {
// 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(),
})?;
let method_str = method.as_deref().unwrap_or("GET");
// Check web permissions before making request
crate::extension::permissions::manager::PermissionManager::check_web_permission(
&state,
&extension.id,
method_str,
&url,
)
.await?;
let request = WebFetchRequest {
url,
method: Some(method_str.to_string()),
headers,
body,
timeout,
};
fetch_web_request(request).await
}
/// Performs the actual HTTP request without CORS restrictions
async fn fetch_web_request(request: WebFetchRequest) -> Result<WebFetchResponse, ExtensionError> {
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,
})
}

View File

@ -78,6 +78,8 @@ pub fn run() {
database::vault_exists,
extension::database::extension_sql_execute,
extension::database::extension_sql_select,
extension::web::extension_web_fetch,
extension::web::extension_web_open,
extension::get_all_dev_extensions,
extension::get_all_extensions,
extension::get_extension_info,

View File

@ -7,7 +7,7 @@ import {
import {
handleDatabaseMethodAsync,
handleFilesystemMethodAsync,
handleHttpMethodAsync,
handleWebMethodAsync,
handlePermissionsMethodAsync,
handleContextMethodAsync,
handleStorageMethodAsync,
@ -165,8 +165,8 @@ const registerGlobalMessageHandler = () => {
result = await handleDatabaseMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.fs.')) {
result = await handleFilesystemMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.http.')) {
result = await handleHttpMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.web.')) {
result = await handleWebMethodAsync(request, instance.extension)
} else if (request.method.startsWith('haextension.permissions.')) {
result = await handlePermissionsMethodAsync(request, instance.extension)
} else {

View File

@ -1,14 +0,0 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleHttpMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere HTTP Commands im Backend
throw new Error('HTTP methods not yet implemented')
}

View File

@ -1,7 +1,7 @@
// Export all handler functions
export { handleDatabaseMethodAsync } from './database'
export { handleFilesystemMethodAsync } from './filesystem'
export { handleHttpMethodAsync } from './http'
export { handleWebMethodAsync } from './web'
export { handlePermissionsMethodAsync } from './permissions'
export { handleContextMethodAsync, setContextGetters } from './context'
export { handleStorageMethodAsync } from './storage'

View File

@ -0,0 +1,96 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
import { invoke } from '@tauri-apps/api/core'
export async function handleWebMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
const { method, params } = request
if (method === 'haextension.web.fetch') {
return await handleWebFetchAsync(params, extension)
}
if (method === 'haextension.web.open') {
return await handleWebOpenAsync(params, extension)
}
throw new Error(`Unknown web method: ${method}`)
}
async function handleWebFetchAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const url = params.url as string
const method = (params.method as string) || undefined
const headers = (params.headers as Record<string, string>) || undefined
const body = params.body as string | undefined
const timeout = (params.timeout as number) || undefined
if (!url) {
throw new Error('URL is required')
}
try {
// Call Rust backend through Tauri IPC to avoid CORS restrictions
const response = await invoke<{
status: number
status_text: string
headers: Record<string, string>
body: string
url: string
}>('extension_web_fetch', {
url,
method,
headers,
body,
timeout,
publicKey: extension.publicKey,
name: extension.name,
})
return {
status: response.status,
statusText: response.status_text,
headers: response.headers,
body: response.body,
url: response.url,
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Web request failed: ${error.message}`)
}
throw new Error('Web request failed with unknown error')
}
}
async function handleWebOpenAsync(
params: Record<string, unknown>,
extension: IHaexHubExtension,
) {
const url = params.url as string
if (!url) {
throw new Error('URL is required')
}
try {
// Call Rust backend to open URL in default browser
await invoke<void>('extension_web_open', {
url,
publicKey: extension.publicKey,
name: extension.name,
})
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to open URL: ${error.message}`)
}
throw new Error('Failed to open URL with unknown error')
}
}