156 lines
4.2 KiB
JavaScript
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 },
|
|
);
|
|
}
|
|
},
|
|
};
|