diff --git a/src-tauri/src/core/cmd.rs b/src-tauri/src/core/cmd.rs index 4023329d9..4a48e63d3 100644 --- a/src-tauri/src/core/cmd.rs +++ b/src-tauri/src/core/cmd.rs @@ -1,8 +1,6 @@ -use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::{fs, io, path::PathBuf}; use tauri::{AppHandle, Manager, Runtime, State}; -use tauri_plugin_updater::UpdaterExt; use super::{server, setup, state::AppState}; @@ -347,10 +345,15 @@ pub async fn start_server( host: String, port: u16, prefix: String, + api_key: String, trusted_hosts: Vec, ) -> Result { - let auth_token = app.state::().app_token.clone().unwrap_or_default(); - server::start_server(host, port, prefix, auth_token, trusted_hosts) + let auth_token = app + .state::() + .app_token + .clone() + .unwrap_or_default(); + server::start_server(host, port, prefix, auth_token, api_key, trusted_hosts) .await .map_err(|e| e.to_string())?; Ok(true) diff --git a/src-tauri/src/core/server.rs b/src-tauri/src/core/server.rs index bf13a2073..68021214a 100644 --- a/src-tauri/src/core/server.rs +++ b/src-tauri/src/core/server.rs @@ -20,6 +20,7 @@ struct ProxyConfig { prefix: String, auth_token: String, trusted_hosts: Vec, + api_key: String, } /// Removes a prefix from a path, ensuring proper formatting @@ -86,6 +87,25 @@ async fn proxy_request( .unwrap()); } + if !config.api_key.is_empty() { + if let Some(authorization) = req.headers().get(hyper::header::AUTHORIZATION) { + let auth_str = authorization.to_str().unwrap_or(""); + + if auth_str.strip_prefix("Bearer ") != Some(config.api_key.as_str()) + { + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("Invalid or missing authorization token")) + .unwrap()); + } + } else { + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("Missing authorization header")) + .unwrap()); + } + } + // Block access to /configs endpoint if path.contains("/configs") { return Ok(Response::builder() @@ -102,8 +122,8 @@ async fn proxy_request( // Copy original headers for (name, value) in req.headers() { - if name != hyper::header::HOST { - // Skip host header + // Skip host & authorization header + if name != hyper::header::HOST && name != hyper::header::AUTHORIZATION { outbound_req = outbound_req.header(name, value); } } @@ -152,14 +172,26 @@ fn is_valid_host(host: &str, trusted_hosts: &[String]) -> bool { return false; } - let host_without_port = if host.starts_with('[') { host.split(']').next().unwrap_or(host).trim_start_matches('[') } else { host.split(':').next().unwrap_or(host) }; + let host_without_port = if host.starts_with('[') { + host.split(']') + .next() + .unwrap_or(host) + .trim_start_matches('[') + } else { + host.split(':').next().unwrap_or(host) + }; let default_valid_hosts = ["localhost", "127.0.0.1"]; - if default_valid_hosts.iter().any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase()) { + if default_valid_hosts + .iter() + .any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase()) + { return true; } - - trusted_hosts.iter().any(|valid| host_without_port.to_lowercase() == valid.to_lowercase()) + + trusted_hosts + .iter() + .any(|valid| host_without_port.to_lowercase() == valid.to_lowercase()) } /// Starts the proxy server @@ -168,6 +200,7 @@ pub async fn start_server( port: u16, prefix: String, auth_token: String, + api_key: String, trusted_hosts: Vec, ) -> Result> { // Check if server is already running @@ -186,6 +219,7 @@ pub async fn start_server( upstream: "http://127.0.0.1:39291".to_string(), prefix, auth_token, + api_key, trusted_hosts, }; diff --git a/web-app/src/containers/ApiKeyInput.tsx b/web-app/src/containers/ApiKeyInput.tsx new file mode 100644 index 000000000..4ea717f1f --- /dev/null +++ b/web-app/src/containers/ApiKeyInput.tsx @@ -0,0 +1,88 @@ +import { Input } from '@/components/ui/input' +import { useLocalApiServer } from '@/hooks/useLocalApiServer' +import { useState, useEffect } from 'react' +import { Eye, EyeOff } from 'lucide-react' + +interface ApiKeyInputProps { + showError?: boolean + onValidationChange?: (isValid: boolean) => void +} + +export function ApiKeyInput({ + showError = false, + onValidationChange, +}: ApiKeyInputProps) { + const { apiKey, setApiKey } = useLocalApiServer() + const [inputValue, setInputValue] = useState(apiKey.toString()) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + + const validateApiKey = (value: string) => { + if (!value || value.trim().length === 0) { + setError('API Key is required') + onValidationChange?.(false) + return false + } + setError('') + onValidationChange?.(true) + return true + } + + useEffect(() => { + if (showError) { + validateApiKey(inputValue) + } + }, [showError, inputValue]) + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + + // Clear error when user starts typing + if (error && value.trim().length > 0) { + setError('') + onValidationChange?.(true) + } + } + + const handleBlur = () => { + setApiKey(inputValue) + // Validate on blur if showError is true + if (showError) { + validateApiKey(inputValue) + } + } + + const hasError = error && showError + + return ( +
+ +
+ +
+ {hasError && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/web-app/src/hooks/useLocalApiServer.ts b/web-app/src/hooks/useLocalApiServer.ts index 0ddf64835..8080d4d2d 100644 --- a/web-app/src/hooks/useLocalApiServer.ts +++ b/web-app/src/hooks/useLocalApiServer.ts @@ -21,6 +21,8 @@ type LocalApiServerState = { // Verbose server logs verboseLogs: boolean setVerboseLogs: (value: boolean) => void + apiKey: string + setApiKey: (value: string) => void // Trusted hosts trustedHosts: string[] addTrustedHost: (host: string) => void @@ -53,6 +55,8 @@ export const useLocalApiServer = create()( trustedHosts: state.trustedHosts.filter((h) => h !== host), })), setTrustedHosts: (hosts) => set({ trustedHosts: hosts }), + apiKey: '', + setApiKey: (value) => set({ apiKey: value }), }), { name: localStorageKey.settingLocalApiServer, diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index 3f935b333..fa8064976 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -16,6 +16,8 @@ import { useAppState } from '@/hooks/useAppState' import { windowKey } from '@/constants/windows' import { IconLogs } from '@tabler/icons-react' import { cn } from '@/lib/utils' +import { ApiKeyInput } from '@/containers/ApiKeyInput' +import { useState } from 'react' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.local_api_server as any)({ @@ -32,12 +34,30 @@ function LocalAPIServer() { serverHost, serverPort, apiPrefix, + apiKey, trustedHosts, } = useLocalApiServer() const { serverStatus, setServerStatus } = useAppState() + const [showApiKeyError, setShowApiKeyError] = useState(false) + const [isApiKeyEmpty, setIsApiKeyEmpty] = useState( + !apiKey || apiKey.toString().trim().length === 0 + ) + + const handleApiKeyValidation = (isValid: boolean) => { + setIsApiKeyEmpty(!isValid) + } const toggleAPIServer = async () => { + // Validate API key before starting server + if (serverStatus === 'stopped') { + if (!apiKey || apiKey.toString().trim().length === 0) { + setShowApiKeyError(true) + return + } + setShowApiKeyError(false) + } + setServerStatus('pending') if (serverStatus === 'stopped') { window.core?.api @@ -45,6 +65,7 @@ function LocalAPIServer() { host: serverHost, port: serverPort, prefix: apiPrefix, + apiKey, trustedHosts, isCorsEnabled: corsEnabled, isVerboseEnabled: verboseLogs, @@ -52,6 +73,10 @@ function LocalAPIServer() { .then(() => { setServerStatus('running') }) + .catch((error: unknown) => { + console.error('Error starting server:', error) + setServerStatus('stopped') + }) } else { window.core?.api ?.stopServer() @@ -182,6 +207,20 @@ function LocalAPIServer() { )} actions={} /> + + } + />