--- /dev/null
+EMAIL="example@example.com"
+PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...EXAMPLE...\n-----END PRIVATE KEY-----\n"
\ No newline at end of file
--- /dev/null
+.dev.vars
\ No newline at end of file
--- /dev/null
+# google-forms-comments
+
+A simple comment system that uses Google Forms and Google Sheets as its backend.
+
+## Creating comments
+
+To create new comments, the frontend directly `POST`s to the Google Form. Since
+the `POST` is cross-origin, it is sent in `no-cors` mode and the response is
+opaque. The frontend assumes all `POST`s are successful. If the `POST` is
+blocked by Google Forms for any reason (e.g. if Google suspects the end-user is
+a bot and does a CAPTCHA challenge), then the comment will be silently dropped.
+
+## Reading comments
+
+The `/functions/comments.js` file is a
+[Cloudflare Worker](https://workers.cloudflare.com/) that acts as a proxy to
+access the Google Sheets API. The Cloudflare Worker injects a service account's
+credentials to the API request because the Google Sheets API requires
+authorization for _all_ requests, even requests to public spreadsheets.
+
+## Local development
+
+Use the `wrangler` CLI to serve the static frontend and the Cloudflare Worker
+proxy at the same time.
+
+1. Copy `.dev.vars.example` and rename the copy to `.dev.vars`. Provide an email
+ and private key for a service account with access to the Google Sheet that
+ has the form responses.
+
+2. `npx wrangler pages dev .`
--- /dev/null
+function base64UrlEncode(s) {
+ return btoa(s)
+ .replaceAll('=', '')
+ .replaceAll('+', '-')
+ .replaceAll('/', '_');
+}
+
+async function sign(message, privateKey) {
+ const cleanedKey = privateKey
+ .replace('-----BEGIN PRIVATE KEY-----', '')
+ .replace('-----END PRIVATE KEY-----', '')
+ .replaceAll(/\r\n|\n|\r/g, '');
+ const keyBuffer = Uint8Array.from(atob(cleanedKey), c => c.charCodeAt(0));
+
+ const signingKey = await crypto.subtle.importKey(
+ 'pkcs8',
+ keyBuffer,
+ {
+ name: 'RSASSA-PKCS1-V1_5',
+ hash: 'SHA-256'
+ },
+ false,
+ ['sign']
+ );
+
+ const signatureBuffer = await crypto.subtle.sign('RSASSA-PKCS1-V1_5', signingKey, new TextEncoder().encode(message));
+ return base64UrlEncode(String.fromCharCode(...new Uint8Array(signatureBuffer)));
+}
+
+async function getGoogleAuthToken(clientEmail, privateKey, scopes) {
+ const header = base64UrlEncode(JSON.stringify({
+ alg: 'RS256',
+ typ: 'JWT'
+ }));
+
+ const now = Math.round(Date.now() / 1000);
+ const expires = now + 3600;
+ const payload = base64UrlEncode(JSON.stringify({
+ iss: clientEmail,
+ aud: 'https://oauth2.googleapis.com/token',
+ exp: expires,
+ iat: now,
+ scope: scopes
+ }));
+
+ const unsignedJwt = `${header}.${payload}`;
+ const signature = await sign(unsignedJwt, privateKey);
+ const jwt = `${unsignedJwt}.${signature}`;
+
+ const response = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ body: JSON.stringify({
+ assertion: jwt,
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer'
+ })
+ });
+ const oauth = await response.json();
+
+ return oauth.access_token;
+}
+
+export async function onRequestGet({ env }) {
+ const sheetId = '1iiIolIwByFYo3ufmVWMpiGpjMlXA3e-qXI3-XFVedSQ';
+ const range = 'A2:C';
+ const requestUrl = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}`;
+ const token = await getGoogleAuthToken(env.EMAIL, env.PRIVATE_KEY, 'https://www.googleapis.com/auth/spreadsheets.readonly');
+
+ return fetch(requestUrl, { headers: { Authorization: `Bearer ${token}` } });
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="stylesheet" href="/style.css">
+ <link rel="icon" href="data:,">
+ <title>google-forms-comments</title>
+</head>
+
+<body>
+ <header>
+ <div class="header-content">
+ <h1 class="title">google-forms-comments</h1>
+ <p class="subtitle">A simple comment system that uses Google Forms and Google Sheets as its backend.</p>
+ </div>
+ </header>
+
+ <main>
+ <form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSfhGXEvOug2xrDMLQk-IS9UxKegLESQ8oHkZqakvAfpdjslSA/formResponse">
+ <h2 class="section-header">Post a comment</h2>
+
+ <div>
+ <label for="entry.1435609742">Name</label>
+ <input name="entry.1435609742" required>
+ </div>
+
+ <div>
+ <label for="entry.281337557">Comment</label>
+ <textarea name="entry.281337557" rows="10" required></textarea>
+ </div>
+
+ <div>
+ <button type="submit">
+ Post
+ </button>
+ <p class="warning">Comments are automatically deleted after 24 hours.</p>
+ </div>
+ </form>
+
+ <div class="feed">
+ <h2 class="section-header">Comment feed</h2>
+ <div class="feed-content"></div>
+ </div>
+ </main>
+
+ <!-- Spacer fills any remaining space to push the footer to bottom -->
+ <div class="spacer"></div>
+
+ <footer>
+ <div class="footer-content">
+ <a href="https://github.com/ardislu/google-forms-comments" class="github-link">
+ <svg role="img" aria-labelledby="github" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <title id="github">GitHub repository</title>
+ <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"></path>
+ <path d="M9 18c-4.51 2-5-2-7-2"></path>
+ </svg>
+ </a>
+ </div>
+ </footer>
+
+ <template>
+ <style>
+ :host {
+ background: hsl(224deg 90% 90%);
+ border-radius: 4px;
+ border: 1px solid hsl(224deg 80% 80%);
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ h3,
+ p {
+ margin: 0;
+ }
+
+ h3 {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+
+ ::slotted([slot="name"]) {
+ inline-size: 100%;
+ overflow-wrap: break-word;
+ }
+
+ ::slotted([slot="name"])::after {
+ content: "\a0 \a0 ";
+ }
+
+ ::slotted([slot="timestamp"]) {
+ font-size: 0.8rem;
+ font-weight: 400;
+ color: hsl(0deg 0% 50%);
+ }
+ </style>
+
+ <h3>
+ <slot name="name"></slot>
+ <slot name="timestamp"></slot>
+ </h3>
+ <p>
+ <slot name="comment"></slot>
+ </p>
+ </template>
+
+ <script src="/script.js"></script>
+</body>
+
+</html>
\ No newline at end of file
--- /dev/null
+customElements.define('al-comment', class extends HTMLElement {
+ constructor() {
+ super();
+ const template = document.querySelector('template').content;
+ const shadowRoot = this.attachShadow({ mode: 'open' });
+ shadowRoot.appendChild(template.cloneNode(true));
+ }
+});
+
+async function updateComments() {
+ const ONE_DAY = 24 * 60 * 60 * 1000;
+ const feed = document.querySelector('.feed-content');
+ feed.innerHTML = '';
+
+ // Google Sheets API returns a 2D array of the requested range
+ const values = await fetch('/comments')
+ .then(r => r.json())
+ .then(obj => obj['values']);
+
+ if (values === undefined) {
+ feed.insertAdjacentHTML('beforeend', '<p>No comments in the last 24 hours.</p>');
+ return;
+ }
+
+ values
+ .map(r => ({
+ timestamp: new Date(r[0]),
+ name: r[1],
+ comment: r[2]
+ }))
+ .filter(r => r.timestamp > Date.now() - ONE_DAY)
+ .reverse()
+ .forEach(r => {
+ const e = document.createElement('al-comment');
+ e.insertAdjacentHTML('beforeend', `<span slot="name">${r.name}</slot>`);
+ e.insertAdjacentHTML('beforeend', `<span slot="timestamp">${r.timestamp.toLocaleString()}</slot>`);
+ e.insertAdjacentHTML('beforeend', `<span slot="comment">${r.comment}</slot>`);
+ feed.append(e);
+ });
+
+ if (feed.innerHTML === '') {
+ feed.insertAdjacentHTML('beforeend', '<p>No comments in the last 24 hours.</p>');
+ }
+}
+
+document.querySelector('form').addEventListener('submit', async e => {
+ e.preventDefault();
+ const formUrl = e.target.action;
+ const payload = new URLSearchParams(new FormData(e.target));
+
+ // Assume the submission was successful. There's no way to see the response.
+ await fetch(formUrl, {
+ method: 'POST',
+ body: payload,
+ mode: 'no-cors'
+ });
+
+ const input = document.querySelector('input');
+ const textarea = document.querySelector('textarea');
+ input.value = '';
+ textarea.value = '';
+
+ updateComments();
+});
+
+updateComments();
\ No newline at end of file
--- /dev/null
+@font-face {
+ font-family: 'Inter';
+ src: url('/Inter.woff2');
+}
+
+:root {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
+ background: hsl(224deg 80% 95%);
+ color: hsl(54deg 90% 10%);
+ box-sizing: border-box;
+}
+
+:root * {
+ box-sizing: inherit;
+}
+
+body {
+ margin: 0;
+ min-block-size: 100dvh;
+ display: flex;
+ flex-direction: column;
+}
+
+header {
+ padding: 16px;
+ border-block-end: 1px solid hsl(0deg 0% 85%);
+ margin-block-end: 16px;
+}
+
+footer {
+ padding: 16px;
+ border-block-start: 1px solid hsl(0deg 0% 85%);
+ margin-block-start: 16px;
+}
+
+.header-content,
+.footer-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+h1.title {
+ margin: 0;
+ font-size: 1.5rem;
+ text-align: center;
+}
+
+p.subtitle {
+ margin-block: 8px;
+ color: hsl(55deg 5% 45%);
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+@media (min-width: 500px) {
+ h1.title {
+ font-size: 2rem;
+ }
+
+ p.subtitle {
+ font-size: 1rem;
+ }
+}
+
+.github-link {
+ color: inherit;
+ inline-size: 24px;
+ transition: transform 600ms;
+}
+
+.github-link:hover,
+.github-link:focus-within {
+ transform: translateY(-3px);
+ transition: transform 200ms;
+}
+
+.spacer {
+ flex: 1;
+}
+
+main {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ inline-size: min(100% - 32px, 1440px);
+ margin-inline: auto;
+}
+
+form,
+.feed {
+ flex: 1;
+ min-inline-size: min(100% - 32px, 480px);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.section-header {
+ margin: 0;
+ text-align: center;
+ align-self: center;
+}
+
+label {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: hsl(0deg 0% 40%);
+}
+
+input,
+textarea {
+ inline-size: 100%;
+ font-family: inherit;
+ font-size: 16px;
+ padding: 8px;
+ border-radius: 4px;
+ outline: none;
+ border: 1px solid hsl(0deg 0% 80%);
+}
+
+input:focus,
+textarea:focus {
+ border: 1px solid hsl(224deg 80% 50%);
+}
+
+textarea {
+ resize: vertical;
+}
+
+button {
+ inline-size: fit-content;
+ color: hsl(0deg 0% 95%);
+ font-size: 16px;
+ font-weight: 600;
+ padding-inline: 16px;
+ padding-block: 8px;
+ border-radius: 4px;
+ border: 1px solid hsl(224deg 80% 45%);
+ background: hsl(224deg 80% 40%);
+ cursor: pointer;
+ transition: filter 600ms;
+}
+
+button:hover {
+ filter: brightness(120%);
+ transition: filter 200ms;
+}
+
+.warning {
+ margin: 0;
+ color: hsl(0deg 0% 50%);
+}
+
+.feed-content {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ max-block-size: calc(100dvh - 253px);
+}
\ No newline at end of file