diff --git a/server/umami-analytics.mjs b/server/umami-analytics.mjs index 50d0260..3522f9d 100644 --- a/server/umami-analytics.mjs +++ b/server/umami-analytics.mjs @@ -1,3 +1,5 @@ +import crypto from 'node:crypto'; + const TOKEN_TTL_MS = 55 * 60 * 1000; let cachedToken = ''; @@ -14,13 +16,40 @@ function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) { ); const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim(); const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim(); + const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim(); const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || '').trim(); - return { baseUrl, username, password, websiteId }; + return { baseUrl, username, password, sharedSecret, websiteId }; } -async function loginUmami({ baseUrl, username, password }) { +async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) { + const encoded = Buffer.from( + JSON.stringify({ + username, + exp: Math.floor(Date.now() / 1000) + 60, + nonce: crypto.randomUUID(), + }), + ).toString('base64url'); + const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url'); + const ticket = `${encoded}.${signature}`; + const response = await fetch( + `${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`, + { signal: AbortSignal.timeout(10_000) }, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`); + } + const token = String(body?.token ?? '').trim(); + if (!token) throw new Error('Umami SSO 登录响应缺少 token'); + return token; +} + +async function loginUmami({ baseUrl, username, password, sharedSecret }) { + if (sharedSecret) { + return loginUmamiViaSso({ baseUrl, username, sharedSecret }); + } if (!password) { - throw new Error('未配置 UMAMI_ADMIN_PASSWORD,无法拉取 SEO/GEO 页面统计'); + throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计'); } const response = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', diff --git a/server/umami-analytics.test.mjs b/server/umami-analytics.test.mjs index 0616609..453cdf5 100644 --- a/server/umami-analytics.test.mjs +++ b/server/umami-analytics.test.mjs @@ -23,12 +23,12 @@ test('fetchMemindDiscoveryPages rejects missing website id', async () => { ); }); -test('fetchMemindDiscoveryPages rejects missing admin password', async () => { +test('fetchMemindDiscoveryPages rejects missing auth credentials', async () => { await assert.rejects( () => fetchMemindDiscoveryPages( { discoveryChannel: 'geo', startAt: '1', endAt: '2' }, { env: {}, analyticsConfig: { websiteId: 'site-1' } }, ), - /未配置 UMAMI_ADMIN_PASSWORD/, + /未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET/, ); }); diff --git a/src/admin/pages/AnalyticsConfigPage.tsx b/src/admin/pages/AnalyticsConfigPage.tsx index b11dc42..8d27d71 100644 --- a/src/admin/pages/AnalyticsConfigPage.tsx +++ b/src/admin/pages/AnalyticsConfigPage.tsx @@ -83,7 +83,7 @@ export function AnalyticsConfigPage() { -
SEO/GEO 看板还需在 memind_adm 环境变量配置 UMAMI_ADMIN_PASSWORD(与 Umami admin 账号匹配)。
SEO/GEO 看板通过 MEMIND_UMAMI_SSO_SECRET(推荐,与 105 Umami 一致)或 UMAMI_ADMIN_PASSWORD 访问 Umami API;生产 Umami 若开启 SSO-only 则只需前者。