mirror of
https://github.com/lockfale/OSINT-Framework.git
synced 2026-08-17 19:35:41 +02:00
Add click tracking Worker and frontend sendBeacon integration (THE-106)
- src/worker.js: Cloudflare Worker entry point handling POST /api/track and GET /api/tool-stats, falling through to ASSETS for static files - wrangler.jsonc: wire main entry point + CLICK_DATA KV binding (namespace: osint_framework_kv, id: cdb975e31e) - public/js/arf.js: fire sendBeacon to /api/track on Open Tool click Privacy: no IPs stored, no cookies, client-generated session_hash only. Dedup: one counted click per session per tool (1-hour TTL). Rate limit: 60 req/min per session_hash (2-min TTL key). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -521,6 +521,24 @@ function openPanel(d) {
|
||||
ctaLink.href = url;
|
||||
ctaLink.textContent = "Open " + parsed.cleanName + " \u2197";
|
||||
ctaSection.classList.remove("empty");
|
||||
|
||||
// Fire-and-forget click tracking via sendBeacon (THE-106)
|
||||
// Clone values into closure so they're captured correctly per open
|
||||
(function(toolId) {
|
||||
ctaLink.onclick = function() {
|
||||
if (!navigator.sendBeacon) return;
|
||||
var session = sessionStorage.getItem("osint-session") || "";
|
||||
var payload = JSON.stringify({
|
||||
tool_id: toolId,
|
||||
session_hash: session,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
navigator.sendBeacon(
|
||||
"/api/track",
|
||||
new Blob([payload], { type: "application/json" })
|
||||
);
|
||||
};
|
||||
})(parsed.cleanName);
|
||||
} else {
|
||||
ctaSection.classList.add("empty");
|
||||
}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* OSINT Framework – Cloudflare Worker entry point
|
||||
*
|
||||
* Handles two API routes, then falls through to static assets:
|
||||
* POST /api/track – fire-and-forget click tracking
|
||||
* GET /api/tool-stats – per-tool click counts
|
||||
*
|
||||
* KV binding: CLICK_DATA (configured in wrangler.jsonc + Pages dashboard)
|
||||
*
|
||||
* Privacy contract:
|
||||
* - No IP addresses stored
|
||||
* - No cookies used or set
|
||||
* - session_hash is client-generated and ephemeral (sessionStorage)
|
||||
* - Dedup key TTL: 1 hour (prevents double-counting same session open)
|
||||
* - Rate-limit key TTL: 2 min (60 req/min ceiling per session_hash)
|
||||
*/
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
"https://osintframework.com",
|
||||
"https://www.osintframework.com",
|
||||
];
|
||||
|
||||
function corsHeaders(origin) {
|
||||
const allowed =
|
||||
ALLOWED_ORIGINS.includes(origin) || origin.endsWith(".osintframework.com")
|
||||
? origin
|
||||
: ALLOWED_ORIGINS[0];
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowed,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(data, status, origin) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...corsHeaders(origin),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/track
|
||||
* Body: { tool_id: string, session_hash: string, timestamp: number }
|
||||
*
|
||||
* Returns: { ok: true } or { ok: false, error: string }
|
||||
*/
|
||||
async function handleTrack(request, env) {
|
||||
const origin = request.headers.get("Origin") || "";
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonResponse({ ok: false, error: "invalid json" }, 400, origin);
|
||||
}
|
||||
|
||||
const { tool_id, session_hash, timestamp } = body;
|
||||
|
||||
if (
|
||||
typeof tool_id !== "string" ||
|
||||
tool_id.length === 0 ||
|
||||
tool_id.length > 200
|
||||
) {
|
||||
return jsonResponse({ ok: false, error: "invalid tool_id" }, 400, origin);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof session_hash !== "string" ||
|
||||
session_hash.length === 0 ||
|
||||
session_hash.length > 64
|
||||
) {
|
||||
return jsonResponse(
|
||||
{ ok: false, error: "invalid session_hash" },
|
||||
400,
|
||||
origin
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit: 60 req/min per session_hash (stored in KV with 2-min TTL)
|
||||
const minute = Math.floor(Date.now() / 60000);
|
||||
const rlKey = `ratelimit:${session_hash}:${minute}`;
|
||||
const rlRaw = await env.CLICK_DATA.get(rlKey);
|
||||
const rlCount = rlRaw ? parseInt(rlRaw, 10) : 0;
|
||||
if (rlCount >= 60) {
|
||||
return jsonResponse({ ok: false, error: "rate limited" }, 429, origin);
|
||||
}
|
||||
|
||||
// Dedup: one counted click per tool per session (TTL: 1 hour)
|
||||
const dedupKey = `dedup:${session_hash}:${tool_id}`;
|
||||
const alreadyCounted = await env.CLICK_DATA.get(dedupKey);
|
||||
if (alreadyCounted) {
|
||||
// Acknowledge without incrementing
|
||||
return jsonResponse({ ok: true, counted: false }, 200, origin);
|
||||
}
|
||||
|
||||
// Increment click counter
|
||||
const clickKey = `clicks:${tool_id}`;
|
||||
const currentRaw = await env.CLICK_DATA.get(clickKey);
|
||||
const current = currentRaw ? parseInt(currentRaw, 10) : 0;
|
||||
|
||||
// Write all three keys; dedup and rate-limit keys have TTLs
|
||||
await Promise.all([
|
||||
env.CLICK_DATA.put(clickKey, String(current + 1)),
|
||||
env.CLICK_DATA.put(dedupKey, "1", { expirationTtl: 3600 }),
|
||||
env.CLICK_DATA.put(rlKey, String(rlCount + 1), { expirationTtl: 120 }),
|
||||
]);
|
||||
|
||||
return jsonResponse({ ok: true, counted: true }, 200, origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tool-stats?tool_id=<name>
|
||||
*
|
||||
* Returns: { tool_id: string, clicks: number }
|
||||
*/
|
||||
async function handleStats(request, env) {
|
||||
const origin = request.headers.get("Origin") || "";
|
||||
const url = new URL(request.url);
|
||||
const tool_id = url.searchParams.get("tool_id");
|
||||
|
||||
if (!tool_id || tool_id.length === 0 || tool_id.length > 200) {
|
||||
return jsonResponse({ ok: false, error: "invalid tool_id" }, 400, origin);
|
||||
}
|
||||
|
||||
const clickKey = `clicks:${tool_id}`;
|
||||
const raw = await env.CLICK_DATA.get(clickKey);
|
||||
const clicks = raw ? parseInt(raw, 10) : 0;
|
||||
|
||||
return jsonResponse({ tool_id, clicks }, 200, origin);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get("Origin") || "";
|
||||
|
||||
// Handle CORS preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/track" && request.method === "POST") {
|
||||
return handleTrack(request, env);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/tool-stats" && request.method === "GET") {
|
||||
return handleStats(request, env);
|
||||
}
|
||||
|
||||
// Everything else: serve static assets
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "osint-framework",
|
||||
"main": "src/worker.js",
|
||||
"compatibility_date": "2025-09-27",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
@@ -10,5 +11,16 @@
|
||||
},
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
// KV namespace for click tracking and tool stats
|
||||
// Production namespace ID from Cloudflare dashboard (osint_framework_kv)
|
||||
"binding": "CLICK_DATA",
|
||||
"id": "cdb975e31e"
|
||||
// For local dev with `wrangler dev`, create a preview namespace:
|
||||
// wrangler kv namespace create "osint_framework_kv" --preview
|
||||
// then add: "preview_id": "<preview-namespace-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user