6a7d6cec70
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>
140 lines
5.1 KiB
JavaScript
140 lines
5.1 KiB
JavaScript
import crypto from 'node:crypto';
|
|
|
|
const TOKEN_TTL_MS = 55 * 60 * 1000;
|
|
|
|
let cachedToken = '';
|
|
let cachedTokenAt = 0;
|
|
let cachedBaseUrl = '';
|
|
|
|
function normalizeBaseUrl(value = '') {
|
|
return String(value ?? '').trim().replace(/\/$/, '');
|
|
}
|
|
|
|
function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) {
|
|
const baseUrl = normalizeBaseUrl(
|
|
analyticsConfig?.analyticsUrl || env.UMAMI_URL || 'http://127.0.0.1:3100',
|
|
);
|
|
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, sharedSecret, websiteId };
|
|
}
|
|
|
|
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 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计');
|
|
}
|
|
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
|
|
}
|
|
const token = String(body?.token ?? '').trim();
|
|
if (!token) throw new Error('Umami 登录响应缺少 token');
|
|
return token;
|
|
}
|
|
|
|
async function getUmamiAuthToken(credentials) {
|
|
const now = Date.now();
|
|
if (
|
|
cachedToken &&
|
|
cachedBaseUrl === credentials.baseUrl &&
|
|
now - cachedTokenAt < TOKEN_TTL_MS
|
|
) {
|
|
return cachedToken;
|
|
}
|
|
cachedToken = await loginUmami(credentials);
|
|
cachedTokenAt = now;
|
|
cachedBaseUrl = credentials.baseUrl;
|
|
return cachedToken;
|
|
}
|
|
|
|
export async function fetchMemindDiscoveryPages(
|
|
query = {},
|
|
{ env = process.env, analyticsConfig = {} } = {},
|
|
) {
|
|
const credentials = resolveUmamiCredentials(env, analyticsConfig);
|
|
if (!credentials.websiteId) {
|
|
throw new Error('未配置 Umami Website ID');
|
|
}
|
|
const channel = String(query.discoveryChannel ?? query.channel ?? '').trim().toLowerCase();
|
|
if (channel !== 'seo' && channel !== 'geo') {
|
|
throw new Error('discoveryChannel 必须是 seo 或 geo');
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set('discoveryChannel', channel);
|
|
params.set('page', String(Math.max(Number(query.page) || 1, 1)));
|
|
params.set('pageSize', String(Math.min(Math.max(Number(query.pageSize) || 10, 1), 100)));
|
|
params.set('sortBy', String(query.sortBy || 'views'));
|
|
params.set('sortOrder', String(query.sortOrder || 'desc'));
|
|
if (query.startAt) params.set('startAt', String(query.startAt));
|
|
if (query.endAt) params.set('endAt', String(query.endAt));
|
|
if (query.timezone) params.set('timezone', String(query.timezone));
|
|
if (query.generatedStartAt) params.set('generatedStartAt', String(query.generatedStartAt));
|
|
if (query.generatedEndAt) params.set('generatedEndAt', String(query.generatedEndAt));
|
|
|
|
const token = await getUmamiAuthToken(credentials);
|
|
const response = await fetch(
|
|
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
|
|
{
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(15_000),
|
|
},
|
|
);
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
|
|
}
|
|
return {
|
|
discoveryChannel: channel,
|
|
...body,
|
|
};
|
|
}
|
|
|
|
export function resolvePublicPageUrl(row = {}, fallbackHost = 'm.tkmind.cn') {
|
|
const exact = String(row.pageUrl ?? '').trim();
|
|
if (/^https?:\/\//i.test(exact)) return exact.split('#')[0];
|
|
const hostname = String(row.hostname ?? '').trim();
|
|
if (hostname === '127.0.0.1' || hostname === 'localhost') {
|
|
return `http://${hostname}:8081${row.urlPath}`;
|
|
}
|
|
const host = hostname || fallbackHost;
|
|
return `https://${host}${row.urlPath}`;
|
|
}
|