Created
October 17, 2023 19:13
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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