This commit is contained in:
parent
5e48737ab8
commit
8204aff28b
|
|
@ -1,11 +1,15 @@
|
|||
"""
|
||||
FastAPI application entry point for TradingAgentsX Backend
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.cors import setup_cors
|
||||
|
|
@ -18,6 +22,105 @@ logging.basicConfig(
|
|||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all responses"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
# Security headers
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple in-memory rate limiting middleware"""
|
||||
|
||||
def __init__(self, app, max_requests: int = 30, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.requests: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip rate limiting for health checks
|
||||
if request.url.path == "/api/health":
|
||||
return await call_next(request)
|
||||
|
||||
# Get client IP
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for.split(",")[0].strip()
|
||||
|
||||
# Clean old requests
|
||||
now = time.time()
|
||||
cutoff = now - self.window_seconds
|
||||
self.requests[client_ip] = [
|
||||
t for t in self.requests[client_ip] if t > cutoff
|
||||
]
|
||||
|
||||
# Check rate limit
|
||||
if len(self.requests[client_ip]) >= self.max_requests:
|
||||
retry_after = int(self.window_seconds - (now - self.requests[client_ip][0]))
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "Too many requests",
|
||||
"message": f"Rate limit exceeded. Please wait {retry_after} seconds.",
|
||||
"retry_after": retry_after,
|
||||
},
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Record this request
|
||||
self.requests[client_ip].append(now)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add rate limit headers
|
||||
remaining = self.max_requests - len(self.requests[client_ip])
|
||||
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
|
||||
response.headers["X-RateLimit-Reset"] = str(int(now + self.window_seconds))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Filter to mask API keys in log messages"""
|
||||
|
||||
SENSITIVE_PATTERNS = ["api_key", "apikey", "api-key", "token", "secret", "password"]
|
||||
|
||||
def filter(self, record):
|
||||
if hasattr(record, 'msg') and isinstance(record.msg, str):
|
||||
msg = record.msg
|
||||
for pattern in self.SENSITIVE_PATTERNS:
|
||||
if pattern.lower() in msg.lower():
|
||||
# Mask the value after the pattern
|
||||
import re
|
||||
msg = re.sub(
|
||||
rf'({pattern}["\']?\s*[=:]\s*["\']?)([^"\'\s,}}]+)',
|
||||
r'\1***MASKED***',
|
||||
msg,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
record.msg = msg
|
||||
return True
|
||||
|
||||
|
||||
# Add sensitive data filter to all loggers
|
||||
for handler in logging.root.handlers:
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
|
|
@ -27,6 +130,10 @@ app = FastAPI(
|
|||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# Add security middleware (order matters - added first, executed last)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware, max_requests=30, window_seconds=60)
|
||||
|
||||
# Setup CORS
|
||||
setup_cors(app)
|
||||
|
||||
|
|
@ -47,13 +154,25 @@ async def root():
|
|||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Global exception handler"""
|
||||
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
||||
"""Global exception handler - masks sensitive data in errors"""
|
||||
error_msg = str(exc)
|
||||
|
||||
# Mask any API keys that might be in the error message
|
||||
import re
|
||||
patterns = ["sk-", "sk-ant-", "xai-", "AIza"]
|
||||
for pattern in patterns:
|
||||
error_msg = re.sub(
|
||||
rf'{pattern}[a-zA-Z0-9_-]+',
|
||||
f'{pattern}***MASKED***',
|
||||
error_msg
|
||||
)
|
||||
|
||||
logger.error(f"Unhandled exception: {error_msg}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "Internal server error",
|
||||
"detail": str(exc), # Always return detailed error for user debugging
|
||||
"detail": error_msg,
|
||||
"type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,10 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getApiSettings,
|
||||
saveApiSettings,
|
||||
getApiSettingsAsync,
|
||||
saveApiSettingsAsync,
|
||||
clearApiSettings,
|
||||
migrateToEncrypted,
|
||||
type ApiSettings,
|
||||
DEFAULT_API_SETTINGS,
|
||||
} from "@/lib/storage";
|
||||
|
|
@ -59,25 +60,38 @@ type FormValues = z.infer<typeof formSchema>;
|
|||
export function ApiSettingsDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: getApiSettings(),
|
||||
defaultValues: DEFAULT_API_SETTINGS,
|
||||
});
|
||||
|
||||
// Load settings when dialog opens
|
||||
// Load and decrypt settings when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const settings = getApiSettings();
|
||||
form.reset(settings);
|
||||
setLoading(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
// First try to migrate legacy settings
|
||||
migrateToEncrypted().then(() => {
|
||||
// Then load decrypted settings
|
||||
return getApiSettingsAsync();
|
||||
}).then((settings) => {
|
||||
form.reset(settings);
|
||||
}).catch((error) => {
|
||||
console.error("Failed to load settings:", error);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Type assertion since our form values match ApiSettings structure
|
||||
saveApiSettings(values as ApiSettings);
|
||||
// Encrypt and save settings
|
||||
await saveApiSettingsAsync(values as ApiSettings);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
|
|
@ -85,6 +99,8 @@ export function ApiSettingsDialog() {
|
|||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -110,7 +126,10 @@ export function ApiSettingsDialog() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>API 配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
設定您的 API 金鑰。這些資訊會儲存在瀏覽器的本機儲存空間中。
|
||||
設定您的 API 金鑰。這些資訊會以加密形式儲存在瀏覽器中。
|
||||
<span className="block mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
🔒 已啟用 AES-256-GCM 加密保護
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -335,8 +354,8 @@ export function ApiSettingsDialog() {
|
|||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button type="submit" className="flex-1">
|
||||
儲存設定
|
||||
<Button type="submit" className="flex-1" disabled={loading}>
|
||||
{loading ? "處理中..." : "儲存設定"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Crypto utilities for secure API key storage
|
||||
* Uses AES-256-GCM encryption with Web Crypto API
|
||||
*/
|
||||
|
||||
// Storage keys
|
||||
const SALT_KEY = "tradingagents_crypto_salt";
|
||||
const IV_PREFIX = "tradingagents_iv_";
|
||||
|
||||
/**
|
||||
* Generate a random salt for key derivation
|
||||
*/
|
||||
function generateSalt(): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the salt stored in localStorage
|
||||
*/
|
||||
function getOrCreateSalt(): Uint8Array {
|
||||
if (typeof window === "undefined") {
|
||||
return new Uint8Array(16);
|
||||
}
|
||||
|
||||
const storedSalt = localStorage.getItem(SALT_KEY);
|
||||
if (storedSalt) {
|
||||
return Uint8Array.from(atob(storedSalt), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
const newSalt = generateSalt();
|
||||
localStorage.setItem(SALT_KEY, btoa(String.fromCharCode(...newSalt)));
|
||||
return newSalt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a browser fingerprint for key derivation
|
||||
* This creates a semi-unique identifier based on browser characteristics
|
||||
*/
|
||||
function getBrowserFingerprint(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "server-side";
|
||||
}
|
||||
|
||||
const components = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
screen.width.toString(),
|
||||
screen.height.toString(),
|
||||
screen.colorDepth.toString(),
|
||||
new Date().getTimezoneOffset().toString(),
|
||||
navigator.hardwareConcurrency?.toString() || "unknown",
|
||||
];
|
||||
|
||||
return components.join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an encryption key from the browser fingerprint using PBKDF2
|
||||
*/
|
||||
async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
|
||||
const fingerprint = getBrowserFingerprint();
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(fingerprint),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits", "deriveKey"]
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: salt.buffer as ArrayBuffer, // Cast to ArrayBuffer to fix TypeScript error
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a string using AES-256-GCM
|
||||
*/
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
if (typeof window === "undefined" || !plaintext) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = getOrCreateSalt();
|
||||
const key = await deriveKey(salt);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
encoder.encode(plaintext)
|
||||
);
|
||||
|
||||
// Combine IV + encrypted data and encode as base64
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
} catch (error) {
|
||||
console.error("Encryption failed:", error);
|
||||
throw new Error("Failed to encrypt data");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a string using AES-256-GCM
|
||||
*/
|
||||
export async function decrypt(ciphertext: string): Promise<string> {
|
||||
if (typeof window === "undefined" || !ciphertext) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = getOrCreateSalt();
|
||||
const key = await deriveKey(salt);
|
||||
|
||||
// Decode from base64
|
||||
const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
|
||||
|
||||
// Extract IV (first 12 bytes) and encrypted data
|
||||
const iv = combined.slice(0, 12);
|
||||
const encrypted = combined.slice(12);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error("Decryption failed:", error);
|
||||
throw new Error("Failed to decrypt data");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string appears to be encrypted (base64 with proper length)
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
if (!value || value.length < 20) return false;
|
||||
|
||||
try {
|
||||
// Try to decode as base64
|
||||
const decoded = atob(value);
|
||||
// Encrypted data should be at least 12 (IV) + 16 (min ciphertext) bytes
|
||||
return decoded.length >= 28;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an object (for API settings)
|
||||
*/
|
||||
export async function encryptObject(obj: Record<string, string>): Promise<Record<string, string>> {
|
||||
const encrypted: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === "string" && value.trim() !== "") {
|
||||
// Only encrypt non-empty values that look like API keys
|
||||
if (key.includes("api_key") || key.includes("api_secret")) {
|
||||
encrypted[key] = await encrypt(value);
|
||||
} else {
|
||||
encrypted[key] = value;
|
||||
}
|
||||
} else {
|
||||
encrypted[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an object (for API settings)
|
||||
*/
|
||||
export async function decryptObject(obj: Record<string, string>): Promise<Record<string, string>> {
|
||||
const decrypted: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === "string" && isEncrypted(value)) {
|
||||
try {
|
||||
decrypted[key] = await decrypt(value);
|
||||
} catch {
|
||||
// If decryption fails, the data might be corrupted or from a different device
|
||||
decrypted[key] = "";
|
||||
}
|
||||
} else {
|
||||
decrypted[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all crypto-related data (useful for logout/reset)
|
||||
*/
|
||||
export function clearCryptoData(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
localStorage.removeItem(SALT_KEY);
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
/**
|
||||
* localStorage utility for API settings
|
||||
* localStorage utility for API settings with encryption
|
||||
* API keys are encrypted using AES-256-GCM before storage
|
||||
*/
|
||||
|
||||
import { encryptObject, decryptObject, isEncrypted, clearCryptoData } from "./crypto";
|
||||
|
||||
export interface ApiSettings {
|
||||
// Required providers
|
||||
openai_api_key: string;
|
||||
|
|
@ -21,6 +24,7 @@ export interface ApiSettings {
|
|||
}
|
||||
|
||||
const STORAGE_KEY = "tradingagents_api_settings";
|
||||
const ENCRYPTED_FLAG_KEY = "tradingagents_encrypted";
|
||||
|
||||
export const DEFAULT_API_SETTINGS: ApiSettings = {
|
||||
openai_api_key: "",
|
||||
|
|
@ -30,13 +34,77 @@ export const DEFAULT_API_SETTINGS: ApiSettings = {
|
|||
grok_api_key: "",
|
||||
deepseek_api_key: "",
|
||||
qwen_api_key: "",
|
||||
finmind_api_key: "", // 台灣股市資料 API
|
||||
finmind_api_key: "",
|
||||
custom_base_url: "",
|
||||
custom_api_key: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get API settings from localStorage
|
||||
* Check if stored data is using legacy (unencrypted) format
|
||||
*/
|
||||
function isLegacyFormat(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const encryptedFlag = localStorage.getItem(ENCRYPTED_FLAG_KEY);
|
||||
if (encryptedFlag === "true") return false;
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Check if any API key looks like plaintext (starts with known prefixes)
|
||||
const apiKeyFields = Object.keys(parsed).filter(k => k.includes("api_key"));
|
||||
for (const field of apiKeyFields) {
|
||||
const value = parsed[field];
|
||||
if (value && typeof value === "string") {
|
||||
// OpenAI keys start with sk-, Anthropic with sk-ant-, etc.
|
||||
if (value.startsWith("sk-") || value.startsWith("AIza") || value.length < 50) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API settings from localStorage (async due to decryption)
|
||||
*/
|
||||
export async function getApiSettingsAsync(): Promise<ApiSettings> {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_API_SETTINGS;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return DEFAULT_API_SETTINGS;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
// If legacy format detected, return as-is (will be encrypted on next save)
|
||||
if (isLegacyFormat()) {
|
||||
console.warn("Legacy unencrypted settings detected. Will encrypt on next save.");
|
||||
return { ...DEFAULT_API_SETTINGS, ...parsed };
|
||||
}
|
||||
|
||||
// Decrypt the settings
|
||||
const decrypted = await decryptObject(parsed);
|
||||
return { ...DEFAULT_API_SETTINGS, ...decrypted };
|
||||
} catch (error) {
|
||||
console.error("Error reading API settings:", error);
|
||||
return DEFAULT_API_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API settings synchronously (for backward compatibility)
|
||||
* WARNING: This returns encrypted values - use getApiSettingsAsync for actual values
|
||||
*/
|
||||
export function getApiSettings(): ApiSettings {
|
||||
if (typeof window === "undefined") {
|
||||
|
|
@ -47,7 +115,6 @@ export function getApiSettings(): ApiSettings {
|
|||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge with defaults to handle any missing fields
|
||||
return { ...DEFAULT_API_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -58,19 +125,36 @@ export function getApiSettings(): ApiSettings {
|
|||
}
|
||||
|
||||
/**
|
||||
* Save API settings to localStorage
|
||||
* Save API settings to localStorage with encryption
|
||||
*/
|
||||
export async function saveApiSettingsAsync(settings: ApiSettings): Promise<void> {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Encrypt sensitive fields
|
||||
const encrypted = await encryptObject(settings as unknown as Record<string, string>);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
|
||||
localStorage.setItem(ENCRYPTED_FLAG_KEY, "true");
|
||||
} catch (error) {
|
||||
console.error("Error saving API settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API settings synchronously (legacy - not recommended)
|
||||
* WARNING: This saves unencrypted data - use saveApiSettingsAsync instead
|
||||
*/
|
||||
export function saveApiSettings(settings: ApiSettings): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error("Error saving API settings to localStorage:", error);
|
||||
throw error;
|
||||
}
|
||||
// Call async version and ignore the promise (for backward compatibility)
|
||||
saveApiSettingsAsync(settings).catch(console.error);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +167,37 @@ export function clearApiSettings(): void {
|
|||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ENCRYPTED_FLAG_KEY);
|
||||
clearCryptoData();
|
||||
} catch (error) {
|
||||
console.error("Error clearing API settings from localStorage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy unencrypted settings to encrypted format
|
||||
*/
|
||||
export async function migrateToEncrypted(): Promise<boolean> {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
if (!isLegacyFormat()) {
|
||||
return false; // Already encrypted or no data
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return false;
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
const merged = { ...DEFAULT_API_SETTINGS, ...parsed };
|
||||
|
||||
// Save with encryption
|
||||
await saveApiSettingsAsync(merged);
|
||||
|
||||
console.log("Successfully migrated settings to encrypted format");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate settings:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,58 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
reactCompiler: true,
|
||||
|
||||
// Security headers
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// Apply to all routes
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-DNS-Prefetch-Control',
|
||||
value: 'on'
|
||||
},
|
||||
{
|
||||
key: 'X-XSS-Protection',
|
||||
value: '1; mode=block'
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY'
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff'
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin'
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()'
|
||||
},
|
||||
{
|
||||
// Content Security Policy
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Required for Next.js
|
||||
"style-src 'self' 'unsafe-inline'", // Required for Tailwind
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://api.openai.com https://api.anthropic.com https://api.x.ai https://api.deepseek.com https://dashscope-intl.aliyuncs.com https://generativelanguage.googleapis.com https://*.alphavantage.co https://api.finmindtrade.com",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; ')
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
// In development: use localhost backend
|
||||
// In production (Railway): use BACKEND_URL env var or fallback to Railway URL
|
||||
|
|
|
|||
Loading…
Reference in New Issue