Ctrl K
ComunidadGuías…

feedback-relay/worker.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Clips Kitty feedback relay — a tiny Cloudflare Worker that lets app
 * users file bug reports / feature requests WITHOUT a GitHub account.
 *
 * The app POSTs a report here; this worker validates it, applies anti-spam
 * checks, and creates a GitHub Issue on the repo using a token that only
 * lives in this worker's secrets (never in the open-source app).
 *
 * Anti-spam layers:
 *  1. Custom header (x-clips-studio): its absence 403s everything, and its
 *     presence on cross-origin browser requests forces a CORS preflight we
 *     never answer — web pages can't draft visitors into spamming.
 *  2. Proof-of-work challenge: the client must fetch GET /challenge and
 *     burn ~1s of CPU finding a nonce whose sha256(salt+nonce) starts with
 *     DIFFICULTY zero bits. Invisible to real users, expensive at spam
 *     scale. Challenges are HMAC-signed, IP-bound, expire in 10 minutes,
 *     and are single-use (KV).
 *  3. Per-IP rate limit: MAX_PER_DAY submissions per IP per UTC day (KV).
 *  4. Size caps + strict field/structure validation + image magic bytes.
 *  5. Honeypot: a "website" field that humans never see; bots that fill it
 *     get a fake success.
 *
 * Duplicate handling: exact resubmissions are answered from a 24h KV cache;
 * reports whose title strongly matches an open from-app issue become a
 * comment on that issue (comment count = how many users hit it).
 *
 * Secrets (wrangler secret put ...):
 *   GITHUB_TOKEN  — fine-grained PAT, Issues: Read+Write on the repo only
 *   HMAC_KEY      — any long random string (signs challenges)
 * Vars (wrangler.toml):
 *   REPO          — "owner/name", e.g. "ColinGPT9/clips-studio"
 * KV binding: FEEDBACK_KV
 */

const DIFFICULTY_BITS = 20; // ~1s of hashing on a normal PC
const CHALLENGE_TTL_S = 600;
const MAX_PER_DAY = 5;
const MAX_BODY_BYTES = 6 * 1024 * 1024; // report + up to 3 screenshots
const MAX_TEXT = 20_000; // per free-text field
const MAX_IMAGES = 3;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;

const TYPES = {
  bug: { labels: ["bug", "from-app"], prefix: "[Bug]" },
  feature: { labels: ["feature-request", "from-app"], prefix: "[Feature]" },
  improvement: { labels: ["enhancement", "from-app"], prefix: "[Improvement]" },
};

const AREA_LABELS = new Set([
  "ui", "ai", "performance", "accessibility", "video-editor", "clips-studio",
  "windows", "youtube", "twitch", "kick", "ffmpeg", "whisper", "gemma", "ollama",
]);

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const ip = request.headers.get("cf-connecting-ip") || "unknown";
    try {
      // The custom header is required on every route. Cross-origin browser
      // requests with a custom header trigger a CORS preflight, which this
      // worker never answers — so web pages can't draft their visitors'
      // browsers (and fresh IPs) into spamming the relay. The desktop app
      // sets it trivially; only browsers are locked out.
      if (request.headers.get("x-clips-studio") !== "1") {
        return json({ error: "bad client" }, 403);
      }
      if (request.method === "GET" && url.pathname === "/challenge") {
        return json(await makeChallenge(env, ip));
      }
      if (request.method === "POST" && url.pathname === "/submit") {
        return await handleSubmit(request, env, ip);
      }
      return json({ error: "not found" }, 404);
    } catch (e) {
      // Log for `wrangler tail` / Workers Logs; never echo internals.
      console.error("relay error:", e);
      return json({ error: "relay error" }, 500);
    }
  },
};

// ---- proof-of-work challenge ------------------------------------------------

