Skip to content

Instantly share code, notes, and snippets.

@damieng
Created October 24, 2023 21:42
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/d5f7212b25e77146e8205412ffd34818 to your computer and use it in GitHub Desktop.
Save damieng/d5f7212b25e77146e8205412ffd34818 to your computer and use it in GitHub Desktop.
Cloudflare function for creating a PR from a form post
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.test(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 {
return str === null || str === undefined || 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|COM[1-9]|LPT[1-9]/i
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment