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
2 changes: 1 addition & 1 deletion messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"privacy_hosting_title": "Hosting und Server-Logs",
"privacy_hosting_body": "Diese Website wird bei Netlify gehostet. Beim Aufruf der Website verarbeitet Netlify technische Zugriffsdaten wie IP-Adresse, Zeitpunkt, abgerufene Datei, Referrer und Browserinformationen.",
"privacy_analytics_title": "Reichweitenmessung mit GoatCounter",
"privacy_analytics_body": "Diese Website verwendet GoatCounter für datensparsame Reichweitenmessung. GoatCounter hilft zu verstehen, welche Seiten aufgerufen werden, ohne Cookies, Local Storage oder Tracking-IDs zu verwenden. Nach Angaben von GoatCounter werden IP-Adressen und vollständige User-Agent-Header nicht dauerhaft gespeichert; zur Erkennung wiederholter Aufrufe können technische Daten bis zu acht Stunden im Arbeitsspeicher verarbeitet werden.",
"privacy_analytics_body": "Diese Website verwendet GoatCounter für datensparsame Reichweitenmessung. GoatCounter hilft zu verstehen, welche Inhalte dieser Website aufgerufen werden, ohne Cookies, Local Storage oder Tracking-IDs zu verwenden. Nach Angaben von GoatCounter werden IP-Adressen und vollständige User-Agent-Header nicht dauerhaft gespeichert; zur Erkennung wiederholter Aufrufe können technische Daten bis zu acht Stunden im Arbeitsspeicher verarbeitet werden.",
"privacy_contact_title": "Kontaktaufnahme",
"privacy_contact_body": "Wenn Sie per E-Mail oder Signal Kontakt aufnehmen, werden die von Ihnen übermittelten Daten zur Bearbeitung der Anfrage verarbeitet.",
"privacy_external_title": "Externe Links und eingebettete Inhalte",
Expand Down
2 changes: 1 addition & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"privacy_hosting_title": "Hosting and server logs",
"privacy_hosting_body": "This website is hosted by Netlify. When you access the website, Netlify processes technical access data such as IP address, time, requested file, referrer, and browser information.",
"privacy_analytics_title": "Analytics with GoatCounter",
"privacy_analytics_body": "This website uses GoatCounter for privacy-friendly analytics. GoatCounter helps understand which pages are visited without using cookies, localStorage, or tracking IDs. According to GoatCounter, IP addresses and full user agent headers are not stored permanently; technical data may be processed in memory for up to eight hours to detect repeat visits.",
"privacy_analytics_body": "This website uses GoatCounter for privacy-friendly analytics. GoatCounter helps understand which content on this website is accessed without using cookies, localStorage, or tracking IDs. According to GoatCounter, IP addresses and full user agent headers are not stored permanently; technical data may be processed in memory for up to eight hours to detect repeat visits.",
"privacy_contact_title": "Contact",
"privacy_contact_body": "If you contact me by email or Signal, the data you provide is processed to handle your request.",
"privacy_external_title": "External links and embedded content",
Expand Down
24 changes: 24 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,27 @@ path = "/resume.json"
[[edge_functions]]
function = "goatcounter"
path = "/gc/count"

[[edge_functions]]
function = "count-download"
path = "/de/robert-schaefer-resume.de.pdf"

[[edge_functions]]
function = "count-download"
path = "/en/robert-schaefer-resume.en.pdf"

[[edge_functions]]
function = "count-download"
path = "/de/robert-schaefer-resume-ats.de.pdf"

[[edge_functions]]
function = "count-download"
path = "/en/robert-schaefer-resume-ats.en.pdf"

[[edge_functions]]
function = "count-download"
path = "/de/resume.json"

[[edge_functions]]
function = "count-download"
path = "/en/resume.json"
61 changes: 61 additions & 0 deletions netlify/edge-functions/count-download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { siteUrl } from "../../src/lib/config/site.ts";
import { primaryLanguage, send } from "./goatcounter.ts";

declare const Netlify: { env: { get: (name: string) => string | undefined } };

type Context = {
ip: string;
next: (options?: { sendConditionalRequest?: boolean }) => Promise<Response>;
waitUntil: (promise: Promise<unknown>) => void;
};

const productionHostname = new URL(siteUrl).hostname;

const isPrefetch = (request: Request): boolean =>
/prefetch|prerender/i.test(
`${request.headers.get("sec-purpose") ?? ""} ${request.headers.get("purpose") ?? ""}`,
);

// PDF viewers fetch large files in byte ranges after the first request; only
// the range that starts at the beginning counts as a download.
const isFollowUpRange = (request: Request): boolean => {
const range = request.headers.get("range");
return range !== null && !/^bytes=0-/.test(range.trim());
};

const shouldCount = (request: Request): boolean =>
new URL(request.url).hostname === productionHostname &&
request.method === "GET" &&
!isPrefetch(request) &&
!isFollowUpRange(request);

export default async (request: Request, context: Context) => {
const response = await context.next({ sendConditionalRequest: true });

if (!shouldCount(request) || response.status >= 400) {
return response;
}

const token = Netlify.env.get("GOATCOUNTER_API_TOKEN");
if (!token) {
console.error("GOATCOUNTER_API_TOKEN is not set, not counting");
return response;
}

const url = new URL(request.url);
const hit = {
path: url.pathname,
title: "",
ref: request.headers.get("referer") ?? "",
event: false,
size: "",
query: url.search,
bot: 0,
user_agent: request.headers.get("user-agent") ?? "",
language: primaryLanguage(request.headers.get("accept-language")),
ip: context.ip,
};
context.waitUntil(send(hit, token).catch((error) => console.error(error)));
Comment thread
roschaefer marked this conversation as resolved.
Comment thread
roschaefer marked this conversation as resolved.

return response;
};
6 changes: 4 additions & 2 deletions netlify/edge-functions/goatcounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const gifResponse = () =>
headers: { "content-type": "image/gif", "cache-control": "no-store" },
});