// Challenges are bound to the requesting IP: a botnet can't solve once and
// share the solution — every node must burn its own CPU.
async function makeChallenge(env, ip) {
  const salt = crypto.randomUUID();
  const expires = Date.now() + CHALLENGE_TTL_S * 1000;
  const sig = await hmac(env.HMAC_KEY, `${salt}.${expires}.${ip}`);
  return { salt, expires, sig, difficulty: DIFFICULTY_BITS };
}

async function verifyPow(env, pow, ip) {
  if (!pow || typeof pow !== "object") return "missing proof-of-work";
  const { salt, expires, sig, nonce } = pow;
  if (typeof salt !== "string" || typeof nonce !== "string") return "bad pow fields";
  if (!Number.isFinite(expires) || Date.now() > expires) return "challenge expired";
  if ((await hmac(env.HMAC_KEY, `${salt}.${expires}.${ip}`)) !== sig) {
    return "bad challenge signature";
  }
  // hash must start with DIFFICULTY_BITS zero bits
  const digest = await sha256Bytes(`${salt}.${nonce}`);
  let bits = 0;
  for (const byte of digest) {
    if (byte === 0) { bits += 8; continue; }
    bits += Math.clz32(byte) - 24;
    break;
  }
  if (bits < DIFFICULTY_BITS) return "proof-of-work too weak";
  // Single use, marked only AFTER the hash checks out — garbage submissions
  // don't consume KV's 1k writes/day. (KV is eventually consistent, so a
  // cross-region burst could reuse a challenge briefly; the rate limit and
  // the PoW cost bound the damage — a deliberate free-tier trade-off.)
  if (await env.FEEDBACK_KV.get(`pow:${salt}`)) return "challenge already used";
  await env.FEEDBACK_KV.put(`pow:${salt}`, "1", { expirationTtl: CHALLENGE_TTL_S });
  return null;
}

// ---- submit -----------------------------------------------------------------

