mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 00:19:40 +08:00
✨ feat(security): 前端状态迁移至无明文密钥存储
This commit is contained in:
@@ -17,6 +17,8 @@ import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, is
|
||||
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
|
||||
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
|
||||
import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
import { createGlobalProxyDraft, toSaveGlobalProxyInput } from './utils/globalProxyDraft';
|
||||
import { LEGACY_PERSIST_KEY, readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './utils/legacyConnectionStorage';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
@@ -35,7 +37,7 @@ import {
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './utils/aiEntryLayout';
|
||||
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -76,6 +78,8 @@ function App() {
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const globalProxy = useStore(state => state.globalProxy);
|
||||
const setGlobalProxy = useStore(state => state.setGlobalProxy);
|
||||
const replaceConnections = useStore(state => state.replaceConnections);
|
||||
const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const updateShortcut = useStore(state => state.updateShortcut);
|
||||
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
|
||||
@@ -100,14 +104,14 @@ function App() {
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false);
|
||||
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
|
||||
const sidebarWidth = useStore(state => state.sidebarWidth);
|
||||
const setSidebarWidth = useStore(state => state.setSidebarWidth);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const toggleAIPanel = useStore(state => state.toggleAIPanel);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
@@ -167,6 +171,90 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadSecureConfig = async () => {
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
const persistedPayload = typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(LEGACY_PERSIST_KEY)
|
||||
: null;
|
||||
const legacy = readLegacyPersistedSecrets(persistedPayload);
|
||||
|
||||
let importedLegacyConnections = false;
|
||||
let importedLegacyGlobalProxy = false;
|
||||
|
||||
if (legacy.connections.length > 0) {
|
||||
if (typeof backendApp?.ImportLegacyConnections === 'function') {
|
||||
try {
|
||||
await backendApp.ImportLegacyConnections(
|
||||
legacy.connections.map(({ id, name, config }) => ({ id, name, config }))
|
||||
);
|
||||
importedLegacyConnections = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to import legacy saved connections', err);
|
||||
}
|
||||
} else {
|
||||
replaceConnections(legacy.connections);
|
||||
}
|
||||
}
|
||||
|
||||
if (legacy.globalProxy) {
|
||||
if (typeof backendApp?.ImportLegacyGlobalProxy === 'function') {
|
||||
try {
|
||||
await backendApp.ImportLegacyGlobalProxy(toSaveGlobalProxyInput(legacy.globalProxy));
|
||||
importedLegacyGlobalProxy = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to import legacy global proxy', err);
|
||||
}
|
||||
} else {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(legacy.globalProxy));
|
||||
}
|
||||
}
|
||||
|
||||
if ((importedLegacyConnections || importedLegacyGlobalProxy) && persistedPayload && typeof window !== 'undefined') {
|
||||
const sanitizedPayload = stripLegacyPersistedSecrets(persistedPayload);
|
||||
if (sanitizedPayload && sanitizedPayload !== persistedPayload) {
|
||||
window.localStorage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof backendApp?.GetSavedConnections === 'function') {
|
||||
try {
|
||||
const savedConnections = await backendApp.GetSavedConnections();
|
||||
if (!cancelled && Array.isArray(savedConnections)) {
|
||||
replaceConnections(savedConnections);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load saved connections from backend', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof backendApp?.GetGlobalProxyConfig === 'function') {
|
||||
try {
|
||||
const proxyResult = await backendApp.GetGlobalProxyConfig();
|
||||
if (!cancelled && proxyResult?.success && proxyResult.data) {
|
||||
replaceGlobalProxy(createGlobalProxyDraft(proxyResult.data));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load global proxy from backend', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setHasLoadedSecureConfig(true);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSecureConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isStoreHydrated, replaceConnections, replaceGlobalProxy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated || !hasLoadedSecureConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = String(globalProxy.host || '').trim();
|
||||
const port = Number(globalProxy.port);
|
||||
const portValid = Number.isFinite(port) && port > 0 && port <= 65535;
|
||||
@@ -180,57 +268,44 @@ function App() {
|
||||
});
|
||||
globalProxyInvalidHintShownRef.current = true;
|
||||
}
|
||||
} else {
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
void message.destroy('global-proxy-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
|
||||
let cancelled = false;
|
||||
try {
|
||||
ConfigureGlobalProxy(enabledForBackend, {
|
||||
type: globalProxy.type,
|
||||
host,
|
||||
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
user: String(globalProxy.user || '').trim(),
|
||||
password: globalProxy.password || '',
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || res?.success) {
|
||||
return;
|
||||
}
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setHasAppliedInitialGlobalProxy(true);
|
||||
}
|
||||
console.warn("Wails API: ConfigureGlobalProxy unavailable", e);
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
void message.destroy('global-proxy-invalid');
|
||||
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.SaveGlobalProxy !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Promise.resolve(
|
||||
backendApp.SaveGlobalProxy(
|
||||
toSaveGlobalProxyInput({
|
||||
...globalProxy,
|
||||
host,
|
||||
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
})
|
||||
)
|
||||
)
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
void message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isStoreHydrated,
|
||||
hasLoadedSecureConfig,
|
||||
globalProxy.enabled,
|
||||
globalProxy.type,
|
||||
globalProxy.host,
|
||||
@@ -2490,3 +2565,5 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
|
||||
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
@@ -34,6 +35,7 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
hasPassword: false,
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
@@ -246,6 +248,7 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
|
||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||
...raw,
|
||||
id: toTrimmedString(raw.id ?? raw.ID),
|
||||
type,
|
||||
host: toTrimmedString(raw.host, 'localhost') || 'localhost',
|
||||
port: normalizePort(raw.port, defaultPort),
|
||||
@@ -321,7 +324,16 @@ const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
config,
|
||||
config: { ...config, id: config.id || id },
|
||||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||||
hasPrimaryPassword: raw.hasPrimaryPassword === true,
|
||||
hasSSHPassword: raw.hasSSHPassword === true,
|
||||
hasProxyPassword: raw.hasProxyPassword === true,
|
||||
hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true,
|
||||
hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true,
|
||||
hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true,
|
||||
hasOpaqueURI: raw.hasOpaqueURI === true,
|
||||
hasOpaqueDSN: raw.hasOpaqueDSN === true,
|
||||
includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined,
|
||||
includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
|
||||
};
|
||||
@@ -393,10 +405,6 @@ export interface QueryOptions {
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
connectionTags: ConnectionTag[];
|
||||
@@ -440,6 +448,7 @@ interface AppState {
|
||||
addConnection: (conn: SavedConnection) => void;
|
||||
updateConnection: (conn: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
replaceConnections: (connections: SavedConnection[]) => void;
|
||||
|
||||
addConnectionTag: (tag: ConnectionTag) => void;
|
||||
updateConnectionTag: (tag: ConnectionTag) => void;
|
||||
@@ -468,6 +477,7 @@ interface AppState {
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
replaceGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
updateShortcut: (action: ShortcutAction, binding: Partial<ShortcutBinding>) => void;
|
||||
@@ -618,18 +628,24 @@ const sanitizeFontSize = (value: unknown): number => {
|
||||
return normalizeIntegerInRange(value, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const sanitizeGlobalProxy = (
|
||||
value: unknown,
|
||||
options: { allowPassword?: boolean } = {}
|
||||
): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5';
|
||||
const fallbackPort = type === 'http' ? 8080 : 1080;
|
||||
const password = toTrimmedString(raw.password);
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(raw.host),
|
||||
port: normalizePort(raw.port, fallbackPort),
|
||||
user: toTrimmedString(raw.user),
|
||||
password: toTrimmedString(raw.password),
|
||||
password: options.allowPassword === false ? '' : password,
|
||||
hasPassword: raw.hasPassword === true || password !== '',
|
||||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -782,6 +798,7 @@ export const useStore = create<AppState>()(
|
||||
connectionIds: tag.connectionIds.filter(cid => cid !== id)
|
||||
}))
|
||||
})),
|
||||
replaceConnections: (connections) => set({ connections: sanitizeConnections(connections) }),
|
||||
|
||||
addConnectionTag: (tag) => set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
|
||||
updateConnectionTag: (tag) => set((state) => ({
|
||||
@@ -963,6 +980,7 @@ export const useStore = create<AppState>()(
|
||||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
replaceGlobalProxy: (proxy) => set({ globalProxy: sanitizeGlobalProxy({ ...DEFAULT_GLOBAL_PROXY, ...proxy }) }),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
updateShortcut: (action, binding) => set((state) => ({
|
||||
@@ -1203,7 +1221,7 @@ export const useStore = create<AppState>()(
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
nextState.connections = [];
|
||||
if (version < 5) {
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
} else {
|
||||
@@ -1215,7 +1233,7 @@ export const useStore = create<AppState>()(
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy, { allowPassword: false });
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.shortcutOptions = sanitizeShortcutOptions(state.shortcutOptions);
|
||||
@@ -1242,7 +1260,7 @@ export const useStore = create<AppState>()(
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connections: currentState.connections,
|
||||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
@@ -1250,7 +1268,7 @@ export const useStore = create<AppState>()(
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
fontSize: sanitizeFontSize(state.fontSize),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy, { allowPassword: false }),
|
||||
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
|
||||
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
|
||||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||||
@@ -1271,7 +1289,6 @@ export const useStore = create<AppState>()(
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
connections: state.connections,
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
@@ -1279,7 +1296,7 @@ export const useStore = create<AppState>()(
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
globalProxy: toPersistedGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
shortcutOptions: state.shortcutOptions,
|
||||
@@ -1298,3 +1315,5 @@ export const useStore = create<AppState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface HTTPTunnelConfig {
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id?: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -70,12 +71,27 @@ export interface SavedConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
config: ConnectionConfig;
|
||||
secretRef?: string;
|
||||
hasPrimaryPassword?: boolean;
|
||||
hasSSHPassword?: boolean;
|
||||
hasProxyPassword?: boolean;
|
||||
hasHttpTunnelPassword?: boolean;
|
||||
hasMySQLReplicaPassword?: boolean;
|
||||
hasMongoReplicaPassword?: boolean;
|
||||
hasOpaqueURI?: boolean;
|
||||
hasOpaqueDSN?: boolean;
|
||||
includeDatabases?: string[];
|
||||
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
|
||||
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
|
||||
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
hasPassword?: boolean;
|
||||
secretRef?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTag {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -243,3 +259,5 @@ export interface AISafetyResult {
|
||||
requiresConfirm: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
35
frontend/src/utils/globalProxyDraft.test.ts
Normal file
35
frontend/src/utils/globalProxyDraft.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft';
|
||||
|
||||
describe('global proxy draft', () => {
|
||||
it('hydrates a secretless draft from backend metadata while keeping password input blank', () => {
|
||||
const draft = createGlobalProxyDraft({
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
hasPassword: true,
|
||||
password: 'should-be-ignored',
|
||||
});
|
||||
|
||||
expect(draft.password).toBe('');
|
||||
expect(draft.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('drops password from persisted metadata but preserves hasPassword', () => {
|
||||
const persisted = toPersistedGlobalProxy({
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
hasPassword: true,
|
||||
});
|
||||
|
||||
expect('password' in persisted).toBe(false);
|
||||
expect(persisted.hasPassword).toBe(true);
|
||||
});
|
||||
});
|
||||
62
frontend/src/utils/globalProxyDraft.ts
Normal file
62
frontend/src/utils/globalProxyDraft.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { GlobalProxyConfig } from '../types';
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
|
||||
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
|
||||
};
|
||||
|
||||
const normalizePort = (value: unknown, fallbackPort: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = Math.trunc(parsed);
|
||||
if (port <= 0 || port > 65535) {
|
||||
return fallbackPort;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
export function createGlobalProxyDraft(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
|
||||
const type = normalizeProxyType(value.type);
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(value.host),
|
||||
port: normalizePort(value.port, type === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(value.user),
|
||||
password: '',
|
||||
hasPassword: value.hasPassword === true,
|
||||
secretRef: toTrimmedString(value.secretRef) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedGlobalProxy(value: Partial<GlobalProxyConfig> = {}): Omit<GlobalProxyConfig, 'password'> {
|
||||
const draft = createGlobalProxyDraft(value);
|
||||
return {
|
||||
enabled: draft.enabled,
|
||||
type: draft.type,
|
||||
host: draft.host,
|
||||
port: draft.port,
|
||||
user: draft.user,
|
||||
hasPassword: draft.hasPassword,
|
||||
secretRef: draft.secretRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function toSaveGlobalProxyInput(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
|
||||
const draft = createGlobalProxyDraft(value);
|
||||
return {
|
||||
...draft,
|
||||
password: typeof value.password === 'string' ? value.password : '',
|
||||
};
|
||||
}
|
||||
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal file
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
|
||||
|
||||
describe('legacy connection storage', () => {
|
||||
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = readLegacyPersistedSecrets(payload);
|
||||
expect(result.connections).toHaveLength(1);
|
||||
expect(result.connections[0]?.config.password).toBe('secret');
|
||||
expect(result.globalProxy?.password).toBe('proxy-secret');
|
||||
});
|
||||
|
||||
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
|
||||
const payload = JSON.stringify({
|
||||
state: {
|
||||
connections: [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
globalProxy: {
|
||||
enabled: true,
|
||||
type: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
user: 'ops',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sanitized = stripLegacyPersistedSecrets(payload);
|
||||
const parsed = JSON.parse(sanitized);
|
||||
|
||||
expect(parsed.state.connections).toEqual([]);
|
||||
expect(parsed.state.globalProxy.password).toBeUndefined();
|
||||
expect(parsed.state.globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
});
|
||||
110
frontend/src/utils/legacyConnectionStorage.ts
Normal file
110
frontend/src/utils/legacyConnectionStorage.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { GlobalProxyConfig, SavedConnection } from '../types';
|
||||
|
||||
export const LEGACY_PERSIST_KEY = 'lite-db-storage';
|
||||
|
||||
const toTrimmedString = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
|
||||
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
|
||||
};
|
||||
|
||||
const normalizePort = (value: unknown, fallbackPort: number): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallbackPort;
|
||||
}
|
||||
const port = Math.trunc(parsed);
|
||||
if (port <= 0 || port > 65535) {
|
||||
return fallbackPort;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
const parsePersistedEnvelope = (payload: string | null | undefined): Record<string, unknown> => {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
if (parsed.state && typeof parsed.state === 'object') {
|
||||
return parsed.state as Record<string, unknown>;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export function readLegacyPersistedSecrets(payload: string | null | undefined): {
|
||||
connections: SavedConnection[];
|
||||
globalProxy: GlobalProxyConfig | null;
|
||||
} {
|
||||
const state = parsePersistedEnvelope(payload);
|
||||
const connections = Array.isArray(state.connections)
|
||||
? state.connections.filter((item): item is SavedConnection => !!item && typeof item === 'object')
|
||||
: [];
|
||||
|
||||
const proxyRaw = state.globalProxy && typeof state.globalProxy === 'object'
|
||||
? state.globalProxy as Record<string, unknown>
|
||||
: null;
|
||||
if (!proxyRaw) {
|
||||
return { connections, globalProxy: null };
|
||||
}
|
||||
|
||||
const type = normalizeProxyType(proxyRaw.type);
|
||||
const password = toTrimmedString(proxyRaw.password);
|
||||
const globalProxy: GlobalProxyConfig = {
|
||||
enabled: proxyRaw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(proxyRaw.host),
|
||||
port: normalizePort(proxyRaw.port, type === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
password,
|
||||
hasPassword: proxyRaw.hasPassword === true || password !== '',
|
||||
secretRef: toTrimmedString(proxyRaw.secretRef) || undefined,
|
||||
};
|
||||
|
||||
const hasMeaningfulProxyState = globalProxy.enabled || globalProxy.host !== '' || globalProxy.user !== '' || globalProxy.password !== '' || globalProxy.hasPassword === true;
|
||||
return {
|
||||
connections,
|
||||
globalProxy: hasMeaningfulProxyState ? globalProxy : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const state = parsed.state && typeof parsed.state === 'object'
|
||||
? parsed.state as Record<string, unknown>
|
||||
: parsed;
|
||||
state.connections = [];
|
||||
|
||||
if (state.globalProxy && typeof state.globalProxy === 'object') {
|
||||
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
|
||||
const password = toTrimmedString(proxy.password);
|
||||
delete proxy.password;
|
||||
if (password !== '') {
|
||||
proxy.hasPassword = true;
|
||||
}
|
||||
state.globalProxy = proxy;
|
||||
}
|
||||
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
@@ -10,10 +10,10 @@ describe('startup readiness helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
|
||||
it('keeps sidebar blocked until secure config bootstrap finishes', () => {
|
||||
expect(getConnectionWorkbenchState(true, false)).toEqual({
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
message: '正在加载安全配置...',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,3 +24,4 @@ describe('startup readiness helpers', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function getConnectionWorkbenchState(
|
||||
if (!hasAppliedInitialGlobalProxy) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
message: '正在加载安全配置...',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -24,3 +24,4 @@ export function getConnectionWorkbenchState(
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user