Build a Serverless Comment System for a Jamstack Blog | Dev Extent

Create GitHub Git Repository

HTML Comment Form

<!-- form.html -->
<form id="commentForm" action="FUNCTION_ENDPOINT" method="post">
<input id="postId" type="hidden" name="postId" value="POST_ID" />
<div>
<label for="comment">comment</label>
<textarea required rows="5" id="comment" name="comment"></textarea>
</div>
<div>
<label for="authorName">name</label>
<input
required
type="text"
id="authorName"
name="authorName"
autocomplete="name"
/>
</div>
<div>
<label for="authorEmail">email</label>
<input
required
type="email"
id="authorEmail"
name="authorEmail"
autocomplete="email"
/>
</div>
<button type="submit">Submit</button>
</form>

Azure Serverless Function

// comment.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.log("HTTP trigger function processed a request.");
context.res!.headers["Content-Type"] = "application/json";
context.res!.status = 200;
context.res!.body = { message: "Success!" };
};
export default httpTrigger;

npm install

import * as querystring from "querystring";
import util = require("util");
import uuidv4 = require("uuid/v4");
import * as SendGrid from "@sendgrid/mail";
import * as simpleGit from "simple-git/promise";
import { formHelpers } from "../common/formHelpers";
import { Octokit } from "@octokit/rest";
import fs = require("fs");
import rimrafstd = require("rimraf");
import { tmpdir } from "os";
const rimraf = util.promisify(rimrafstd);
const mkdir = util.promisify(fs.mkdir);
const writeFile = util.promisify(fs.writeFile);
const readFile = util.promisify(fs.readFile);
SendGrid.setApiKey(process.env["SendGridApiKey"] as string);

Validate POST request body

const body = querystring.parse(req.body);if (
!(body && body.comment && body.postId && body.authorEmail && body.authorName)
) {
context.res!.status = 400;
context.res!.body = {
message: "Comment invalid. Please correct errors and try again.",
};
return;
}

Initialize Git Repository with Simple Git

