feat: Jan API Server should have API Key setting (#5193)
* feat: Jan API Server should have API Key setting * chore: reveal api key icon * Update src-tauri/src/core/server.rs Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: add validation apiKey --------- Co-authored-by: Faisal Amir <urmauur@gmail.com> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
parent
da10502bdd
commit
cb3ac4b136
@ -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<String>,
|
||||
) -> Result<bool, String> {
|
||||
let auth_token = app.state::<AppState>().app_token.clone().unwrap_or_default();
|
||||
server::start_server(host, port, prefix, auth_token, trusted_hosts)
|
||||
let auth_token = app
|
||||
.state::<AppState>()
|
||||
.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)
|
||||
|
||||
@ -20,6 +20,7 @@ struct ProxyConfig {
|
||||
prefix: String,
|
||||
auth_token: String,
|
||||
trusted_hosts: Vec<String>,
|
||||
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<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// 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,
|
||||
};
|
||||
|
||||
|
||||
88
web-app/src/containers/ApiKeyInput.tsx
Normal file
88
web-app/src/containers/ApiKeyInput.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={`w-full text-sm pr-10 ${
|
||||
hasError
|
||||
? 'border-1 border-destructive focus:border-destructive focus:ring-destructive'
|
||||
: ''
|
||||
}`}
|
||||
placeholder="Enter API Key"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1 rounded hover:bg-main-view-fg/5 text-main-view-fg/70"
|
||||
type="button"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{hasError && (
|
||||
<p className="text-destructive text-xs mt-1 absolute -bottom-5 left-0">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<LocalApiServerState>()(
|
||||
trustedHosts: state.trustedHosts.filter((h) => h !== host),
|
||||
})),
|
||||
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
|
||||
apiKey: '',
|
||||
setApiKey: (value) => set({ apiKey: value }),
|
||||
}),
|
||||
{
|
||||
name: localStorageKey.settingLocalApiServer,
|
||||
|
||||
@ -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={<ApiPrefixInput />}
|
||||
/>
|
||||
<CardItem
|
||||
title="API Key"
|
||||
description="Authenticate requests with an API key"
|
||||
className={cn(
|
||||
isServerRunning && 'opacity-50 pointer-events-none',
|
||||
isApiKeyEmpty && showApiKeyError && 'pb-6'
|
||||
)}
|
||||
actions={
|
||||
<ApiKeyInput
|
||||
showError={showApiKeyError}
|
||||
onValidationChange={handleApiKeyValidation}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CardItem
|
||||
title="Trusted Hosts"
|
||||
description="Add trusted hosts that can access the API server"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user