]> git.ardis.lu - google-forms-comments/commitdiff
Initial commit
authorArdis Lu <ardisglu@gmail.com>
Mon, 23 Jan 2023 05:01:58 +0000 (21:01 -0800)
committerArdis Lu <ardisglu@gmail.com>
Mon, 23 Jan 2023 05:01:58 +0000 (21:01 -0800)
.dev.vars.example [new file with mode: 0644]
.gitignore [new file with mode: 0644]
Inter.woff2 [new file with mode: 0644]
README.md [new file with mode: 0644]
functions/comments.js [new file with mode: 0644]
index.html [new file with mode: 0644]
script.js [new file with mode: 0644]
style.css [new file with mode: 0644]

diff --git a/.dev.vars.example b/.dev.vars.example
new file mode 100644 (file)
index 0000000..c16286a
--- /dev/null
@@ -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 (file)
index 0000000..49b3888
--- /dev/null
@@ -0,0 +1 @@
+.dev.vars
\ No newline at end of file
diff --git a/Inter.woff2 b/Inter.woff2
new file mode 100644 (file)
index 0000000..fff3df9
Binary files /dev/null and b/Inter.woff2 differ
diff --git a/README.md b/README.md
new file mode 100644 (file)
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 (file)
index 0000000..5e8eb76
--- /dev/null
@@ -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 (file)
index 0000000..da6e442
--- /dev/null
@@ -0,0 +1,115 @@
+<!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
diff --git a/script.js b/script.js
new file mode 100644 (file)
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', '<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
diff --git a/style.css b/style.css
new file mode 100644 (file)
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
This page took 0.046101 seconds and 4 git commands to generate.