//Initialize Git Repository with Simple Git// generate unique folder name for git repository
const tempRepo = uuidv4();
// create empty directory to store comment file
await mkdir(`${tmpdir}/${tempRepo}/comments`, {
recursive: true,
});
// initialize simple-git
const git = simpleGit(`${tmpdir}/${tempRepo}`);
// initialize git repository in tempRepo
await git.init();
// set up git config
await Promise.all([
git.addConfig("user.name", "GITHUB_USERNAME"),
git.addConfig("user.email", "GITHUB_EMAIL"),
]);
// add the private remote
await git.addRemote(
"private",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PRIVATE_REPOSITORY`
);

Checkout Git Branch with Simple Git

//Checkout git branch with Simple Git// generate unique id for comment
const commentId = uuidv4();
// create branch
try {
// fetch main branch to base of off
await git.fetch("private", "main");
// use postId to see if comments already are saved for this post
await git.checkout("private/main", ["--", `comments/${body.postId}.json`]);
// create new branch named with commentID based off main branch
await git.checkoutBranch(`${commentId}`, "private/main");
} catch (error) {
// no previous comments are saved for this post
await git.checkout("private/main");
await git.checkoutLocalBranch(`${commentId}`);
}

Write JSON File

// Write JSON File with updated Comment data// create comment object to store as JSON in git repository
const comment = {
id: commentId,
timestamp: new Date(new Date().toUTCString()).getTime(),
authorEmail: body.authorEmail,
authorName: body.authorName,
bodyText: body.comment,
};
// list of all comments
let comments = [];
// retrieve existing comments
try {
comments = JSON.parse(
await readFile(`${tmpdir}/${tempRepo}/comments/${body.postId}.json`, "utf8")
);
} catch (error) {
//no previous comments
}
// add newly submitted comment
comments.push(comment);
// update or create new comments file with new comment included
await writeFile(
`${tmpdir}/${tempRepo}/comments/${body.postId}.json`,
JSON.stringify(comments, null, 2),
"utf8"
);

Git Commit and Push to Private Repository

// stage file modifications, commit and pushawait git.add(`${tmpdir}/${tempRepo}/comments/${body.postId}.json`);await git.commit(`adding comment ${commentId}`);await git.push("private", `${commentId}`);// delete temporary repository
await rimraf(`${tmpdir}/${tempRepo}/`);

Send Notification Emails and Create Pull Request with Octokit

//send notifications and create pull requestconst userEmail = {
to: body.authorEmail,
from: "YOUR_NAME@YOUR_WEBSITE",
subject: "comment submitted",
text: "Your comment will be visible when approved.",
};
const adminEmail = {
to: "ADMIN_EMAIL",
from: "ADMIN_EMAIL",
subject: "comment submitted",
html: `<div>from: ${body.authorName}</div>
<div>email: ${body.authorEmail}</div>
<div>comment: ${body.comment}</div>`,
};
await Promise.all([
SendGrid.send(userEmail),
SendGrid.send(adminEmail),
new Octokit({
auth: process.env["GitHubUserPassword"],
}).pulls.create({
owner: "GITHUB_USERNAME",
repo: "PRIVATE_REPOSITORY",
title: `${commentId}`,
head: `${commentId}`,
base: "main",
}),
]);
// comment.tsimport { AzureFunction, Context, HttpRequest } from "@azure/functions";
import * as querystring from "querystring";
import util = require("util");
import uuidv4 = require("uuid/v4");
import * as SendGrid from "@sendgrid/mail";
import * as simpleGit from "simple-git/promise";
import { formHelpers } from "../common/formHelpers";
import { Octokit } from "@octokit/rest";
import fs = require("fs");
import rimrafstd = require("rimraf");
import { tmpdir } from "os";
const rimraf = util.promisify(rimrafstd);
const mkdir = util.promisify(fs.mkdir);
const writeFile = util.promisify(fs.writeFile);
const readFile = util.promisify(fs.readFile);
SendGrid.setApiKey(process.env["SendGridApiKey"] as string);
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.log("HTTP trigger function processed a request.");
context.res!.headers["Content-Type"] = "application/json";const body = querystring.parse(req.body);if (
!(
body &&
body.comment &&
body.postGuid &&
body.authorEmail &&
body.authorName
)
) {
context.res!.status = 400;
context.res!.body = {
message: "Comment invalid. Please correct errors and try again.",
};
return;
}
//Initialize Git Repository with Simple Git// generate unique folder name for git repository
const tempRepo = uuidv4();
// create empty directory to store comment file
await mkdir(`${tmpdir}/${tempRepo}/comments`, {
recursive: true,
});
// initialize simple-git
const git = simpleGit(`${tmpdir}/${tempRepo}`);
// initialize git repository in tempRepo
await git.init();
// set up git config
await Promise.all([
git.addConfig("user.name", "GITHUB_USERNAME"),
git.addConfig("user.email", "GITHUB_EMAIL"),
]);
// add the private remote
await git.addRemote(
"private",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PRIVATE_REPOSITORY`
);
//Checkout git branch with Simple Git// generate unique id for comment
const commentId = uuidv4();
// create branch
try {
// fetch main branch to base of off
await git.fetch("private", "main");
// use postID to see if comments already are saved for this post
await git.checkout("private/main", ["--", `comments/${body.postId}.json`]);
// create new branch named with commentID based off main branch
await git.checkoutBranch(`${commentId}`, "private/main");
} catch (error) {
// no previous comments are saved for this post
await git.checkout("private/main");
await git.checkoutLocalBranch(`${commentId}`);
}
// Write JSON File with updated Comment data// create comment object to store as JSON in git repository
const comment = {
id: commentId,
timestamp: new Date(new Date().toUTCString()).getTime(),
authorEmail: body.authorEmail,
authorName: body.authorName,
bodyText: body.comment,
};
// list of all comments
let comments = [];
// retrieve existing comments
try {
comments = JSON.parse(
await readFile(
`${tmpdir}/${tempRepo}/comments/${body.postId}.json`,
"utf8"
)
);
} catch (error) {
//no previous comments
}
// add newly submitted comment
comments.push(comment);
// update or create new comments file with new comment included
await writeFile(
`${tmpdir}/${tempRepo}/comments/${body.postId}.json`,
JSON.stringify(comments, null, 2),
"utf8"
);
// stage file modifications, commit and pushawait git.add(`${tmpdir}/${tempRepo}/comments/${body.postId}.json`);await git.commit(`adding comment ${commentId}`);await git.push("private", `${commentId}`);// delete temporary repository
await rimraf(`${tmpdir}/${tempRepo}/`);
//send notifications and create pull requestconst userEmail = {
to: body.authorEmail,
from: "YOUR_NAME@YOUR_WEBSITE",
subject: "comment submitted",
text: "Your comment will be visible when approved.",
};
const adminEmail = {
to: "ADMIN_EMAIL",
from: "ADMIN_EMAIL",
subject: "comment submitted",
html: `<div>from: ${body.authorName}</div>
<div>email: ${body.authorEmail}</div>
<div>comment: ${body.comment}</div>`,
};
await Promise.all([
SendGrid.send(userEmail),
SendGrid.send(adminEmail),
new Octokit({
auth: process.env["GitHubUserPassword"],
}).pulls.create({
owner: "GITHUB_USERNAME",
repo: "PRIVATE_REPOSITORY",
title: `${commentId}`,
head: `${commentId}`,
base: "main",
}),
]);
context.res!.status = 200;
context.res!.body = {
message: "Success!",
};
};
export default httpTrigger;

