Merge pull request #588 from lockfale/feature/THE-109-110-phase3-community-features

Add Phase 3 community voting and issue reporting (THE-109, THE-110)
This commit is contained in:
s0lray
2026-03-26 10:38:25 -04:00
committed by GitHub
4 changed files with 510 additions and 62 deletions
+78 -19
View File
@@ -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 {
+14 -4
View File
@@ -62,13 +62,23 @@
<span id="panel-opsec"></span>
</div>
<div id="panel-rating-section" class="empty">
<span class="panel-section-label">Rating</span>
<span id="panel-rating"></span>
<div id="panel-rating-section">
<span class="panel-section-label">Community Rating</span>
<div id="panel-vote-row">
<button id="vote-up" class="vote-btn" aria-label="Upvote" title="Upvote">&#128077;</button>
<span id="vote-score" class="vote-score">0</span>
<button id="vote-down" class="vote-btn" aria-label="Downvote" title="Downvote">&#128078;</button>
</div>
</div>
<div id="panel-report-section">
<a id="panel-report-link" href="https://github.com/lockfale/OSINT-Framework/issues/new?template=tool-feedback.md" target="_blank" rel="noopener noreferrer">Report an issue with this tool</a>
<span class="panel-section-label">Report an Issue</span>
<div id="panel-report-buttons">
<button class="report-btn" data-type="dead_link">Dead link</button>
<button class="report-btn" data-type="paywalled">Paywalled</button>
<button class="report-btn" data-type="incorrect_info">Incorrect info</button>
</div>
<div id="panel-report-feedback" class="panel-report-feedback hidden"></div>
</div>
<div id="panel-cta-section" class="empty">
+140 -2
View File
@@ -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;
+278 -37
View File
@@ -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=<name>
*
* 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);
},