mirror of
https://github.com/lockfale/OSINT-Framework.git
synced 2026-08-24 06:42:23 +02:00
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:
+78
-19
@@ -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
@@ -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">👍</button>
|
||||
<span id="vote-score" class="vote-score">0</span>
|
||||
<button id="vote-down" class="vote-btn" aria-label="Downvote" title="Downvote">👎</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
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user