GitHub Webhook

// comment-merge.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import util = require("util");
import * as querystring from "querystring";
import * as simpleGit from "simple-git/promise";
import fs = require("fs");
import { tmpdir } from "os";
import uuidv4 = require("uuid/v4");
import globstd = require("glob");
import rimrafstd = require("rimraf");
const rimraf = util.promisify(rimrafstd);
const glob = util.promisify(globstd);
const mkdir = util.promisify(fs.mkdir);
const writeFile = util.promisify(fs.writeFile);
const readFile = util.promisify(fs.readFile);

Validate GitHub Webhook Post Request

// validate github webhook payload//request content type is configured in GitHub webhook settings
const payload = req.body;
if (
payload.action != "closed" ||
payload.pull_request.base.ref != "main" ||
!payload.pull_request.merged_at
) {
return;
}Since this webhook activates on any event regarding a pull request, whether that be opening or closing, we need to make sure that this code only runs when the pull request is closed. Secondly, the pull request branch needs to match the main branch so that pull requests from other branches are ignored. Lastly, the merged_at value is checked to make sure this pull request has been merged before closing. If the pull request is closed and not merged (the comment is spam) we can ignore the following post request sent by GitHub.

Add Public and Private GitHub Remotes

// create temp repo and add remotesconst tempRepo = uuidv4();await mkdir(`${tmpdir}/${tempRepo}/comments`, {
recursive: true,
});
const git = simpleGit(`${tmpdir}/${tempRepo}`);await git.init();await Promise.all([
git.addConfig("user.name", "GITHUB_USERNAME"),
git.addConfig("user.email", "GITHUB_EMAIL"),
]);
await Promise.all([
git.addRemote(
"private",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PRIVATE_REPOSITORY`
),
git.addRemote(
"public",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PUBLIC_REPOSITORY`
),
]);

Git Checkout and Fetch

// fetch public and integrate with latest modifications from private repoawait git.fetch("public", "main");await git.checkout("main", ["--", "comments/"]);await git.checkoutBranch("main", "main");await git.fetch("private", "main");await git.checkout("main", ["--", "comments/"]);

Filter Out Private Data

