diff --git a/app/analytics/collect.test.ts b/app/analytics/collect.test.ts index c24f8fe6..8332f904 100644 --- a/app/analytics/collect.test.ts +++ b/app/analytics/collect.test.ts @@ -215,4 +215,26 @@ describe("collectRequestHandler", () => { ], ); }); + + test("a PATCH request should return 405", () => { + const env = { + WEB_COUNTER_AE: { + writeDataPoint: vi.fn(), + } as CFAnalyticsEngine, + } as Environment; + + const request = httpMocks.createRequest({ + method: "PATCH", + url: "https://example.com", + // Cloudflare-specific request properties + cf: { + country: "US", + }, + }); + + const response = collectRequestHandler(request, env); + + expect(response.status).toBe(405); + expect(response.statusText).toBe("Method Not Allowed"); + }); }); diff --git a/app/analytics/collect.ts b/app/analytics/collect.ts index a140e4d2..2aed6204 100644 --- a/app/analytics/collect.ts +++ b/app/analytics/collect.ts @@ -1,6 +1,6 @@ import { UAParser } from "ua-parser-js"; -import type { RequestInit } from "@cloudflare/workers-types"; +import type { RequestInit, Request } from "@cloudflare/workers-types"; function checkVisitorSession(ifModifiedSince: string | null): { newVisitor: boolean; @@ -52,60 +52,125 @@ function extractParamsFromQueryString(requestUrl: string): { } export function collectRequestHandler(request: Request, env: Environment) { - const params = extractParamsFromQueryString(request.url); - - const userAgent = request.headers.get("user-agent") || undefined; - const parsedUserAgent = new UAParser(userAgent); - - parsedUserAgent.getBrowser().name; - - const { newVisitor, newSession } = checkVisitorSession( - request.headers.get("if-modified-since"), - ); - - const data: DataPoint = { - siteId: params.sid, - host: params.h, - path: params.p, - referrer: params.r, - newVisitor: newVisitor ? 1 : 0, - newSession: newSession ? 1 : 0, - // user agent stuff - userAgent: userAgent, - browserName: parsedUserAgent.getBrowser().name, - deviceModel: parsedUserAgent.getDevice().model, - }; - - // NOTE: location is derived from Cloudflare-specific request properties - // see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties - const country = (request as RequestInit).cf?.country; - if (typeof country === "string") { - data.country = country; - } - - writeDataPoint(env.WEB_COUNTER_AE, data); - - // encode 1x1 transparent gif - const gif = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; - const gifData = atob(gif); - const gifLength = gifData.length; - const arrayBuffer = new ArrayBuffer(gifLength); - const uintArray = new Uint8Array(arrayBuffer); - for (let i = 0; i < gifLength; i++) { - uintArray[i] = gifData.charCodeAt(i); + switch (request.method) { + case "GET": { + const params = extractParamsFromQueryString(request.url); + + const userAgent = request.headers.get("user-agent") || undefined; + const parsedUserAgent = new UAParser(userAgent); + + parsedUserAgent.getBrowser().name; + + const { newVisitor, newSession } = checkVisitorSession( + request.headers.get("if-modified-since"), + ); + + const data: DataPoint = { + siteId: params.sid, + host: params.h, + path: params.p, + referrer: params.r, + newVisitor: newVisitor ? 1 : 0, + newSession: newSession ? 1 : 0, + // user agent stuff + userAgent: userAgent, + browserName: parsedUserAgent.getBrowser().name, + deviceModel: parsedUserAgent.getDevice().model, + }; + + // NOTE: location is derived from Cloudflare-specific request properties + // see: https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties + const country = (request as RequestInit).cf?.country; + if (typeof country === "string") { + data.country = country; + } + + writeDataPoint(env.WEB_COUNTER_AE, data); + + // encode 1x1 transparent gif + const gif = + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + const gifData = atob(gif); + const gifLength = gifData.length; + const arrayBuffer = new ArrayBuffer(gifLength); + const uintArray = new Uint8Array(arrayBuffer); + for (let i = 0; i < gifLength; i++) { + uintArray[i] = gifData.charCodeAt(i); + } + + return new Response(arrayBuffer, { + headers: { + "Content-Type": "image/gif", + Expires: "Mon, 01 Jan 1990 00:00:00 GMT", + "Cache-Control": "no-cache", + Pragma: "no-cache", + "Last-Modified": new Date().toUTCString(), + Tk: "N", // not tracking + }, + status: 200, + }); + } + case "POST": { + const body: PostRequestBody = + request.json() as unknown as PostRequestBody; + if (!body) { + return new Response("Invalid request", { status: 400 }); + } + + if ( + !body.siteId || + !body.host || + !body.userAgent || + !body.path || + !body.country || + !body.referrer || + !body.browserName || + !body.deviceModel || + !body.ifModifiedSince + ) { + return new Response("Missing required fields", { status: 400 }); + } + + const { newVisitor, newSession } = checkVisitorSession( + body.ifModifiedSince, + ); + + const data: DataPoint = { + siteId: body.siteId, + host: body.host, + userAgent: body.userAgent, + path: body.path, + country: body.country, + referrer: body.referrer, + browserName: body.browserName, + deviceModel: body.deviceModel, + newVisitor: newVisitor ? 1 : 0, + newSession: newSession ? 1 : 0, + }; + + writeDataPoint(env.WEB_COUNTER_AE, data); + + return new Response("OK", { status: 200 }); + } + default: { + return new Response("Method not allowed", { + status: 405, + statusText: "Method Not Allowed", + }); + } } +} - return new Response(arrayBuffer, { - headers: { - "Content-Type": "image/gif", - Expires: "Mon, 01 Jan 1990 00:00:00 GMT", - "Cache-Control": "no-cache", - Pragma: "no-cache", - "Last-Modified": new Date().toUTCString(), - Tk: "N", // not tracking - }, - status: 200, - }); +interface PostRequestBody { + siteId: string; + host: string; + userAgent: string; + path: string; + country: string; + referrer: string; + browserName: string; + deviceModel: string; + ifModifiedSince: string; } interface DataPoint { diff --git a/server.ts b/server.ts index 5663bf7c..84b1eeef 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,7 @@ -import type { RequestInit } from "@cloudflare/workers-types"; +import type { + RequestInit, + Request as WorkerRequest, +} from "@cloudflare/workers-types"; import { getAssetFromKV } from "@cloudflare/kv-asset-handler"; import type { AppLoadContext } from "@remix-run/cloudflare"; @@ -28,7 +31,10 @@ export default { const url = new URL(request.url); if (url.pathname.startsWith("/collect")) { - return collectRequestHandler(request, env); + return collectRequestHandler( + request as unknown as WorkerRequest, + env, + ); } const ttl = url.pathname.startsWith("/build/")