const primaryLanguage = (acceptLanguage: string | null): string =>
export const primaryLanguage = (acceptLanguage: string | null): string =>
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";

export const toApiHit = (request: Request, ip: string) => {
Expand All @@ -40,7 +40,9 @@ export const toApiHit = (request: Request, ip: string) => {
};
};

const send = async (hit: NonNullable<ReturnType<typeof toApiHit>>, token: string) => {
export type ApiHit = NonNullable<ReturnType<typeof toApiHit>>;

export const send = async (hit: ApiHit, token: string) => {
const response = await fetch(goatcounterApiUrl, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
Expand Down
164 changes: 164 additions & 0 deletions src/lib/utils/count-download-edge-function.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { resumePdfPath } from "$lib/data/resume-pdf";
import countDownload from "../../../netlify/edge-functions/count-download.ts";
import { 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 pdfUrl = "https://roschaefer.de/en/robert-schaefer-resume.en.pdf";

const download = (url: string, headers: Record<string, string> = {}, method = "GET") =>
new Request(url, {
method,
headers: { "user-agent": firefox, "accept-language": "en-US,en;q=0.9", ...headers },
});

let fileResponse: Response;

const run = async (request: Request) => {
const pending: Promise<unknown>[] = [];
const response = await countDownload(request, {
ip: visitorIp,
next: nextMock,
waitUntil: (promise) => pending.push(promise),
});
await Promise.all(pending);
return response;
};

const nextMock = vi.fn(async (_options?: { sendConditionalRequest?: boolean }) => fileResponse);

let token: string | undefined;
const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));

beforeEach(() => {
token = "secret-token";
fileResponse = new Response("%PDF-1.7", { headers: { "content-type": "application/pdf" } });
vi.stubGlobal("Netlify", { env: { get: () => token } });
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
fetchMock.mockClear();
nextMock.mockClear();
});

describe("count-download edge function", () => {
it("counts a direct download that the JS beacon never sees, e.g. a CV link opened from a printout or email", async () => {
const response = await run(download(pdfUrl, { referer: "https://mail.example.com/" }));

expect(response).toBe(fileResponse);
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/robert-schaefer-resume.en.pdf",
title: "",
ref: "https://mail.example.com/",
event: false,
size: "",
query: "",
bot: 0,
user_agent: firefox,
language: "en-US",
ip: visitorIp,
},
],
});
});

it("leaves bot detection to GoatCounter, whose API flags hits by the forwarded user agent", async () => {
const googlebot = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";

await run(download(pdfUrl, { "user-agent": googlebot }));

const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(JSON.parse(init.body as string).hits[0]).toMatchObject({
user_agent: googlebot,
bot: 0,
});
});

it("lets the CDN answer revalidations with 304 instead of resending the whole file, and counts them as views", async () => {
fileResponse = new Response(null, { status: 304 });

const response = await run(download(pdfUrl, { "if-none-match": '"abc"' }));

expect(nextMock).toHaveBeenCalledWith({ sendConditionalRequest: true });
expect(response.status).toBe(304);
expect(fetchMock).toHaveBeenCalledOnce();
});

it("does not count downloads from deploy previews, so reviewing a PR does not pollute the production stats", async () => {
await run(
download(
"https://deploy-preview-145--roschaefer.netlify.app/en/robert-schaefer-resume.en.pdf",
),
);

expect(fetchMock).not.toHaveBeenCalled();
});

it("does not count speculative prefetches", async () => {
await run(download(pdfUrl, { "sec-purpose": "prefetch;prerender" }));

expect(fetchMock).not.toHaveBeenCalled();
});

it("counts a PDF viewer's first byte range but not the follow-up ranges of the same download", async () => {
await run(download(pdfUrl, { range: "bytes=0-65535" }));
await run(download(pdfUrl, { range: "bytes=65536-131071" }));

expect(fetchMock).toHaveBeenCalledOnce();
});

it("does not count HEAD requests", async () => {
await run(download(pdfUrl, {}, "HEAD"));

expect(fetchMock).not.toHaveBeenCalled();
});

it("does not count failed responses", async () => {
fileResponse = new Response("Not found", { status: 404 });

await run(download(pdfUrl));

expect(fetchMock).not.toHaveBeenCalled();
});

it("still serves the file when the API token is not configured", async () => {
token = undefined;
vi.spyOn(console, "error").mockImplementation(() => {});

const response = await run(download(pdfUrl));

expect(response).toBe(fileResponse);
expect(fetchMock).not.toHaveBeenCalled();
});
});

describe("count-download routes in netlify.toml", () => {
const countedPaths = readFileSync(resolve(process.cwd(), "netlify.toml"), "utf8")
.split("[[edge_functions]]")
.slice(1)
.filter((block) => block.includes('function = "count-download"'))
.map((block) => block.match(/path = "([^"]+)"/)?.[1]);

it.each(
(["de", "en"] as const).flatMap((locale) => [
resumePdfPath(locale),
resumePdfPath(locale, "ats"),
`/${locale}/resume.json`,
]),
)("counts %s", (path) => {
expect(countedPaths).toContain(path);
});
});
Loading