Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -101,3 +95,7 @@ path = "/"
[[edge_functions]]
function = "locale-redirect"
path = "/resume.json"

[[edge_functions]]
function = "goatcounter"
path = "/gc/count"
65 changes: 65 additions & 0 deletions netlify/edge-functions/goatcounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
declare const Netlify: { env: { get: (name: string) => string | undefined } };

type Context = { ip: string; waitUntil: (promise: Promise<unknown>) => 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<ReturnType<typeof toApiHit>>, 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();
};
97 changes: 97 additions & 0 deletions src/lib/utils/goatcounter-edge-function.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[] = [];
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();
});
});
Loading