diff --git a/netlify.toml b/netlify.toml index 617a5ec..efd28c2 100644 --- a/netlify.toml +++ b/netlify.toml @@ -52,12 +52,6 @@ to = "https://gc.zgo.at/count.js" status = 200 force = true -[[redirects]] -from = "/gc/count" -to = "https://roschaefer.goatcounter.com/count" -status = 200 -force = true - [[redirects]] from = "/oss-contributors-berlin" to = "https://www.youtube.com/watch?v=p5iMjfVCXBY" @@ -101,3 +95,7 @@ path = "/" [[edge_functions]] function = "locale-redirect" path = "/resume.json" + +[[edge_functions]] +function = "goatcounter" +path = "/gc/count" diff --git a/netlify/edge-functions/goatcounter.ts b/netlify/edge-functions/goatcounter.ts new file mode 100644 index 0000000..d9070c4 --- /dev/null +++ b/netlify/edge-functions/goatcounter.ts @@ -0,0 +1,65 @@ +declare const Netlify: { env: { get: (name: string) => string | undefined } }; + +type Context = { ip: string; waitUntil: (promise: Promise) => void }; + +export const goatcounterApiUrl = "https://roschaefer.goatcounter.com/api/v0/count"; + +// The same 1x1 GIF that GoatCounter's own /count endpoint responds with. +const gif = Uint8Array.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x1, 0x0, 0x1, 0x0, 0x80, 0x1, 0x0, 0x0, 0x0, 0x0, 0xff, 0xff, + 0xff, 0x21, 0xf9, 0x4, 0x1, 0xa, 0x0, 0x1, 0x0, 0x2c, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x1, 0x0, 0x0, + 0x2, 0x2, 0x4c, 0x1, 0x0, 0x3b, +]); + +const gifResponse = () => + new Response(gif, { + headers: { "content-type": "image/gif", "cache-control": "no-store" }, + }); + +const primaryLanguage = (acceptLanguage: string | null): string => + acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? ""; + +export const toApiHit = (request: Request, ip: string) => { + const params = new URL(request.url).searchParams; + const path = params.get("p"); + if (!path) { + return null; + } + + return { + path, + title: params.get("t") ?? "", + ref: params.get("r") ?? "", + event: params.get("e") === "true", + size: params.get("s") ?? "", + query: params.get("q") ?? "", + bot: Number.parseInt(params.get("b") ?? "0", 10) || 0, + user_agent: request.headers.get("user-agent") ?? "", + language: primaryLanguage(request.headers.get("accept-language")), + ip, + }; +}; + +const send = async (hit: NonNullable>, token: string) => { + const response = await fetch(goatcounterApiUrl, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ hits: [hit] }), + }); + if (!response.ok) { + console.error(`GoatCounter API responded ${response.status}: ${await response.text()}`); + } +}; + +export default async (request: Request, context: Context) => { + const hit = toApiHit(request, context.ip); + const token = Netlify.env.get("GOATCOUNTER_API_TOKEN"); + + if (!token) { + console.error("GOATCOUNTER_API_TOKEN is not set, not counting"); + } else if (hit) { + context.waitUntil(send(hit, token).catch((error) => console.error(error))); + } + + return gifResponse(); +}; diff --git a/src/lib/utils/goatcounter-edge-function.test.ts b/src/lib/utils/goatcounter-edge-function.test.ts new file mode 100644 index 0000000..b9e534f --- /dev/null +++ b/src/lib/utils/goatcounter-edge-function.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import countPageview, { goatcounterApiUrl } from "../../../netlify/edge-functions/goatcounter.ts"; + +const visitorIp = "203.0.113.7"; +const firefox = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"; + +const beacon = (query: string) => + new Request(`https://roschaefer.de/gc/count?${query}`, { + method: "POST", + headers: { "user-agent": firefox, "accept-language": "de-DE,de;q=0.9,en;q=0.8" }, + }); + +const run = async (request: Request) => { + const pending: Promise[] = []; + const response = await countPageview(request, { + ip: visitorIp, + waitUntil: (promise) => pending.push(promise), + }); + await Promise.all(pending); + return response; +}; + +let token: string | undefined; +const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); + +beforeEach(() => { + token = "secret-token"; + vi.stubGlobal("Netlify", { env: { get: () => token } }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockClear(); +}); + +describe("goatcounter edge function", () => { + it("reports the visitor's own IP and user agent, so GoatCounter does not see the proxy's datacenter IP and discard the hit as a bot", async () => { + await run(beacon("p=%2Fen%2F&t=Robert&r=https%3A%2F%2Fexample.com%2F&s=1920&b=0&rnd=x")); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(goatcounterApiUrl); + expect(init.headers).toMatchObject({ authorization: "Bearer secret-token" }); + expect(JSON.parse(init.body as string)).toEqual({ + hits: [ + { + path: "/en/", + title: "Robert", + ref: "https://example.com/", + event: false, + size: "1920", + query: "", + bot: 0, + user_agent: firefox, + language: "de-DE", + ip: visitorIp, + }, + ], + }); + }); + + it("responds with a no-store GIF like GoatCounter's pixel endpoint", async () => { + const response = await run(beacon("p=%2Fen%2F")); + + expect(response.headers.get("content-type")).toBe("image/gif"); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("forwards events and the script's own bot detection", async () => { + await run(beacon("p=download-cv&e=true&b=150")); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string).hits[0]).toMatchObject({ + path: "download-cv", + event: true, + bot: 150, + }); + }); + + it("does not call the API for requests without a path", async () => { + await run(beacon("rnd=x")); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("still responds when the API token is not configured", async () => { + token = undefined; + vi.spyOn(console, "error").mockImplementation(() => {}); + + const response = await run(beacon("p=%2Fen%2F")); + + expect(response.status).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});