Files
saphid--frame-control/site/lib/feedback.js
T
saphidandClaude Opus 5.5 7d328e20a5 Website with a feedback form that opens GitHub issues, and pi's contributor gate
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages
  (frame-control.pages.dev). POST /api/feedback validates the form and opens
  a labelled issue with a fine-grained token; honeypot, minimum fill time and
  KV rate limits keep spam out. Ko-fi donate buttons appear once the page
  name is set in site/public/js/site.js.
- .github: the issue and PR gate from badlogic/pi-mono. New contributors'
  issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves
  them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md.
- CI runs the website tests; README points feedback at the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:26:33 +10:00

87 lines
3.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Feedback form → GitHub issue. Pure functions, so tests can run them without
// Cloudflare or GitHub (site/test/feedback.test.mjs).
export const KINDS = {
bug: { label: "bug", title: "Bug report" },
idea: { label: "enhancement", title: "Idea" },
question: { label: "question", title: "Question" },
other: { label: null, title: "Other feedback" },
};
export const LIMITS = { title: [5, 120], message: [10, 5000], field: 120 };
// Anyone who fills the form in under this many milliseconds is a script.
export const MIN_FILL_MS = 3000;
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const oneLine = (value, max) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max);
// Mentions in someone else's text would ping strangers, and issue refs would
// cross-link, so break both with a zero-width space.
export function defang(text) {
return text.replace(/@(?=[A-Za-z0-9])/g, "@​").replace(/(^|\s)#(?=\d)/g, "$1#​");
}
// Returns { error } or { value } with every field trimmed and bounded.
export function validate(input, now = Date.now()) {
if (!input || typeof input !== "object") return { error: "Send the form as JSON." };
if (oneLine(input.website, 200)) return { spam: true };
const started = Number(input.started);
if (!Number.isFinite(started) || now - started < MIN_FILL_MS) return { spam: true };
const kind = Object.hasOwn(KINDS, input.kind) ? input.kind : "other";
const title = oneLine(input.title, LIMITS.title[1]);
const message = String(input.message ?? "").replace(/\r\n?/g, "\n").trim();
if (title.length < LIMITS.title[0]) return { error: "Give it a short title (at least 5 characters)." };
if (message.length < LIMITS.message[0]) return { error: "Tell us a little more (at least 10 characters)." };
if (message.length > LIMITS.message[1]) return { error: `Keep it under ${LIMITS.message[1]} characters.` };
const github = oneLine(input.github, 40).replace(/^@/, "");
if (github && !GITHUB_LOGIN.test(github)) return { error: "That doesn't look like a GitHub username." };
return {
value: {
kind,
title,
message,
github,
version: oneLine(input.version, LIMITS.field),
os: oneLine(input.os, LIMITS.field),
steamos: oneLine(input.steamos, LIMITS.field),
},
};
}
export function buildIssue(value) {
const kind = KINDS[value.kind];
const details = [
["Frame Control version", value.version],
["Computer", value.os],
["SteamOS build", value.steamos],
].filter(([, v]) => v);
const lines = [defang(value.message), ""];
if (details.length) {
lines.push("| | |", "|---|---|", ...details.map(([k, v]) => `| ${k} | ${defang(v).replace(/\|/g, "\\|")} |`), "");
}
lines.push(
"---",
value.github
? `Sent from the website feedback form by @${value.github}.`
: "Sent from the website feedback form. The sender left no GitHub username, so they won't see replies here.",
);
return {
title: `${kind.title}: ${value.title}`,
body: lines.join("\n"),
labels: ["feedback", ...(kind.label ? [kind.label] : [])],
};
}
export async function hashIp(ip, salt) {
const bytes = new TextEncoder().encode(`${salt}:${ip}`);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join("");
}