diff --git a/public/css/panel.css b/public/css/panel.css
index a2e2d0a..bd8b92e 100644
--- a/public/css/panel.css
+++ b/public/css/panel.css
@@ -163,22 +163,95 @@
gap: var(--space-2);
}
-/* Rating */
+/* Community rating / vote */
#panel-rating-section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
-#panel-rating-section.empty {
- display: none;
+#panel-vote-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
}
-#panel-rating {
- font-size: var(--font-size-base);
+.vote-btn {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: var(--space-1) var(--space-2);
+ font-size: 18px;
+ cursor: pointer;
+ line-height: 1;
+ transition: background 150ms ease, border-color 150ms ease;
+}
+
+.vote-btn:hover {
+ background: var(--color-bg);
+ border-color: var(--color-text-secondary);
+}
+
+.vote-btn.active {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+.vote-score {
+ font-size: var(--font-size-lg);
+ font-weight: bold;
+ min-width: 2ch;
+ text-align: center;
+}
+
+.vote-score.positive { color: #2d9e2d; }
+.vote-score.negative { color: #c84040; }
+.vote-score.zero { color: var(--color-text-secondary); }
+
+/* Report buttons */
+#panel-report-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+#panel-report-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.report-btn {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ font-family: var(--font-family);
+ transition: background 150ms ease, color 150ms ease;
+}
+
+.report-btn:hover {
+ background: var(--color-bg);
color: var(--color-text-primary);
}
+.report-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.panel-report-feedback {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+.panel-report-feedback.hidden {
+ display: none;
+}
+
/* CTA button */
#panel-cta-section {
margin-top: auto;
@@ -211,20 +284,6 @@
opacity: 0.88;
}
-/* Report link */
-#panel-report-section {
- text-align: center;
-}
-
-#panel-report-link {
- font-size: var(--font-size-sm);
- color: var(--color-text-secondary);
- text-decoration: none;
-}
-
-#panel-report-link:hover {
- color: var(--color-node-stroke);
-}
/* Enrichment badge pills (status, pricing, opsec) */
.badge-pill {
diff --git a/public/index.html b/public/index.html
index e24858a..73088d6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -62,13 +62,23 @@
-
-
Rating
-
+
+
Community Rating
+
+
+ 0
+
+
diff --git a/public/js/arf.js b/public/js/arf.js
index ba4822b..f8c7a1b 100644
--- a/public/js/arf.js
+++ b/public/js/arf.js
@@ -509,8 +509,11 @@ function openPanel(d) {
opsecSection.classList.remove("empty");
}
- // Rating (Phase 3 — hidden until backend is ready)
- _setPanelSection("panel-rating-section", "panel-rating", d.data.rating);
+ // Community rating: render vote UI and fetch live score
+ _renderVoteUI(d);
+
+ // Report issue: reset buttons for new tool
+ _resetReportButtons(d);
// CTA
var ctaSection = document.getElementById("panel-cta-section");
@@ -597,6 +600,141 @@ document.addEventListener("DOMContentLoaded", function() {
// Canvas click: close panel when clicking the SVG background (not a node)
// This is wired after svgEl is created (see below in the zoom setup area).
+// === Community Voting (THE-109) ===
+
+/**
+ * Render the vote UI for the given node.
+ * Reads cached vote state from sessionStorage to avoid a round-trip on reopen,
+ * then asynchronously fetches the live score from /api/tool-stats.
+ */
+function _renderVoteUI(d) {
+ var toolId = parseName(d.data.name).cleanName;
+
+ // Reset button states
+ var upBtn = document.getElementById("vote-up");
+ var downBtn = document.getElementById("vote-down");
+ var scoreEl = document.getElementById("vote-score");
+ if (!upBtn || !downBtn || !scoreEl) return;
+
+ upBtn.classList.remove("active");
+ downBtn.classList.remove("active");
+ scoreEl.className = "vote-score zero";
+ scoreEl.textContent = "…";
+
+ // Read cached user vote from sessionStorage
+ var userVote = sessionStorage.getItem("vote:" + toolId) || null;
+ if (userVote === "up") upBtn.classList.add("active");
+ if (userVote === "down") downBtn.classList.add("active");
+
+ // Re-bind vote buttons for this tool
+ upBtn.onclick = function() { _castVote(toolId, "up", upBtn, downBtn, scoreEl); };
+ downBtn.onclick = function() { _castVote(toolId, "down", upBtn, downBtn, scoreEl); };
+
+ // Fetch live score asynchronously
+ fetch("/api/tool-stats?tool_id=" + encodeURIComponent(toolId))
+ .then(function(r) { return r.ok ? r.json() : null; })
+ .then(function(data) {
+ if (!data || !data.votes) return;
+ _updateScoreDisplay(scoreEl, data.votes.score);
+ })
+ .catch(function() { /* best effort */ });
+}
+
+/**
+ * Cast or toggle a vote.
+ */
+function _castVote(toolId, direction, upBtn, downBtn, scoreEl) {
+ var session = sessionStorage.getItem("osint-session") || "";
+ var currentVote = sessionStorage.getItem("vote:" + toolId) || null;
+
+ // Toggle off if same direction
+ var newDirection = (direction === currentVote) ? null : direction;
+
+ fetch("/api/vote", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tool_id: toolId, direction: newDirection, session_hash: session })
+ })
+ .then(function(r) { return r.ok ? r.json() : null; })
+ .then(function(data) {
+ if (!data || !data.ok) return;
+ // Update sessionStorage
+ if (data.userVote) {
+ sessionStorage.setItem("vote:" + toolId, data.userVote);
+ } else {
+ sessionStorage.removeItem("vote:" + toolId);
+ }
+ // Update button states
+ upBtn.classList.toggle("active", data.userVote === "up");
+ downBtn.classList.toggle("active", data.userVote === "down");
+ _updateScoreDisplay(scoreEl, data.score);
+ })
+ .catch(function() { /* best effort */ });
+}
+
+function _updateScoreDisplay(scoreEl, score) {
+ scoreEl.textContent = score > 0 ? "+" + score : String(score);
+ scoreEl.className = "vote-score " + (score > 0 ? "positive" : score < 0 ? "negative" : "zero");
+}
+
+// === Issue Reporting (THE-110) ===
+
+/**
+ * Reset report buttons for the newly opened tool.
+ */
+function _resetReportButtons(d) {
+ var toolId = parseName(d.data.name).cleanName;
+ var feedbackEl = document.getElementById("panel-report-feedback");
+ if (feedbackEl) {
+ feedbackEl.textContent = "";
+ feedbackEl.classList.add("hidden");
+ }
+
+ var buttons = document.querySelectorAll(".report-btn");
+ buttons.forEach(function(btn) {
+ btn.disabled = false;
+ btn.onclick = function() { _submitReport(toolId, btn.getAttribute("data-type"), btn, buttons, feedbackEl); };
+ });
+}
+
+/**
+ * Submit a report for a tool.
+ */
+function _submitReport(toolId, reportType, clickedBtn, allButtons, feedbackEl) {
+ var session = sessionStorage.getItem("osint-session") || "";
+
+ // Disable all buttons while request is in-flight
+ allButtons.forEach(function(b) { b.disabled = true; });
+
+ fetch("/api/report", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tool_id: toolId, report_type: reportType, session_hash: session })
+ })
+ .then(function(r) { return r.ok ? r.json() : null; })
+ .then(function(data) {
+ if (feedbackEl) {
+ if (data && data.ok) {
+ feedbackEl.textContent = data.counted
+ ? "Thanks for your report. We\u2019ll review it soon."
+ : "You\u2019ve already reported this issue.";
+ } else {
+ feedbackEl.textContent = "Report failed. Please try again later.";
+ // Re-enable on error so user can retry
+ allButtons.forEach(function(b) { b.disabled = false; });
+ }
+ feedbackEl.classList.remove("hidden");
+ }
+ })
+ .catch(function() {
+ allButtons.forEach(function(b) { b.disabled = false; });
+ if (feedbackEl) {
+ feedbackEl.textContent = "Report failed. Please try again later.";
+ feedbackEl.classList.remove("hidden");
+ }
+ });
+}
+
// Toggle light/dark mode and persist preference.
function goDark() {
var body = document.body;
diff --git a/src/worker.js b/src/worker.js
index c928460..0af9783 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -1,18 +1,22 @@
/**
* OSINT Framework – Cloudflare Worker entry point
*
- * Handles two API routes, then falls through to static assets:
+ * API routes (all others fall through to static assets):
* POST /api/track – fire-and-forget click tracking
- * GET /api/tool-stats – per-tool click counts
+ * GET /api/tool-stats – per-tool click + vote counts
+ * POST /api/vote – community upvote / downvote
+ * POST /api/report – flag dead link / paywalled / incorrect info
*
* KV binding: CLICK_DATA (configured in wrangler.jsonc + Pages dashboard)
+ * Secret: GITHUB_TOKEN (for auto-creating GitHub issues on report threshold)
*
* 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)
+ * - Vote dedup: permanent per session (sessionStorage already limits scope)
+ * - Report dedup key TTL: 7 days (prevents same session from inflating counts)
*/
const ALLOWED_ORIGINS = [
@@ -20,6 +24,9 @@ const ALLOWED_ORIGINS = [
"https://www.osintframework.com",
];
+const REPORT_THRESHOLD = 3; // auto-create GitHub issue after this many unique reports
+const REPORT_DEDUP_TTL = 7 * 24 * 3600; // 7 days in seconds
+
function corsHeaders(origin) {
const allowed =
ALLOWED_ORIGINS.includes(origin) || origin.endsWith(".osintframework.com")
@@ -43,6 +50,42 @@ function jsonResponse(data, status, origin) {
});
}
+/**
+ * Validate common inputs: tool_id and session_hash.
+ * Returns an error string if invalid, null if valid.
+ */
+function validateCommon(tool_id, session_hash) {
+ if (
+ typeof tool_id !== "string" ||
+ tool_id.length === 0 ||
+ tool_id.length > 200
+ ) {
+ return "invalid tool_id";
+ }
+ if (
+ typeof session_hash !== "string" ||
+ session_hash.length === 0 ||
+ session_hash.length > 64
+ ) {
+ return "invalid session_hash";
+ }
+ return null;
+}
+
+/**
+ * Check and increment rate limit for a session.
+ * Returns true if rate limited (over 60 req/min).
+ */
+async function isRateLimited(env, session_hash) {
+ 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 true;
+ await env.CLICK_DATA.put(rlKey, String(rlCount + 1), { expirationTtl: 120 });
+ return false;
+}
+
/**
* POST /api/track
* Body: { tool_id: string, session_hash: string, timestamp: number }
@@ -59,34 +102,13 @@ async function handleTrack(request, env) {
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);
+ const { tool_id, session_hash } = body;
+ const validationError = validateCommon(tool_id, session_hash);
+ if (validationError) {
+ return jsonResponse({ ok: false, error: validationError }, 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) {
+ if (await isRateLimited(env, session_hash)) {
return jsonResponse({ ok: false, error: "rate limited" }, 429, origin);
}
@@ -94,7 +116,6 @@ async function handleTrack(request, env) {
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);
}
@@ -103,20 +124,222 @@ async function handleTrack(request, env) {
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);
}
+/**
+ * POST /api/vote
+ * Body: { tool_id: string, direction: "up" | "down" | null, session_hash: string }
+ * direction=null removes the current vote (toggle off)
+ *
+ * Returns: { ok: true, score: number, userVote: "up" | "down" | null }
+ */
+async function handleVote(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, direction, session_hash } = body;
+ const validationError = validateCommon(tool_id, session_hash);
+ if (validationError) {
+ return jsonResponse({ ok: false, error: validationError }, 400, origin);
+ }
+
+ if (direction !== "up" && direction !== "down" && direction !== null) {
+ return jsonResponse(
+ { ok: false, error: "direction must be 'up', 'down', or null" },
+ 400,
+ origin
+ );
+ }
+
+ if (await isRateLimited(env, session_hash)) {
+ return jsonResponse({ ok: false, error: "rate limited" }, 429, origin);
+ }
+
+ const userVoteKey = `uservote:${session_hash}:${tool_id}`;
+ const upKey = `votes:up:${tool_id}`;
+ const downKey = `votes:down:${tool_id}`;
+
+ // Read current state in parallel
+ const [prevVoteRaw, upRaw, downRaw] = await Promise.all([
+ env.CLICK_DATA.get(userVoteKey),
+ env.CLICK_DATA.get(upKey),
+ env.CLICK_DATA.get(downKey),
+ ]);
+
+ const prevVote = prevVoteRaw; // "up", "down", or null
+ let upCount = upRaw ? parseInt(upRaw, 10) : 0;
+ let downCount = downRaw ? parseInt(downRaw, 10) : 0;
+
+ // Determine the new vote:
+ // If same direction is sent again, treat as toggle-off (remove vote)
+ let newVote = direction;
+ if (direction !== null && direction === prevVote) {
+ newVote = null; // toggle off
+ }
+
+ // Undo previous vote
+ if (prevVote === "up") upCount = Math.max(0, upCount - 1);
+ if (prevVote === "down") downCount = Math.max(0, downCount - 1);
+
+ // Apply new vote
+ if (newVote === "up") upCount++;
+ if (newVote === "down") downCount++;
+
+ // Persist
+ const writes = [
+ env.CLICK_DATA.put(upKey, String(upCount)),
+ env.CLICK_DATA.put(downKey, String(downCount)),
+ ];
+ if (newVote === null) {
+ writes.push(env.CLICK_DATA.delete(userVoteKey));
+ } else {
+ writes.push(env.CLICK_DATA.put(userVoteKey, newVote));
+ }
+ await Promise.all(writes);
+
+ return jsonResponse(
+ { ok: true, score: upCount - downCount, userVote: newVote },
+ 200,
+ origin
+ );
+}
+
+/**
+ * POST /api/report
+ * Body: { tool_id: string, report_type: "dead_link"|"paywalled"|"incorrect_info", session_hash: string }
+ *
+ * Returns: { ok: true, counted: boolean }
+ *
+ * When a tool accumulates REPORT_THRESHOLD unique reports of the same type within
+ * 7 days, a GitHub issue is auto-created on lockfale/OSINT-Framework (requires
+ * GITHUB_TOKEN env secret).
+ */
+async function handleReport(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, report_type, session_hash } = body;
+ const validationError = validateCommon(tool_id, session_hash);
+ if (validationError) {
+ return jsonResponse({ ok: false, error: validationError }, 400, origin);
+ }
+
+ const validTypes = ["dead_link", "paywalled", "incorrect_info"];
+ if (!validTypes.includes(report_type)) {
+ return jsonResponse(
+ { ok: false, error: "report_type must be dead_link, paywalled, or incorrect_info" },
+ 400,
+ origin
+ );
+ }
+
+ if (await isRateLimited(env, session_hash)) {
+ return jsonResponse({ ok: false, error: "rate limited" }, 429, origin);
+ }
+
+ // Dedup: one report per session per tool per type within 7 days
+ const dedupKey = `reported:${session_hash}:${tool_id}:${report_type}`;
+ const alreadyReported = await env.CLICK_DATA.get(dedupKey);
+ if (alreadyReported) {
+ return jsonResponse({ ok: true, counted: false }, 200, origin);
+ }
+
+ // Increment report counter
+ const countKey = `reportcount:${tool_id}:${report_type}`;
+ const countRaw = await env.CLICK_DATA.get(countKey);
+ const prevCount = countRaw ? parseInt(countRaw, 10) : 0;
+ const newCount = prevCount + 1;
+
+ await Promise.all([
+ env.CLICK_DATA.put(countKey, String(newCount)),
+ env.CLICK_DATA.put(dedupKey, "1", { expirationTtl: REPORT_DEDUP_TTL }),
+ ]);
+
+ // Check if we should create a GitHub issue (threshold reached, not already done)
+ if (newCount >= REPORT_THRESHOLD) {
+ const notifiedKey = `github_issue_created:${tool_id}:${report_type}`;
+ const alreadyNotified = await env.CLICK_DATA.get(notifiedKey);
+ if (!alreadyNotified && env.GITHUB_TOKEN) {
+ const created = await createGitHubIssue(env, tool_id, report_type, newCount);
+ if (created) {
+ await env.CLICK_DATA.put(notifiedKey, "1");
+ }
+ }
+ }
+
+ return jsonResponse({ ok: true, counted: true }, 200, origin);
+}
+
+/**
+ * Create a GitHub issue on lockfale/OSINT-Framework via the GitHub API.
+ * Returns true on success.
+ */
+async function createGitHubIssue(env, tool_id, report_type, count) {
+ const typeLabels = {
+ dead_link: "dead link",
+ paywalled: "paywalled",
+ incorrect_info: "incorrect info",
+ };
+ const typeLabel = typeLabels[report_type] || report_type;
+ const title = `[Community Report] ${tool_id} flagged as ${typeLabel}`;
+ const body = [
+ `**Tool:** ${tool_id}`,
+ `**Report type:** ${typeLabel}`,
+ `**Report count:** ${count} unique reports`,
+ ``,
+ `This issue was automatically created by the OSINT Framework community reporting system.`,
+ `Community members have flagged this tool ${count} time(s) as \`${report_type}\`.`,
+ ``,
+ `Please review and take appropriate action (update URL, change pricing badge, correct description, etc.).`,
+ ].join("\n");
+
+ try {
+ const resp = await fetch(
+ "https://api.github.com/repos/lockfale/OSINT-Framework/issues",
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${env.GITHUB_TOKEN}`,
+ "Content-Type": "application/json",
+ "User-Agent": "OSINT-Framework-Worker/1.0",
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ body: JSON.stringify({
+ title,
+ body,
+ labels: ["community-report", "needs-review"],
+ }),
+ }
+ );
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
/**
* GET /api/tool-stats?tool_id=
*
- * Returns: { tool_id: string, clicks: number }
+ * Returns: { tool_id: string, clicks: number, votes: { up: number, down: number, score: number } }
*/
async function handleStats(request, env) {
const origin = request.headers.get("Origin") || "";
@@ -127,11 +350,21 @@ async function handleStats(request, env) {
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;
+ const [clicksRaw, upRaw, downRaw] = await Promise.all([
+ env.CLICK_DATA.get(`clicks:${tool_id}`),
+ env.CLICK_DATA.get(`votes:up:${tool_id}`),
+ env.CLICK_DATA.get(`votes:down:${tool_id}`),
+ ]);
- return jsonResponse({ tool_id, clicks }, 200, origin);
+ const clicks = clicksRaw ? parseInt(clicksRaw, 10) : 0;
+ const up = upRaw ? parseInt(upRaw, 10) : 0;
+ const down = downRaw ? parseInt(downRaw, 10) : 0;
+
+ return jsonResponse(
+ { tool_id, clicks, votes: { up, down, score: up - down } },
+ 200,
+ origin
+ );
}
export default {
@@ -152,6 +385,14 @@ export default {
return handleStats(request, env);
}
+ if (url.pathname === "/api/vote" && request.method === "POST") {
+ return handleVote(request, env);
+ }
+
+ if (url.pathname === "/api/report" && request.method === "POST") {
+ return handleReport(request, env);
+ }
+
// Everything else: serve static assets
return env.ASSETS.fetch(request);
},