// filter private data from comments// retrieve comment file paths
const paths = await glob(`comments/**/*.json`, {
cwd: `${tmpdir}/${tempRepo}/`,
});
// wait for all paths to process asynchronously
await Promise.all(
paths.map(async (path) => {
let pathData = [];
//read JSON file with comment info
pathData = JSON.parse(
await readFile(`${tmpdir}/${tempRepo}/${path}`, "utf8")
);
// filter out private info
const publicData = pathData.map((item) => {
const { authorEmail, ...store } = item;
return store;
});
// write file back to original with private data removed
await writeFile(
`${tmpdir}/${tempRepo}/${path}`,
JSON.stringify(publicData, null, 2),
"utf8"
);
})
);

Git Commit and Push to Public Repository

// add filtered comment file modifications, commit, and pushawait git.add(`${tmpdir}/${tempRepo}/comments/*.json`);await git.commit("approving comment");await git.push("public", "main");await rimraf(`${tmpdir}/${tempRepo}/`);
// comment-merge.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import util = require("util");
import * as querystring from "querystring";
import * as simpleGit from "simple-git/promise";
import fs = require("fs");
import { tmpdir } from "os";
import uuidv4 = require("uuid/v4");
import globstd = require("glob");
import rimrafstd = require("rimraf");
const rimraf = util.promisify(rimrafstd);
const glob = util.promisify(globstd);
const mkdir = util.promisify(fs.mkdir);
const writeFile = util.promisify(fs.writeFile);
const readFile = util.promisify(fs.readFile);
const httpTrigger: AzureFunction = async function (
context: Context,
req: HttpRequest
): Promise<void> {
context.log("HTTP trigger function processed a request.");
context.res!.headers["Content-Type"] = "application/json";//request content type is configured in GitHub webhook settings
const payload = req.body;
if (
payload.action != "closed" ||
payload.pull_request.base.ref != "main" ||
!payload.pull_request.merged_at
) {
return;
}
// create temp repo and add remotesconst tempRepo = uuidv4();await mkdir(`${tmpdir}/${tempRepo}/comments`, {
recursive: true,
});
const git = simpleGit(`${tmpdir}/${tempRepo}`);await git.init();await Promise.all([
git.addConfig("user.name", "GITHUB_USERNAME"),
git.addConfig("user.email", "GITHUB_EMAIL"),
]);
await Promise.all([
git.addRemote(
"private",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PRIVATE_REPOSITORY`
),
git.addRemote(
"public",
`https://GITHUB_USERNAME:${process.env["GitHubUserPassword"]}@https://github.com/GITHUB_USERNAME/PUBLIC_REPOSITORY`
),
]);
// fetch public and integrate with latest modifications from private repoawait git.fetch("public", "main");await git.checkout("main", ["--", "comments/"]);await git.checkoutBranch("main", "main");await git.fetch("private", "main");await git.checkout("main", ["--", "comments/"]);// filter private data from comments// retrieve comment file paths
const paths = await glob(`comments/**/*.json`, {
cwd: `${tmpdir}/${tempRepo}/`,
});
// wait for all paths to process asynchronously
await Promise.all(
paths.map(async (path) => {
let pathData = [];
//read JSON file with comment info
pathData = JSON.parse(
await readFile(`${tmpdir}/${tempRepo}/${path}`, "utf8")
);
// filter out private info
const publicData = pathData.map((item) => {
const { authorEmail, ...store } = item;
return store;
});
// write file back to original with private data removed
await writeFile(
`${tmpdir}/${tempRepo}/${path}`,
JSON.stringify(publicData, null, 2),
"utf8"
);
})
);
// add filtered comment file modifications, commit, and pushawait git.add(`${tmpdir}/${tempRepo}/comments/*.json`);await git.commit("approving comment");await git.push("public", "main");await rimraf(`${tmpdir}/${tempRepo}/`);context.res!.status = 200;
context.res!.body = { message: "success" };
};
export default httpTrigger;

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store