From 619cc84d1516dd04be69f18b2b731c7381690169 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Tue, 24 Mar 2026 03:55:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20api=E6=8E=A5=E5=8F=A3=E6=96=B0=E5=A2=9E?= =?UTF-8?q?access=5Ftoken=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/config.ts | 4 +- electron/services/httpService.ts | 129 ++++++++++++++++++++++--------- src/pages/SettingsPage.tsx | 75 +++++++++++++++--- src/services/config.ts | 12 +++ 4 files changed, 171 insertions(+), 49 deletions(-) diff --git a/electron/services/config.ts b/electron/services/config.ts index c293ee1..97c733b 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -52,13 +52,14 @@ interface ConfigSchema { notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] messagePushEnabled: boolean + httpApiToken: string windowCloseBehavior: 'ask' | 'tray' | 'quit' quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } // 需要 safeStorage 加密的字段(普通模式) -const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword']) +const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken']) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -119,6 +120,7 @@ export class ConfigService { notificationPosition: 'top-right', notificationFilterMode: 'all', notificationFilterList: [], + httpApiToken: '', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b59a014..78b4162 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -246,49 +246,102 @@ class HttpService { } } - /** - * 处理 HTTP 请求 - */ - private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { - // 设置 CORS 头 - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') - - if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - return + /** + * 解析 POST 请求的 JSON Body + */ + private async parseBody(req: http.IncomingMessage): Promise> { + if (req.method !== 'POST') return {} + return new Promise((resolve) => { + let body = '' + req.on('data', chunk => { body += chunk.toString() }) + req.on('end', () => { + try { + resolve(JSON.parse(body)) + } catch { + resolve({}) + } + }) + req.on('error', () => resolve({})) + }) } - const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`) - const pathname = url.pathname + /** + * 鉴权拦截器 + */ + private verifyToken(req: http.IncomingMessage, url: URL, body: Record): boolean { + const expectedToken = String(this.configService.get('httpApiToken') || '').trim() + if (!expectedToken) return true - try { - // 路由处理 - if (pathname === '/health' || pathname === '/api/v1/health') { - this.sendJson(res, { status: 'ok' }) - } else if (pathname === '/api/v1/push/messages') { - this.handleMessagePushStream(req, res) - } else if (pathname === '/api/v1/messages') { - await this.handleMessages(url, res) - } else if (pathname === '/api/v1/sessions') { - await this.handleSessions(url, res) - } else if (pathname === '/api/v1/contacts') { - await this.handleContacts(url, res) - } else if (pathname === '/api/v1/group-members') { - await this.handleGroupMembers(url, res) - } else if (pathname.startsWith('/api/v1/media/')) { - this.handleMediaRequest(pathname, res) - } else { - this.sendError(res, 404, 'Not Found') - } - } catch (error) { - console.error('[HttpService] Request error:', error) - this.sendError(res, 500, String(error)) + const authHeader = req.headers.authorization + if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7).trim() + if (token === expectedToken) return true + } + + const queryToken = url.searchParams.get('access_token') + if (queryToken && queryToken.trim() === expectedToken) return true + + const bodyToken = body['access_token'] + if (bodyToken && String(bodyToken).trim() === expectedToken) return true + + return false } - } + /** + * 处理 HTTP 请求 (重构后) + */ + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`) + const pathname = url.pathname + + try { + const bodyParams = await this.parseBody(req) + + for (const [key, value] of Object.entries(bodyParams)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, String(value)) + } + } + + if (pathname !== '/health' && pathname !== '/api/v1/health') { + if (!this.verifyToken(req, url, bodyParams)) { + this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token') + return + } + } + + if (pathname === '/health' || pathname === '/api/v1/health') { + this.sendJson(res, { status: 'ok' }) + } else if (pathname === '/api/v1/push/messages') { + this.handleMessagePushStream(req, res) + } else if (pathname === '/api/v1/messages') { + await this.handleMessages(url, res) + } else if (pathname === '/api/v1/sessions') { + await this.handleSessions(url, res) + } else if (pathname === '/api/v1/contacts') { + await this.handleContacts(url, res) + } else if (pathname === '/api/v1/group-members') { + await this.handleGroupMembers(url, res) + } else if (pathname.startsWith('/api/v1/media/')) { + this.handleMediaRequest(pathname, res) + } else { + this.sendError(res, 404, 'Not Found') + } + } catch (error) { + console.error('[HttpService] Request error:', error) + this.sendError(res, 500, String(error)) + } + } private startMessagePushHeartbeat(): void { if (this.messagePushHeartbeatTimer) return this.messagePushHeartbeatTimer = setInterval(() => { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d7c8899..696b799 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -103,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 }) const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) + const [httpApiToken, setHttpApiToken] = useState('') + const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -111,6 +113,23 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; + const generateRandomToken = async () => { + // 生成 32 字符的十六进制随机字符串 (16 bytes) + const array = new Uint8Array(16) + crypto.getRandomValues(array) + const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('') + + setHttpApiToken(token) + await configService.setHttpApiToken(token) + showMessage('已生成并保存新的 Access Token', true) + } + + const clearApiToken = async () => { + setHttpApiToken('') + await configService.setHttpApiToken('') + showMessage('已清除 Access Token,API 将允许无鉴权访问', true) + } + const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [transcribeLanguages, setTranscribeLanguages] = useState(['zh']) @@ -192,6 +211,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { checkWaylandStatus() }, []) + + // 检查 Hello 可用性 useEffect(() => { setHelloAvailable(isWindows) @@ -319,6 +340,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() const savedIsLockMode = await window.electronAPI.auth.isLockMode() + + const savedHttpApiToken = await configService.getHttpApiToken() + if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken) + setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) setIsLockMode(savedIsLockMode) @@ -1901,6 +1926,36 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { /> +
+ + + 设置后,请求头需携带 Authorization: Bearer <token>, + 或者参数中携带 ?access_token=<token> + +
+ { + const val = e.target.value + setHttpApiToken(val) + scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val)) + }} + style={{ flex: 1, fontFamily: 'monospace' }} + /> + + {httpApiToken && ( + + )} +
+
+ {httpApiRunning && (
@@ -1956,18 +2011,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`
diff --git a/src/services/config.ts b/src/services/config.ts index acbbdf9..a80ecf2 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -64,6 +64,7 @@ export const CONFIG_KEYS = { NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_LIST: 'notificationFilterList', + HTTP_API_TOKEN: 'httpApiToken', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', QUOTE_LAYOUT: 'quoteLayout', @@ -117,6 +118,17 @@ export async function getDbPath(): Promise { return value as string | null } +// 获取api access_token +export async function getHttpApiToken(): Promise { + const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN) + return (value as string) || '' +} + +// 设置access_token +export async function setHttpApiToken(token: string): Promise { + await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token) +} + // 设置数据库路径 export async function setDbPath(path: string): Promise { await config.set(CONFIG_KEYS.DB_PATH, path)