async function handleSubmit(request, env, ip) {
  // Fast-path reject on the declared size, then verify against the ACTUAL
  // bytes — Content-Length is client-controlled and can be absent or a lie.
  const len = Number(request.headers.get("content-length") || 0);
  if (len > MAX_BODY_BYTES) return json({ error: "report too large" }, 413);
  const raw = await request.text();
  if (raw.length > MAX_BODY_BYTES) return json({ error: "report too large" }, 413);

  // KV read-then-write is eventually consistent (not atomic) — good enough
  // for a feedback box; worst case a burst slightly exceeds the cap.
  const day = new Date().toISOString().slice(0, 10);
  const rlKey = `rl:${ip}:${day}`;
  const used = Number((await env.FEEDBACK_KV.get(rlKey)) || 0);
  if (used >= MAX_PER_DAY) {
    return json({ error: "daily feedback limit reached — try again tomorrow" }, 429);
  }

  let body;
  try {
    body = JSON.parse(raw);
  } catch {
    return json({ error: "invalid JSON" }, 400);
  }
  if (!body || typeof body !== "object") return json({ error: "invalid JSON" }, 400);

  // Honeypot: invisible field a human never fills. Pretend success.
  if (body.website) return json({ ok: true, url: "" });

  const powError = await verifyPow(env, body.pow, ip);
  if (powError) return json({ error: powError }, 403);

  // ---- validation (server-side, strict) ----
  const type = TYPES[body.type];
  if (!type) return json({ error: "type must be bug|feature|improvement" }, 400);
  // Neutralized @mentions: anonymous reports must not be able to ping
  // arbitrary GitHub users from this repo (a zero-width space breaks the
  // mention without changing how the text reads).
  const title = demention(clean(body.title, 140));
  const markdown = demention(clean(body.markdown, MAX_TEXT));
  if (!title || title.length < 8) return json({ error: "title too short" }, 400);
  // Structure check: the app builds one "### question" section per answered
  // field, and required questions are enforced client-side — a report with
  // fewer than two sections or under 60 chars is a skeleton from a modified
  // client, not a real submission.
  if (!markdown || markdown.length < 60) return json({ error: "report too short" }, 400);
  if ((markdown.match(/^### /gm) || []).length < 2) {
    return json({ error: "report is missing required sections" }, 400);
  }
  const labels = [...type.labels];
  for (const l of Array.isArray(body.areas) ? body.areas.slice(0, 4) : []) {
    if (AREA_LABELS.has(l)) labels.push(l);
  }
  if (body.severity === "critical" || body.severity === "high") {
    labels.push(body.severity === "critical" ? "critical" : "high-priority");
  }

  // ---- duplicate layer 1: exact resubmission (double-click, retry) ----
  const dupKey = "dup:" + (await sha256Hex(`${body.type}.${title}.${markdown}`));
  const already = await env.FEEDBACK_KV.get(dupKey);
  if (already) return json({ ok: true, url: already, duplicate: true });

  // ---- optional screenshots -> committed to the feedback-assets branch ----
  let imagesMd = "";
  const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : [];
  for (let i = 0; i < images.length; i++) {
    const img = images[i];
    if (typeof img?.b64 !== "string" || img.b64.length > MAX_IMAGE_BYTES * 1.4) continue;
    const ext = img.ext === "jpg" ? "jpg" : "png"; // whitelist
    // Magic-byte check: without it this branch is anonymous file hosting
    // for arbitrary bytes served from raw.githubusercontent under the repo.
    if (!looksLikeImage(img.b64, ext)) continue;
    const path = `assets/${Date.now()}-${i}.${ext}`;
    const put = await gh(env, `contents/${path}`, "PUT", {
      message: `feedback screenshot`,
      content: img.b64,
      branch: "feedback-assets",
    });
    if (put.ok) {
      const rawUrl = `https://raw.githubusercontent.com/${env.REPO}/feedback-assets/${path}`;
      imagesMd += `\n![screenshot ${i + 1}](${rawUrl})`;
    }
  }
  // Every report opens by saying where it came from.
  //
  // The relay authenticates with the maintainer's own PAT, so GitHub records
  // the MAINTAINER as the author of every issue it files - reports written by
  // app users appear, to anyone reading, to have been written by him. Reports
  // land overnight under his name and read as though he filed them.
  //
  // A banner does not change the author field; only a separate account can do
  // that (see README). It does make every issue say plainly, in its own first
  // line, that a user wrote it. The duplicate-comment path below has always
  // said "Another user reported..." - new issues never got the same courtesy.
  const ATTRIBUTION = `> **Submitted by a Clips Kitty user** through the in-app feedback hub, and filed here automatically. Not written by the maintainer.

`;

  const fullBody = ATTRIBUTION + markdown + (imagesMd ? `\n\n### Screenshots\n${imagesMd}` : "");

  // ---- duplicate layer 2: same problem already reported by someone else?
  // One search call; on a strong title match the report becomes a comment
  // on the existing issue (comment count = "how many users hit this").
  // Search failures fall through to creating a normal issue.
  const similar = await findSimilar(env, title, type.labels[0]);
  if (similar) {
    const c = await gh(env, `issues/${similar.number}/comments`, "POST", {
      body: `Another user reported the same problem from the app:\n\n${fullBody}`,
    });
    if (c.ok) {
      await env.FEEDBACK_KV.put(rlKey, String(used + 1), { expirationTtl: 172800 });
      await env.FEEDBACK_KV.put(dupKey, similar.html_url, { expirationTtl: 86400 });
      return json({ ok: true, url: similar.html_url, number: similar.number, duplicate: true });
    }
  }

  // ---- create the issue ----
  const res = await gh(env, "issues", "POST", {
    title: `${type.prefix} ${title}`,
    body: fullBody,
    labels,
  });
  if (!res.ok) {
    const detail = await res.text();
    // Full detail to Workers Logs for debugging; truncated to the client.
    console.error("issue creation failed:", res.status, JSON.stringify(detail.slice(0, 500)));
    return json({ error: `GitHub rejected the report (${res.status}): ${detail.slice(0, 200)}` }, 502);
  }
  const issue = await res.json();
  await env.FEEDBACK_KV.put(rlKey, String(used + 1), { expirationTtl: 172800 });
  await env.FEEDBACK_KV.put(dupKey, issue.html_url, { expirationTtl: 86400 });
  return json({ ok: true, url: issue.html_url, number: issue.number });
}

// ---- duplicate detection ------------------------------------------------------

function titleWords(title) {
  return [...new Set(
    title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)
      .filter((w) => w.length >= 4)
  )];
}

/** The most similar OPEN from-app issue of the same kind, or null. Primitive
 *  on purpose: token overlap on titles — anything smarter belongs in triage,
 *  not in a relay that must stay simple. */
async function findSimilar(env, title, kindLabel) {
  const words = titleWords(title).slice(0, 6);
  if (words.length < 2) return null;
  const q = encodeURIComponent(
    `repo:${env.REPO} is:issue is:open label:from-app label:${kindLabel} in:title ${words.join(" ")}`
  );
  const res = await ghApi(env, `/search/issues?q=${q}&per_page=3`, "GET");
  if (!res.ok) return null;
  const items = (await res.json()).items || [];
  const mine = new Set(titleWords(title));
  for (const it of items) {
    const theirs = titleWords(String(it.title || ""));
    if (theirs.length === 0) continue;
    const overlap = theirs.filter((w) => mine.has(w)).length /
      Math.min(mine.size, theirs.length);
    if (overlap >= 0.6) return it;
  }
  return null;
}

// ---- helpers ------------------------------------------------------------------

function clean(v, max) {
  if (typeof v !== "string") return "";
  // strip control characters (keep newline=10 and tab=9), cap length
  const stripped = [...v]
    .filter((ch) => { const c = ch.charCodeAt(0); return c === 10 || c === 9 || c >= 32; })
    .join("");
  return stripped.trim().slice(0, max);
}

function json(obj, status = 200) {
  return new Response(JSON.stringify(obj), {
    status,
    headers: { "content-type": "application/json" },
  });
}

/** Break GitHub @mentions with a zero-width space — reads identically but
 *  no longer notifies the named user. */
function demention(text) {
  return text.replace(/@([A-Za-z0-9])/g, "@​$1");
}

/** Magic-byte check on the first decoded bytes: PNG or JPEG only. */
function looksLikeImage(b64, ext) {
  let head;
  try {
    head = atob(b64.slice(0, 16)); // 16 b64 chars -> first 12 bytes
  } catch {
    return false;
  }
  if (head.length < 4) return false;
  if (ext === "png") {
    return head.charCodeAt(0) === 0x89 && head.slice(1, 4) === "PNG";
  }
  return (
    head.charCodeAt(0) === 0xff && head.charCodeAt(1) === 0xd8 && head.charCodeAt(2) === 0xff
  );
}

async function ghApi(env, path, method, payload) {
  return fetch(`https://api.github.com${path}`, {
    method,
    headers: {
      authorization: `Bearer ${env.GITHUB_TOKEN}`,
      accept: "application/vnd.github+json",
      "user-agent": "clips-studio-feedback-relay",
      "x-github-api-version": "2022-11-28",
    },
    body: payload === undefined ? undefined : JSON.stringify(payload),
  });
}

function gh(env, path, method, payload) {
  return ghApi(env, `/repos/${env.REPO}/${path}`, method, payload);
}

async function hmac(key, msg) {
  const k = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(msg));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

async function sha256Bytes(msg) {
  const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg));
  return new Uint8Array(d);
}

async function sha256Hex(msg) {
  return [...(await sha256Bytes(msg))].map((b) => b.toString(16).padStart(2, "0")).join("");
}