Files
blog/workers/umami-site-stats.js
2026-06-19 08:55:14 +08:00

156 lines
4.2 KiB
JavaScript

const DEFAULT_CACHE_TTL_SECONDS = 600;
function jsonResponse(data, init = {}) {
return new Response(JSON.stringify(data), {
...init,
headers: {
'Content-Type': 'application/json; charset=utf-8',
...init.headers,
},
});
}
function getCorsHeaders(request, env) {
const origin = request.headers.get('Origin') || '';
const allowedOrigin = env.ALLOWED_ORIGIN || 'https://blog.biss.click';
const allowOrigin = origin === allowedOrigin ? origin : allowedOrigin;
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Vary': 'Origin',
};
}
function normalizeBaseUrl(value) {
return String(value || 'https://umami.biss.click').replace(/\/+$/, '');
}
function getShareId(env) {
if (env.UMAMI_SHARE_ID) return env.UMAMI_SHARE_ID;
if (!env.UMAMI_SHARE_URL) return '';
try {
const url = new URL(env.UMAMI_SHARE_URL);
const parts = url.pathname.split('/').filter(Boolean);
const shareIndex = parts.indexOf('share');
return shareIndex >= 0 ? parts[shareIndex + 1] || '' : parts.at(-1) || '';
} catch {
return env.UMAMI_SHARE_URL.split('/').filter(Boolean).at(-1) || '';
}
}
async function getShareData(env) {
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
const shareId = getShareId(env);
if (!shareId) {
throw new Error('Missing UMAMI_SHARE_ID or UMAMI_SHARE_URL');
}
const response = await fetch(`${baseUrl}/api/share/${shareId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Umami share lookup failed: ${response.status}`);
}
const data = await response.json();
if (!data.websiteId || !data.token) {
throw new Error('Umami share response did not include websiteId or token');
}
return data;
}
async function fetchUmamiStats(env, shareData) {
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
const websiteId = env.UMAMI_WEBSITE_ID || shareData.websiteId;
const startAt = Number(env.STATS_START_AT || 0);
const endAt = Date.now();
const url = `${baseUrl}/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`;
return fetch(url, {
headers: {
Accept: 'application/json',
'x-umami-share-context': '1',
'x-umami-share-token': shareData.token,
},
});
}
async function getStats(env) {
const shareData = await getShareData(env);
const response = await fetchUmamiStats(env, shareData);
if (!response.ok) {
throw new Error(`Umami stats failed: ${response.status}`);
}
const stats = await response.json();
return {
visitors: stats.visitors ?? 0,
pageviews: stats.pageviews ?? 0,
visits: stats.visits ?? 0,
updatedAt: Date.now(),
};
}
export default {
async fetch(request, env, ctx) {
const corsHeaders = getCorsHeaders(request, env);
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'GET') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
const cacheTtl = Number(env.CACHE_TTL_SECONDS || DEFAULT_CACHE_TTL_SECONDS);
const cache = caches.default;
const cacheKey = new Request(
`https://site-stats-cache.local/umami-site-stats/${getShareId(env) || 'default'}`,
);
const cached = await cache.match(cacheKey);
if (cached) {
return new Response(cached.body, {
status: cached.status,
headers: {
...Object.fromEntries(cached.headers),
...corsHeaders,
'X-Stats-Cache': 'HIT',
},
});
}
try {
const stats = await getStats(env);
const response = jsonResponse(stats, {
headers: {
...corsHeaders,
'Cache-Control': `public, max-age=${cacheTtl}`,
'X-Stats-Cache': 'MISS',
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
} catch (error) {
return jsonResponse(
{
error: 'Failed to fetch stats',
message: error instanceof Error ? error.message : String(error),
},
{ status: 502, headers: corsHeaders },
);
}
},
};