From: Ardis Lu Date: Mon, 23 Jan 2023 05:01:58 +0000 (-0800) Subject: Initial commit X-Git-Url: https://git.ardis.lu/?a=commitdiff_plain;h=e81a71a4e2cd3ae2c600158f08f31576a5c9ae00;p=google-forms-comments Initial commit --- e81a71a4e2cd3ae2c600158f08f31576a5c9ae00 diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..c16286a --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,2 @@ +EMAIL="example@example.com" +PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...EXAMPLE...\n-----END PRIVATE KEY-----\n" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49b3888 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.dev.vars \ No newline at end of file diff --git a/Inter.woff2 b/Inter.woff2 new file mode 100644 index 0000000..fff3df9 Binary files /dev/null and b/Inter.woff2 differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac28df6 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# 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 .` diff --git a/functions/comments.js b/functions/comments.js new file mode 100644 index 0000000..5e8eb76 --- /dev/null +++ b/functions/comments.js @@ -0,0 +1,69 @@ +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}` } }); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..da6e442 --- /dev/null +++ b/index.html @@ -0,0 +1,115 @@ + + + + + + + + + google-forms-comments + + + +
+
+

google-forms-comments

+

A simple comment system that uses Google Forms and Google Sheets as its backend.

+
+
+ +
+
+

Post a comment

+ +
+ + +
+ +
+ + +
+ +
+ +

Comments are automatically deleted after 24 hours.

+
+
+ +
+

Comment feed

+
+
+
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..270257f --- /dev/null +++ b/script.js @@ -0,0 +1,66 @@ +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', '

No comments in the last 24 hours.

'); + 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', `${r.name}`); + e.insertAdjacentHTML('beforeend', `${r.timestamp.toLocaleString()}`); + e.insertAdjacentHTML('beforeend', `${r.comment}`); + feed.append(e); + }); + + if (feed.innerHTML === '') { + feed.insertAdjacentHTML('beforeend', '

No comments in the last 24 hours.

'); + } +} + +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 diff --git a/style.css b/style.css new file mode 100644 index 0000000..7490b09 --- /dev/null +++ b/style.css @@ -0,0 +1,162 @@ +@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