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 - +
+ +
+ + 0 + +
- Report an issue with this tool + +
+ + + +
+
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); },