Skip to content

Instantly share code, notes, and snippets.

@damieng
Created October 17, 2023 19:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save damieng/075f836962a35c66cf4aff7867a9262b to your computer and use it in GitHub Desktop.
Save damieng/075f836962a35c66cf4aff7867a9262b to your computer and use it in GitHub Desktop.
Cloudflare function to receive a form for blog comments and create a PR
import { stringify } from "yaml";
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
// Make sure this is a POST to /post-comment
if (
request.method !== "POST" ||
new URL(request.url).pathname !== "/post-comment"
) {
return new Response("Not found", { status: 404 });
}
// We only accept form-encoded bodies
if (
request.headers.get("content-type") !==
"application/x-www-form-urlencoded"
) {
return new Response("Bad request", { status: 400 });
}
// Get and validate the form
const form = await request.formData();
const validationError = validateForm(form);
if (validationError) {
return validationError;
}
// Validate the Turnstile recaptcha if configured to do so
if (env.TURNSTILE_SECRET_KEY) {
const passedTurnstile = await isTurnstileValid(
form.get("g-recaptcha-response") ?? ""
);
if (!passedTurnstile) {
return new Response("Failed Turnstile validation", { status: 403 });
}
}
// Details required for the branch/filename
const commentId = crypto.randomUUID();
const postId = form.get("post_id")?.replace(invalidPathChars, "-");
// Get the starting point for the github repo
const repository = await github();
const defaultBranch = await github(
`/branches/${repository.default_branch}`
);
// Create a new branch for the comment
const newBranchName = `comments-${commentId}`;
await github(`/git/refs`, "POST", {
ref: `refs/heads/${newBranchName}`,
sha: defaultBranch.commit.sha,
});
// Create a new file for the comment
const frontmatter = {
id: commentId,
date: new Date().toISOString(),
name: form.get("name") ?? undefined,
email: form.get("email") ?? undefined,
avatar: form.get("avatar") ?? undefined,
url: form.get("url") ?? undefined,
};
await github(
`/contents/content/comments/${postId}/${commentId}.md`,
"PUT",
{
message: `Comment by ${form.get("name")} on ${postId}`,
content: btoa(
"---\n" + stringify(frontmatter) + "---\n" + form.get("message")
),
branch: newBranchName,
author: {
name: form.get("name"),
email: form.get("email") ?? env.FALLBACK_EMAIL,
},
}
);
// Create a pull request for it
await github(`/pulls`, "POST", {
title: `Comment by ${form.get("name")} on ${postId}`,
body: form.get("message"),
head: newBranchName,
base: repository.default_branch,
});
// Redirect to the thanks page
return Response.redirect(env.SUCCESS_REDIRECT, 302);
async function github(
path: string = "",
method: string = "GET",
body: any | undefined = undefined
): Promise<any> {
const request = new Request(
`https://api.github.com/repos/${env.GITHUB_REPO}${path}`,
{
method: method,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${env.GITHUB_ACCESS_TOKEN}`,
"User-Agent": "Blog Comments via PR",
"X-GitHub-Api-Version": "2022-11-28",
},
body: body ? JSON.stringify(body) : undefined,
}
);
const response = await fetch(request);
if (!response.ok) {
throw new Error(
`GitHub API returned ${response.status} ${response.statusText}`
);
}
return await response.json();
}
async function isTurnstileValid(clientTurnstile: string): Promise<boolean> {
const form = new FormData();
form.set("secret", env.TURNSTILE_SECRET_KEY);
form.set("response", clientTurnstile);
form.set("remoteip", request.headers.get("CF-Connecting-IP") ?? "");
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
body: form,
method: "POST",
}
);
if (!response.ok) return false;
const json = (await response.json()) as any;
return json.success === true;
}
},
};
function validateForm(form: FormData): Response | undefined {
if (form === null) return new Response("Form not decoded", { status: 400 });
// Validate the form fields
if (isMissingOrBlank(form.get("post_id")))
return new Response("post_id must not be empty.", { status: 422 });
if (reservedIds.includes(form.get("post_id") ?? ""))
return new Response("post_id must not use reserved Windows filenames.", {
status: 422,
});
if (isMissingOrBlank(form.get("message")))
return new Response("message must not be empty.", { status: 422 });
if (isMissingOrBlank(form.get("name")))
return new Response("name must not be empty.", { status: 422 });
// Validate the email if provided
if (!isMissingOrBlank(form.get("email"))) {
if (!validEmail.test(form.get("email") ?? ""))
return new Response("email must be a valid email address if supplied.", {
status: 422,
});
}
// Validate the website URL if provided
if (!isMissingOrBlank(form.get("url"))) {
try {
new URL(form.get("url") ?? "");
} catch {
return new Response("url must be a valid URL if supplied.", {
status: 422,
});
}
}
}
function isMissingOrBlank(str: string | null): boolean {
if (str === null || str === undefined) return true;
return str.trim().length === 0;
}
export interface Env {
FALLBACK_EMAIL: string;
SUCCESS_REDIRECT: string;
GITHUB_REPO: string;
GITHUB_ACCESS_TOKEN: string;
TURNSTILE_SECRET_KEY: string;
}
const invalidPathChars = /[<>:"/\\|?*\x00-\x1F]/g;
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const reservedIds = [
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment