From fb3635dd4772130e1b5f4aa5c2e7e7c5ae839947 Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Mon, 26 Oct 2026 15:32:33 +0000 Subject: [PATCH] Feat: Count ?i= and ?src= in GoatCounter image hits Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 2 +- functions/[[path]].js | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index fbc5c39..b4c414d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -25,7 +25,7 @@ Queries and IP addresses may be included in alerts sent to the maintainer throug We use [GoatCounter](https://www.goatcounter.com), a privacy-focused analytics service that doesn't use cookies. - The homepage loads GoatCounter's script, which records the visit, the referring page, and your screen width. -- Each image request is reported to GoatCounter from our servers, including the query, the referring page (for example, the site embedding the image), your user agent, and your IP address. +- Each image request is reported to GoatCounter from our servers, including the query and any `?i=` or `?src=`, the referring page (for example, the site embedding the image), your user agent, and your IP address. - GoatCounter uses your IP address and user agent to work out your country, browser, and operating system, and to count unique visitors. It [doesn't store the IP address or user agent itself](https://www.goatcounter.com/help/sessions). ## Newsletter diff --git a/functions/[[path]].js b/functions/[[path]].js index c0071f3..a628953 100644 --- a/functions/[[path]].js +++ b/functions/[[path]].js @@ -65,7 +65,9 @@ export async function onRequest(context) { if (!i || (site && !src) || extra) return asset("bad.webp"); if (i > MAX_INDEX[site]) return asset("max-i.webp"); - context.waitUntil(countHit(env, request, query, site)); + // Only the ?i= and ?src= actually given are counted (normalized, e.g. ?i=02 → ?i=2), so bare queries stay one path + const counted = new URLSearchParams(Object.entries({ i, src }).filter(([k]) => url.searchParams.has(k))).toString(); + context.waitUntil(countHit(env, request, query, site, counted)); // Main-site i=1 keeps the bare query so existing cache entries stay valid. // Other keys use uppercase prefixes, which can't collide with (always lowercased) queries. @@ -353,15 +355,17 @@ async function notify(env,{ title, message, tags, priority }) { } catch {} } -// site is "free", "ai" or "" (main site); each is counted under its own path prefix -async function countHit(env, request, query, site) { +// site is "free", "ai" or "" (main site); each is counted under its own path prefix, with params (e.g. "i=2&src=flux") +// after the query. ? and # in the query are escaped so GoatCounter doesn't split the path there. +async function countHit(env, request, query, site, params) { if (!env.GOATCOUNTER_URL || !env.GOATCOUNTER_TOKEN) return; const h = k => request.headers.get(k) || ""; + const path = `${site ? `/${site}` : ""}/${query.replace(/ /g, "+").replace(/[?#]/g, encodeURIComponent)}${params && `?${params}`}`; try { await fetch(`${env.GOATCOUNTER_URL}/api/v0/count`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${env.GOATCOUNTER_TOKEN}` }, - body: JSON.stringify({ no_sessions: true, hits: [{ path: `${site ? `/${site}` : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }), + body: JSON.stringify({ no_sessions: true, hits: [{ path, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }), signal: AbortSignal.timeout(5000) }); } catch {}