diff --git a/.dev.vars.example b/.dev.vars.example index 6ee5d84..3640c3b 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,6 +2,7 @@ MATOMO_URL=https://analytics.example.com MATOMO_SITE_ID=1 MATOMO_TIMEOUT_MS=5000 LOG_LEVEL=debug +HTTP_METHOD_ALLOWLIST=GET USER_AGENT_ALLOWLIST_REGEX=(?:ChatGPT-User|MistralAI-User|Gemini-Deep-Research|Claude-User|Perplexity-User|Google-NotebookLM|Devin) URL_EXCLUDE_REGEX=^[^?]+\.(?:css|js|mjs|map|json|xml|webmanifest|manifest|png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|woff2?|ttf|otf|eot|rss|atom|wasm|txt)(?:\?|$) DOCUMENT_REGEX=^[^?]+\.(?:pdf|docx?|xlsx?|pptx?|csv|json|txt|xml|epub|mobi|azw3|mp3|mp4|mpe?g|webm|mov|avi|ogg|wav|flac|zip|gz|gzip|tgz|tar|bz2|tbz|7z|rar|dmg|exe|msi|apk|jar|md5|sig)(?:\?|$) diff --git a/README.md b/README.md index 66bcefd..cc8c5ed 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Cloudflare Worker (TypeScript, Node 24 tooling) that sits inline on your zone, p - `MATOMO_URL` (required): Base Matomo URL, e.g. `https://analytics.example.com`. - `MATOMO_SITE_ID` (required): Matomo site ID (integer). - `MATOMO_TIMEOUT_MS` (optional, default `5000`): HTTP timeout in ms for Matomo calls. +- `HTTP_METHOD_ALLOWLIST` (optional, default `GET`): Comma-separated list of HTTP methods to track (e.g. `GET,POST`); empty/unset uses the default. - `DOCUMENT_REGEX` (optional): Case-insensitive regex to detect downloads; matching URLs add `download=` to Matomo payloads. This regex runs against the full URL (`protocol://host/path?query`) and defaults to a modern/common set of extensions: - Documents: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` - Data/text: `.csv`, `.json`, `.txt`, `.xml` @@ -50,7 +51,7 @@ Wrangler bundles the TypeScript entry for you; no manual build is required for ` - Install Wrangler (e.g., `npm install -g wrangler` or `npx wrangler --version` to use npx). - Copy `.dev.vars.example` to `.dev.vars` and set your local values (these are only for `wrangler dev --local`): - - `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TIMEOUT_MS`, `LOG_LEVEL`, `USER_AGENT_ALLOWLIST_REGEX`, `URL_EXCLUDE_REGEX`, `DOCUMENT_REGEX` + - `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TIMEOUT_MS`, `LOG_LEVEL`, `HTTP_METHOD_ALLOWLIST`, `USER_AGENT_ALLOWLIST_REGEX`, `URL_EXCLUDE_REGEX`, `DOCUMENT_REGEX` - Start local dev (serves on http://localhost:8787 by default): ```sh @@ -101,7 +102,7 @@ The Worker simply calls `fetch(request)` to reach your origin and separately pos - Receives each incoming request, proxies to origin with `fetch`, and returns the origin response. If configuration is invalid, logs an error and just proxies (no tracking). - Measures server time (`pf_srv` in seconds), status, and response bytes from `Content-Length` when present. - Builds a Matomo payload with `idsite`, `rec:1`, `recMode:1`, `url`, `source:'Cloudflare'`, `cdt` (UTC `YYYY-MM-DD HH:mm:ss`), and `ua`. -- Skips tracking when `URL_EXCLUDE_REGEX` matches; detects downloads via `DOCUMENT_REGEX`; disallowed UAs are skipped by `USER_AGENT_ALLOWLIST_REGEX`. +- Skips tracking when `URL_EXCLUDE_REGEX` matches; detects downloads via `DOCUMENT_REGEX`; disallowed UAs are skipped by `USER_AGENT_ALLOWLIST_REGEX`; skips tracking when request method not in `HTTP_METHOD_ALLOWLIST`. - Sends a single Matomo hit asynchronously via `waitUntil` to `/matomo.php` (standard tracking API) with timeout. ## Logging diff --git a/src/config.ts b/src/config.ts index c0e3519..f5d8f2f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ const defaultUserAgentPatterns = [ const defaultAllowlistPattern = `(?:${defaultUserAgentPatterns .map(escapeRegex) .join('|')})`; +const defaultHttpMethodAllowlist = ['GET']; const defaultDocumentPattern = '^[^?]+\\.(?:pdf|docx?|xlsx?|pptx?|csv|json|txt|xml|epub|mobi|azw3|mp3|mp4|mpe?g|webm|mov|avi|ogg|wav|flac|zip|gz|gzip|tgz|tar|bz2|tbz|7z|rar|dmg|exe|msi|apk|jar|md5|sig)(?:\\?|$)'; const defaultUrlExcludePattern = @@ -47,6 +48,24 @@ export function getConfig( const matomoTimeoutMs = toInt(env.MATOMO_TIMEOUT_MS, 5000) ?? 5000; const logLevel = (env.LOG_LEVEL || 'warn').toLowerCase() as LogLevel; + const httpMethodAllowlist = + env.HTTP_METHOD_ALLOWLIST && env.HTTP_METHOD_ALLOWLIST.trim() + ? env.HTTP_METHOD_ALLOWLIST.split(',').map((v) => v.trim().toUpperCase()) + : defaultHttpMethodAllowlist; + const normalizedHttpMethodAllowlist = Array.from( + new Set(httpMethodAllowlist.filter(Boolean)) + ); + if (normalizedHttpMethodAllowlist.length === 0) { + throw new Error('HTTP_METHOD_ALLOWLIST must include at least one method'); + } + const invalidMethod = normalizedHttpMethodAllowlist.find( + (method) => !/^[A-Z]+$/.test(method) + ); + if (invalidMethod) { + throw new Error( + `Invalid HTTP_METHOD_ALLOWLIST entry "${invalidMethod}" (expected letters only)` + ); + } const allowlistPattern = env.USER_AGENT_ALLOWLIST_REGEX || defaultAllowlistPattern; const urlExcludePattern = env.URL_EXCLUDE_REGEX || defaultUrlExcludePattern; @@ -78,6 +97,7 @@ export function getConfig( matomoSiteId, matomoTimeoutMs, logLevel, + httpMethodAllowlist: normalizedHttpMethodAllowlist, userAgentAllowlistRegex, urlExcludeRegex, documentRegex diff --git a/src/matomo.ts b/src/matomo.ts index 2a75477..ea93f5e 100644 --- a/src/matomo.ts +++ b/src/matomo.ts @@ -17,6 +17,15 @@ export function buildMatomoPayload( throw new Error('matomoSiteId is required in config'); } + const method = request.method.toUpperCase(); + if ( + Array.isArray(config.httpMethodAllowlist) && + config.httpMethodAllowlist.length > 0 && + !config.httpMethodAllowlist.includes(method) + ) { + return null; + } + const url = request.url; if (config.urlExcludeRegex && config.urlExcludeRegex.test(url)) { return null; diff --git a/src/types.ts b/src/types.ts index 61ea58e..613c09f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export interface Env { MATOMO_SITE_ID: string; MATOMO_TIMEOUT_MS?: string; LOG_LEVEL?: LogLevel; + HTTP_METHOD_ALLOWLIST?: string; USER_AGENT_ALLOWLIST_REGEX?: string; URL_EXCLUDE_REGEX?: string; DOCUMENT_REGEX?: string; @@ -23,6 +24,7 @@ export interface MatomoConfig { matomoSiteId: number; matomoTimeoutMs: number; logLevel: LogLevel; + httpMethodAllowlist: string[]; userAgentAllowlistRegex?: RegExp; urlExcludeRegex?: RegExp; documentRegex?: RegExp; diff --git a/tests/config.test.ts b/tests/config.test.ts index 3977088..66d068e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,7 +13,8 @@ describe('getConfig', () => { matomoUrl: baseEnv.MATOMO_URL, matomoSiteId: 42, matomoTimeoutMs: 5000, - logLevel: 'warn' + logLevel: 'warn', + httpMethodAllowlist: ['GET'] }); expect(config.userAgentAllowlistRegex).toEqual( /(?:ChatGPT-User|MistralAI-User|Gemini-Deep-Research|Claude-User|Perplexity-User|Google-NotebookLM|Devin)/i @@ -31,6 +32,7 @@ describe('getConfig', () => { ...baseEnv, MATOMO_TIMEOUT_MS: '8000', LOG_LEVEL: 'debug', + HTTP_METHOD_ALLOWLIST: 'get, post', USER_AGENT_ALLOWLIST_REGEX: 'CustomBot', URL_EXCLUDE_REGEX: '\\.(?:js|css)$', DOCUMENT_REGEX: '\\.custom$' @@ -39,13 +41,24 @@ describe('getConfig', () => { matomoUrl: baseEnv.MATOMO_URL, matomoSiteId: 42, matomoTimeoutMs: 8000, - logLevel: 'debug' + logLevel: 'debug', + httpMethodAllowlist: ['GET', 'POST'] }); expect(config.userAgentAllowlistRegex).toEqual(/CustomBot/i); expect(config.urlExcludeRegex).toEqual(/\.(?:js|css)$/i); expect(config.documentRegex).toEqual(/\.custom$/i); }); + it('defaults to GET when HTTP_METHOD_ALLOWLIST is empty/blank', () => { + expect( + getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: '' }).httpMethodAllowlist + ).toEqual(['GET']); + expect( + getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: ' ' }) + .httpMethodAllowlist + ).toEqual(['GET']); + }); + it('throws on invalid regex config', () => { expect(() => getConfig({ ...baseEnv, USER_AGENT_ALLOWLIST_REGEX: '[' }) @@ -58,6 +71,15 @@ describe('getConfig', () => { ); }); + it('throws on invalid HTTP_METHOD_ALLOWLIST', () => { + expect(() => getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: '$' })).toThrow( + /Invalid HTTP_METHOD_ALLOWLIST/ + ); + expect(() => getConfig({ ...baseEnv, HTTP_METHOD_ALLOWLIST: ',' })).toThrow( + /HTTP_METHOD_ALLOWLIST must include at least one method/ + ); + }); + it('throws when MATOMO_URL is missing', () => { expect(() => getConfig({ MATOMO_SITE_ID: '1' })).toThrow( /MATOMO_URL is required/ diff --git a/tests/matomo.test.ts b/tests/matomo.test.ts index 62e0622..a0de7fa 100644 --- a/tests/matomo.test.ts +++ b/tests/matomo.test.ts @@ -90,6 +90,41 @@ describe('buildMatomoPayload (Worker)', () => { expect(payload).toBeNull(); }); + it('returns null when http method is not allowlisted', () => { + const response = new Response(null, { status: 200 }); + const payload = buildMatomoPayload( + new Request('https://example.com/path', { + method: 'POST', + headers: { 'user-agent': 'AgentX' } + }), + response, + 10, + config + ); + expect(payload).toBeNull(); + }); + + it('tracks request when http method is allowlisted', () => { + const cfg = getConfig({ + MATOMO_URL: 'https://analytics.example.com', + MATOMO_SITE_ID: '99', + HTTP_METHOD_ALLOWLIST: 'GET,POST', + USER_AGENT_ALLOWLIST_REGEX: '.*' + }); + const response = new Response(null, { status: 200 }); + const payload = buildMatomoPayload( + new Request('https://example.com/path', { + method: 'POST', + headers: { 'user-agent': 'AgentX' } + }), + response, + 10, + cfg + ); + expect(payload).not.toBeNull(); + expect(payload?.url).toBe('https://example.com/path'); + }); + it('uses defaults when user agent allowlist is disabled', () => { const response = new Response(null, { status: 200 }); const payload = buildMatomoPayload(