fix(analytics): use Umami SSO for SEO/GEO API on SSO-only deploy

Production Umami rejects password login when MEMIND_UMAMI_SSO_ONLY=1;
server-side memind-pages fetch now authenticates via MEMIND_UMAMI_SSO_SECRET.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-10 17:21:28 +08:00
parent a67d5a137a
commit 6a7d6cec70
3 changed files with 35 additions and 6 deletions
+32 -3
View File
@@ -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',
+2 -2
View File
@@ -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/,
);
});
+1 -1
View File
@@ -83,7 +83,7 @@ export function AnalyticsConfigPage() {
<label className="admin-form-row"><span>Umami </span><input value={config.analyticsUrl} disabled={loading || busy} onChange={(e) => setConfig({ ...config, analyticsUrl: e.target.value })} placeholder="http://127.0.0.1:3100" /></label>
<label className="admin-form-row"><span></span><input value={config.domains} disabled={loading || busy} onChange={(e) => setConfig({ ...config, domains: e.target.value })} placeholder="127.0.0.1,localhost" /></label>
<label className="admin-form-row"><span> {config.idSecretConfigured ? '(已配置,留空保持不变)' : ''}</span><input type="password" value={secret} disabled={loading || busy} onChange={(e) => setSecret(e.target.value)} placeholder={config.idSecretConfigured ? '留空保持当前密钥' : '输入本地随机密钥'} autoComplete="new-password" /></label>
<p className="muted">SEO/GEO memind_adm <code>UMAMI_ADMIN_PASSWORD</code> Umami admin </p>
<p className="muted">SEO/GEO <code>MEMIND_UMAMI_SSO_SECRET</code> 105 Umami <code>UMAMI_ADMIN_PASSWORD</code> 访 Umami API Umami SSO-only </p>
<div className="admin-actions"><button type="submit" className="send-btn" disabled={busy || loading}>{busy ? '保存中...' : '保存配置'}</button><button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}></button></div>
</form>
</section>