mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
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>
This commit is contained in:
1 parent
9110557e23
commit
7d328e20a5
40 files changed
+2066
-3
No files matched your search
@@ -0,0 +1,82 @@
|
||||
// POST /api/feedback: turns the website's feedback form into a GitHub issue.
|
||||
//
|
||||
// Environment (Cloudflare Pages → Settings → Variables and Secrets):
|
||||
// GITHUB_TOKEN secret. Fine-grained token with Issues: read and write on GITHUB_REPO only.
|
||||
// GITHUB_REPO owner/name, e.g. saphid/frame-control (wrangler.toml sets it).
|
||||
// FEEDBACK_RL KV namespace binding for rate limits (optional; without it there is no limit).
|
||||
|
||||
import { buildIssue, hashIp, validate } from "../../lib/feedback.js";
|
||||
|
||||
const PER_IP_PER_HOUR = 5;
|
||||
const TOTAL_PER_DAY = 100;
|
||||
|
||||
const json = (status, data) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
|
||||
async function overLimit(kv, key, limit, ttl) {
|
||||
const count = Number(await kv.get(key)) || 0;
|
||||
if (count >= limit) return true;
|
||||
await kv.put(key, String(count + 1), { expirationTtl: ttl });
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function onRequestPost({ request, env }) {
|
||||
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
||||
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." });
|
||||
}
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && new URL(origin).host !== new URL(request.url).host) {
|
||||
return json(403, { error: "Send feedback from the website's form." });
|
||||
}
|
||||
|
||||
let input;
|
||||
try {
|
||||
input = await request.json();
|
||||
} catch {
|
||||
return json(400, { error: "Send the form as JSON." });
|
||||
}
|
||||
|
||||
const checked = validate(input);
|
||||
// Bots get a success-shaped answer so they don't learn what tripped them.
|
||||
if (checked.spam) return json(200, { ok: true });
|
||||
if (checked.error) return json(400, { error: checked.error });
|
||||
|
||||
if (env.FEEDBACK_RL) {
|
||||
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
||||
const hour = Math.floor(Date.now() / 3600e3);
|
||||
const day = Math.floor(Date.now() / 86400e3);
|
||||
// Salted with the secret token, so the stored hashes can't be reversed by trying every IP.
|
||||
const who = await hashIp(ip, env.GITHUB_TOKEN);
|
||||
if (await overLimit(env.FEEDBACK_RL, `ip:${who}:${hour}`, PER_IP_PER_HOUR, 3900)) {
|
||||
return json(429, { error: "That's a lot of feedback in one hour. Try again later, or use GitHub." });
|
||||
}
|
||||
if (await overLimit(env.FEEDBACK_RL, `day:${day}`, TOTAL_PER_DAY, 90000)) {
|
||||
return json(429, { error: "The form has had a busy day. Try again tomorrow, or use GitHub." });
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "frame-control-website",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildIssue(checked.value)),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`);
|
||||
return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." });
|
||||
}
|
||||
const issue = await res.json();
|
||||
return json(201, { ok: true, number: issue.number, url: issue.html_url });
|
||||
}
|
||||
|
||||
export const onRequest = () => json(405, { error: "POST only." });
|
||||
Reference in new issue
Block a user