TradingAgents/frontend/lib/storage.ts

309 lines
8.6 KiB
TypeScript

/**
* localStorage/sessionStorage utility for API settings with encryption
*
* Storage Strategy:
* - Logged-in users: localStorage with encryption (persistent)
* - Anonymous users: sessionStorage (cleared on browser close)
*
* API keys are encrypted using AES-256-GCM before storage (localStorage only)
*/
import { encryptObject, decryptObject, isEncrypted, clearCryptoData } from "./crypto";
export interface ApiSettings {
// Required providers
openai_api_key: string;
alpha_vantage_api_key: string;
// Optional providers
anthropic_api_key: string;
google_api_key: string;
grok_api_key: string;
deepseek_api_key: string;
qwen_api_key: string;
finmind_api_key: string; // 台灣股市資料 API
// Custom endpoint
custom_base_url: string;
custom_api_key: string;
}
// Storage keys
const STORAGE_KEY = "tradingagents_api_settings";
const ENCRYPTED_FLAG_KEY = "tradingagents_encrypted";
const AUTH_TOKEN_KEY = "tradingagents_auth_token";
// Storage mode type
export type StorageMode = "local" | "session";
export const DEFAULT_API_SETTINGS: ApiSettings = {
openai_api_key: "",
alpha_vantage_api_key: "",
anthropic_api_key: "",
google_api_key: "",
grok_api_key: "",
deepseek_api_key: "",
qwen_api_key: "",
finmind_api_key: "",
custom_base_url: "",
custom_api_key: "",
};
/**
* Check if user is currently authenticated
*/
function isUserAuthenticated(): boolean {
if (typeof window === "undefined") return false;
const token = localStorage.getItem(AUTH_TOKEN_KEY);
if (!token) return false;
// Check if token is expired
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const exp = payload.exp * 1000;
return Date.now() < exp;
} catch {
return false;
}
}
/**
* Get the appropriate storage based on authentication status
* - Authenticated: localStorage (persistent)
* - Anonymous: sessionStorage (cleared on browser close)
*/
function getStorage(): Storage {
if (typeof window === "undefined") {
// Return a mock storage for SSR
return {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
length: 0,
key: () => null,
};
}
return isUserAuthenticated() ? localStorage : sessionStorage;
}
/**
* Get current storage mode
*/
export function getCurrentStorageMode(): StorageMode {
return isUserAuthenticated() ? "local" : "session";
}
/**
* 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 (async due to potential decryption)
*
* Storage strategy:
* - Authenticated users: Read from localStorage (encrypted)
* - Anonymous users: Read from sessionStorage (plaintext, cleared on browser close)
*/
export async function getApiSettingsAsync(): Promise<ApiSettings> {
if (typeof window === "undefined") {
return DEFAULT_API_SETTINGS;
}
const storage = getStorage();
const authenticated = isUserAuthenticated();
try {
const stored = storage.getItem(STORAGE_KEY);
if (!stored) {
// If authenticated, also check sessionStorage for data to migrate
if (authenticated) {
const sessionData = sessionStorage.getItem(STORAGE_KEY);
if (sessionData) {
console.log("Migrating session data to localStorage after login");
const parsed = JSON.parse(sessionData);
const merged = { ...DEFAULT_API_SETTINGS, ...parsed };
await saveApiSettingsAsync(merged);
sessionStorage.removeItem(STORAGE_KEY);
return merged;
}
}
return DEFAULT_API_SETTINGS;
}
const parsed = JSON.parse(stored);
// For authenticated users, decrypt the data
if (authenticated) {
// 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 };
}
// For anonymous users, data is stored as plaintext in sessionStorage
return { ...DEFAULT_API_SETTINGS, ...parsed };
} 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") {
return DEFAULT_API_SETTINGS;
}
const storage = getStorage();
try {
const stored = storage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_API_SETTINGS, ...parsed };
}
} catch (error) {
console.error("Error reading API settings:", error);
}
return DEFAULT_API_SETTINGS;
}
/**
* Save API settings (async)
*
* Storage strategy:
* - Authenticated users: Save to localStorage with encryption (persistent)
* - Anonymous users: Save to sessionStorage as plaintext (cleared on browser close)
*/
export async function saveApiSettingsAsync(settings: ApiSettings): Promise<void> {
if (typeof window === "undefined") {
return;
}
const storage = getStorage();
const authenticated = isUserAuthenticated();
try {
if (authenticated) {
// For authenticated users, encrypt and store in localStorage
const encrypted = await encryptObject(settings as unknown as Record<string, string>);
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted));
localStorage.setItem(ENCRYPTED_FLAG_KEY, "true");
console.log("API settings saved to localStorage (encrypted)");
} else {
// For anonymous users, store as plaintext in sessionStorage
// This will be automatically cleared when browser closes
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
console.log("API settings saved to sessionStorage (will clear on browser close)");
}
} 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;
}
// Call async version and ignore the promise (for backward compatibility)
saveApiSettingsAsync(settings).catch(console.error);
}
/**
* Clear API settings from both localStorage and sessionStorage
*/
export function clearApiSettings(): void {
if (typeof window === "undefined") {
return;
}
try {
// Clear from localStorage
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ENCRYPTED_FLAG_KEY);
// Clear from sessionStorage
sessionStorage.removeItem(STORAGE_KEY);
// Clear crypto data
clearCryptoData();
console.log("API settings cleared from all storage");
} catch (error) {
console.error("Error clearing API settings:", 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;
}
}