// ─────────────────────────────────────────────────────────────────────────────
// Knockoff report worker (Cloudflare Worker + D1)
//
// Accepts brand misclassification reports from the extension and stores them
// for review. No accounts, no cookies, no PII. The reporter IP is only ever
// stored as a salted hash, and only to rate-limit abuse.
//
// POST /report one report (JSON body, see below)
// GET /brands known-brands list (text, one per line;
// served from D1 and edge-cached 6h; the
// extension's daily refresh hits this)
// GET /flagged curated blocklist additions (text, one
// per line; extensions fetch daily)
// GET /review?token=... HTML triage dashboard: queue of uncurated
// brands + one-click Trust/Block/Dismiss
// POST /curate?token=... {brand, list: "flagged"|"known"|"dismissed"}
// to decide, {brand, remove: true} to undo
// GET /reports?token=...&days=7 recent reports (JSON, review only)
// GET /tallies?token=... per-brand vote tallies (JSON, review only)
// GET /queue?token=... triage queue, same rows the dashboard
// shows (JSON, review only)
//
// Deploy (from this directory):
// wrangler d1 create knockoff-reports # once; put the id in wrangler.toml
// wrangler d1 execute knockoff-reports --file=schema.sql --remote
// wrangler d1 execute knockoff-reports --file=migrate-triage.sql --remote # pre-existing DBs, once
// wrangler d1 execute knockoff-reports --file=seed-brands.sql --remote # once
// wrangler secret put REVIEW_TOKEN # any long random string
// wrangler secret put IP_SALT # any long random string
// wrangler deploy
// Then set REPORT_ENDPOINT in the extension's src/content.js to the worker URL.
// ─────────────────────────────────────────────────────────────────────────────
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
};
const MAX_REPORTS_PER_IP_PER_HOUR = 30;
const BRANDS_CACHE_SECONDS = 6 * 60 * 60;
function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS }
});
}
async function sha256(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function normalize(s) {
return (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
async function handleReport(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ error: "invalid JSON" }, 400);
}
const brand = String(body.brand || "").trim().slice(0, 64);
const brandKey = normalize(brand);
const suggestion = body.suggestion;
if (!brandKey) return json({ error: "brand required" }, 400);
if (suggestion !== "is_junk" && suggestion !== "not_junk") {
return json({ error: "suggestion must be is_junk or not_junk" }, 400);
}
const asin = /^[A-Z0-9]{10}$/.test(body.asin || "") ? body.asin : null;
const verdict = String(body.verdict || "").slice(0, 20) || null;
const marketplace = String(body.marketplace || "").slice(0, 40) || null;
const extVersion = String(body.extVersion || "").slice(0, 20) || null;
const title = String(body.title || "").slice(0, 150) || null;
const reason = String(body.reason || "").slice(0, 200) || null;
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const ipHash = await sha256(env.IP_SALT + ip);
const { count } = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM reports WHERE ip_hash = ?1 AND created_at > datetime('now', '-1 hour')"
).bind(ipHash).first();
if (count >= MAX_REPORTS_PER_IP_PER_HOUR) {
return json({ error: "rate limited" }, 429);
}
// One row per reporter per brand: reporting the same brand again updates
// the earlier vote instead of stacking the tally. COALESCE keeps context
// fields (ASIN, title...) from an earlier report when the new one lacks them.
await env.DB.prepare(
`INSERT INTO reports (brand, brand_key, suggestion, verdict, asin, marketplace, ext_version, ip_hash, title, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT (ip_hash, brand_key) DO UPDATE SET
brand = excluded.brand, suggestion = excluded.suggestion,
verdict = excluded.verdict, asin = COALESCE(excluded.asin, asin),
marketplace = COALESCE(excluded.marketplace, marketplace),
ext_version = excluded.ext_version,
title = COALESCE(excluded.title, title),
reason = COALESCE(excluded.reason, reason),
created_at = datetime('now')`
).bind(brand, brandKey, suggestion, verdict, asin, marketplace, extVersion, ipHash, title, reason).run();
return json({ ok: true });
}
// Serve the known-brands allowlist: the seeded base list plus known-brand
// additions curated from the review dashboard, edge-cached. A failed D1 read
// throws and 500s, which clients treat like any bad refresh — they keep
// their last good copy.
async function handleBrands(request, env, ctx) {
// The Cache API only works on custom-domain zones; touching it with a
// workers.dev URL dies at the edge with error 1042 (uncatchable), so on
// the workers.dev alias we just serve uncached.
const url = new URL(request.url);
const useCache = !url.hostname.endsWith("workers.dev");
const cache = caches.default;
const cacheKey = new Request(url.origin + "/brands-v3");
if (useCache) {
const cached = await cache.match(cacheKey);
if (cached) return cached;
}
const { results } = await env.DB.prepare(
`SELECT brand FROM brands
UNION SELECT brand FROM curated WHERE list = 'known'
ORDER BY brand`
).all();
const res = new Response(results.map((r) => r.brand).join("\n"), {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=" + BRANDS_CACHE_SECONDS,
...CORS
}
});
if (useCache) ctx.waitUntil(cache.put(cacheKey, res.clone()));
return res;
}
// Curated blocklist additions. Uncached: traffic is one hit per install per
// day and a new block should propagate immediately.
async function handleFlagged(env) {
const { results } = await env.DB.prepare(
"SELECT brand FROM curated WHERE list = 'flagged' ORDER BY brand"
).all();
return new Response(results.map((r) => r.brand).join("\n"), {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
...CORS
}
});
}
async function handleCurate(request, env, url) {
if (url.searchParams.get("token") !== env.REVIEW_TOKEN) {
return json({ error: "unauthorized" }, 401);
}
let body;
try {
body = await request.json();
} catch {
return json({ error: "invalid JSON" }, 400);
}
const brand = String(body.brand || "").trim().slice(0, 64);
const key = normalize(brand);
if (!key) return json({ error: "brand required" }, 400);
if (body.remove) {
await env.DB.prepare("DELETE FROM curated WHERE brand_key = ?1").bind(key).run();
return json({ ok: true });
}
if (body.list !== "flagged" && body.list !== "known" && body.list !== "dismissed") {
return json({ error: "list must be flagged, known or dismissed" }, 400);
}
await env.DB.prepare(
`INSERT INTO curated (brand, brand_key, list) VALUES (?1, ?2, ?3)
ON CONFLICT(brand_key) DO UPDATE SET list = ?3, brand = ?1`
).bind(brand, key, body.list).run();
return json({ ok: true });
}
// Human-readable review dashboard. Brand strings are reporter-submitted, so
// everything is HTML-escaped on the way out.
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
// The queue is only brands with no curation decision yet — Trust, Block and
// Dismiss all clear a brand from it. brand_tallies already excludes reports
// that agree with the verdict shown at report time, so pure noise never
// queues. The latest reported ASIN, product title and detector reason ride
// along as decision context.
const QUEUE_SQL = `SELECT t.*,
(SELECT r.asin FROM reports r WHERE r.brand_key = t.brand_key
AND r.asin IS NOT NULL ORDER BY r.id DESC LIMIT 1) AS asin,
(SELECT r.marketplace FROM reports r WHERE r.brand_key = t.brand_key
AND r.asin IS NOT NULL ORDER BY r.id DESC LIMIT 1) AS marketplace,
(SELECT r.title FROM reports r WHERE r.brand_key = t.brand_key
AND r.title IS NOT NULL ORDER BY r.id DESC LIMIT 1) AS title,
(SELECT r.reason FROM reports r WHERE r.brand_key = t.brand_key
AND r.reason IS NOT NULL ORDER BY r.id DESC LIMIT 1) AS reason,
(SELECT GROUP_CONCAT(DISTINCT r.verdict) FROM reports r
WHERE r.brand_key = t.brand_key) AS verdicts
FROM brand_tallies t
LEFT JOIN curated c ON c.brand_key = t.brand_key
WHERE c.brand_key IS NULL
ORDER BY t.total DESC, t.last_report DESC LIMIT 500`;
async function handleDashboard(env, url) {
if (url.searchParams.get("token") !== env.REVIEW_TOKEN) {
return json({ error: "unauthorized" }, 401);
}
const { results: queue } = await env.DB.prepare(QUEUE_SQL).all();
const { results: recent } = await env.DB.prepare(
`SELECT brand, suggestion, verdict, asin, marketplace, created_at
FROM reports ORDER BY id DESC LIMIT 100`
).all();
const { results: curated } = await env.DB.prepare(
"SELECT brand, brand_key, list, created_at FROM curated ORDER BY created_at DESC"
).all();
// False positives (real brands being filtered) erode trust the most, so
// brands whose reporters lean "real" triage first.
const isFp = (t) => t.real_votes > 0 && t.real_votes >= t.junk_votes;
const fp = queue.filter(isFp)
.sort((a, b) => b.real_votes - a.real_votes || b.total - a.total);
const junk = queue.filter((t) => !isFp(t))
.sort((a, b) => b.junk_votes - a.junk_votes || b.total - a.total);
const queueRow = (t) => {
const product = t.asin
? `${esc(t.asin)}`
: `search`;
// Second line: the reported product title (the fastest way for a human to
// judge a brand), falling back to the detector's reason for old reports.
const context = t.title || t.reason
? `
${esc(t.title || t.reason)}
` : "";
// Single, uncorroborated reports start collapsed behind the section's
// toggle; search still reaches them.
const single = t.total === 1 ? ' class="single" hidden' : "";
return `