diff --git a/.env.example b/.env.example index ff34c3226..4579e8f89 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ VITE_REMOTE_SERVER_URL=http://localhost:4014 # Controls the initial visibility of the Sample Data section. VITE_SHOW_SAMPLE_DATA=true + +# Optional Netlify Edge Function analytics. Set these in Netlify's Functions +# scope for the deployment contexts to track. Never use a VITE_ prefix for tokens. +GOATCOUNTER_SITE= +GOATCOUNTER_API_TOKEN= diff --git a/netlify/edge-functions/visits.ts b/netlify/edge-functions/visits.ts index 1803d213f..d980a40a4 100644 --- a/netlify/edge-functions/visits.ts +++ b/netlify/edge-functions/visits.ts @@ -1,38 +1,73 @@ -// This file runs in the Deno runtime +/* global Netlify */ +import type { Context } from '@netlify/edge-functions'; -// @ts-ignore -// eslint-disable-next-line import/no-unresolved -import { Context } from 'https://edge.netlify.com'; - -// @ts-ignore -const GOATCOUNTER_SITE = Deno.env.get('GOATCOUNTER_SITE'); - -export default async (request: Request, context: Context) => { - if (!GOATCOUNTER_SITE) return; - - const headers = { 'X-Forwarded-For': context.ip }; - const url = new URL(GOATCOUNTER_SITE); - const { searchParams } = url; - - url.pathname = '/count'; +function getReferrer(request: Request) { + const referrer = request.headers.get('Referer'); + if (!referrer) return undefined; + try { + const url = new URL(referrer); + return `${url.protocol}//${url.host}${url.pathname}`; + } catch { + return undefined; + } +} - const requestUrl = new URL(request.url); - searchParams.set('p', `${requestUrl.host}${requestUrl.pathname}`); - searchParams.set('t', 'VolView'); +async function countVisit( + request: Request, + ip: string, + site: string, + token: string +) { + try { + const requestUrl = new URL(request.url); + const response = await fetch(new URL('/api/v0/count', site), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + redirect: 'error', + signal: AbortSignal.timeout(10_000), + body: JSON.stringify({ + hits: [ + { + path: `${requestUrl.host}${requestUrl.pathname}`, + title: 'VolView', + ref: getReferrer(request), + user_agent: request.headers.get('User-Agent') || 'Unknown', + ip, + }, + ], + }), + }); + await response.body?.cancel(); - if (request.headers.has('Referer')) { - searchParams.set( - 'r', - encodeURIComponent(request.headers.get('Referer') ?? '') + if (response.status !== 202) { + console.error(`GoatCounter request rejected (HTTP ${response.status}).`); + } else if (response.headers.has('X-Goatcounter-Filter')) { + console.info('GoatCounter visit filtered (HTTP 202).'); + } + } catch { + // Errors and response bodies may contain visitor data or credentials. + console.error( + 'GoatCounter request failed. Check configuration and network.' ); } +} + +export default (request: Request, context: Context) => { + if (request.method !== 'GET') return; + + const site = Netlify.env.get('GOATCOUNTER_SITE'); + if (!site) return; - if (request.headers.has('User-Agent')) { - headers['User-Agent'] = request.headers.get('User-Agent') ?? 'Unknown'; + const token = Netlify.env.get('GOATCOUNTER_API_TOKEN'); + if (!token) { + console.warn('GoatCounter disabled: GOATCOUNTER_API_TOKEN is not set.'); + return; } - // don't block the request - fetch(url.toString(), { headers }); + context.waitUntil(countVisit(request, context.ip, site, token)); }; export const config = { path: '/' };