diff --git a/README.md b/README.md index b07a3ac..4c76409 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,81 @@ If you're here, you're probably a potential client, collaborator, or fellow buil - **`index.html`** — The main portfolio page, hand-crafted with intention - **`styles.css`** — Clean, branded styling +- **`form-handler.js`** — Contact form submission logic (Google Sheets integration) +- **`google-apps-script/Code.gs`** — Apps Script to deploy for the Google Sheets backend - **`favicon.png`** — The mark of ClearLine - **`Codex_Horizon_Regolith_Mapping.md`** — Internal knowledge architecture documentation - **`.github/`** — Workflow and automation configuration --- -## Intelligence Rollout (IR) +## Contact Form → Google Sheets Integration + +The contact form collects leads (name, email, company, and address) and sends +each submission to a Google Sheet via a Google Apps Script Web App. + +### One-time setup + +1. **Create a Google Sheet** + - Open [Google Sheets](https://sheets.google.com) and create a new spreadsheet. + - Name it something like `ClearLine Leads`. + +2. **Add the Apps Script** + - In the spreadsheet, click **Extensions → Apps Script**. + - Delete any existing code in `Code.gs`. + - Paste in the contents of [`google-apps-script/Code.gs`](google-apps-script/Code.gs). + - Click **Save** (💾). + +3. **Deploy as a Web App** + - Click **Deploy → New deployment**. + - Under *Select type*, choose **Web app**. + - Set **Execute as** → *Me*. + - Set **Who has access** → *Anyone with the link* (recommended; reduces + automated spam compared to fully public access). + - Click **Deploy** and authorise when prompted. + - **Copy the Web App URL** shown in the confirmation dialog. + +4. **Wire up the front end** + - Open [`form-handler.js`](form-handler.js). + - Replace the placeholder on the `SCRIPT_URL` line with the URL you just copied: + ```js + var SCRIPT_URL = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec'; + ``` + - Also update the `action` attribute on the `
` tag in `index.html` with the same URL (used as a no-JS fallback). + - Commit and push the change. + +5. **Test it** + - Fill in the contact form on the live site and click **Send Message**. + - Open your Google Sheet — a new row should appear in the **Leads** tab within + a few seconds. + +### What gets recorded + +| Column | Source field | +|-----------|---------------------| +| Timestamp | Submission time | +| Name | Name | +| Email | Email | +| Company | Company/Organization| +| Address | Street address | +| City | City | +| State | State | +| Zip | Zip code | +| Message | Message | + +### Anti-abuse measures built into the Apps Script + +The deployed script includes several safeguards to protect your Sheet: + +| Measure | How it works | +|---------|--------------| +| **Honeypot** | A hidden field (`hp`) is added to the form. Real users never see or fill it; bots that blindly populate all fields get silently discarded. | +| **Required-field guard** | Submissions missing `name` or `email` are rejected before any data is written. | +| **Rate limiting** | Each email address is limited to **5 submissions per hour** via `CacheService`. Requests over the cap receive an error response without touching the Sheet. | +| **Formula injection protection** | All cell values are sanitized before writing: strings starting with `=`, `+`, `-`, `@`, `\|`, or `%` are prefixed with a tab character so Sheets stores them as plain text instead of executing them as formulas. | +| **Concurrent write safety** | `LockService` serialises simultaneous requests so sheet creation and row appends are race-condition-free. | + +--- State IR reviews require at least an outline of each CCC/GMP document. Each state in scope requires the following documents: diff --git a/form-handler.js b/form-handler.js new file mode 100644 index 0000000..e7be412 --- /dev/null +++ b/form-handler.js @@ -0,0 +1,74 @@ +/** + * form-handler.js + * + * Submits the contact form to a Google Apps Script Web App which appends + * each lead to a Google Sheet. + * + * Setup: deploy google-apps-script/Code.gs as a Web App (see README for + * full instructions) and paste the resulting URL into SCRIPT_URL below. + */ +(function () { + 'use strict'; + + // ── Replace with your deployed Google Apps Script Web App URL ──────────── + var SCRIPT_URL = 'YOUR_GOOGLE_APPS_SCRIPT_URL'; + // ───────────────────────────────────────────────────────────────────────── + + var form = document.getElementById('contact-form'); + if (!form) return; + + var submitBtn = form.querySelector('button[type="submit"]'); + var statusEl = document.getElementById('form-status'); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + + // Guard: if SCRIPT_URL has not been configured yet, show a clear message + // rather than silently failing or submitting to a placeholder endpoint. + if (!SCRIPT_URL || SCRIPT_URL === 'YOUR_GOOGLE_APPS_SCRIPT_URL' || + SCRIPT_URL.slice(0, 34) !== 'https://script.google.com/macros/') { + showStatus('error', 'Form is not yet configured. Please check back soon.'); + return; + } + + // Collect all form values (includes honeypot field; see Code.gs) + var params = new URLSearchParams(); + new FormData(form).forEach(function (value, key) { + params.append(key, value); + }); + params.append('timestamp', new Date().toISOString()); + + // Disable button while in flight + submitBtn.disabled = true; + submitBtn.textContent = 'Sending\u2026'; + + // POST to Google Apps Script + // no-cors makes this a simple CORS request so no preflight is needed. + // The response is opaque — success is optimistic (the request is sent, + // but we cannot verify delivery or distinguish server-side errors from success). + fetch(SCRIPT_URL, { + method: 'POST', + mode: 'no-cors', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }) + .then(function () { + showStatus('success', "Message received \u2014 I\u2019ll be in touch soon."); + form.reset(); + }) + .catch(function () { + showStatus('error', 'Something went wrong. Please try again or reach out directly.'); + }) + .finally(function () { + submitBtn.disabled = false; + submitBtn.textContent = 'Send Message'; + }); + }); + + function showStatus(type, message) { + if (!statusEl) return; + statusEl.textContent = message; + statusEl.className = 'form-status form-status--' + type; + statusEl.removeAttribute('hidden'); + } +}()); diff --git a/google-apps-script/Code.gs b/google-apps-script/Code.gs new file mode 100644 index 0000000..8a71afb --- /dev/null +++ b/google-apps-script/Code.gs @@ -0,0 +1,149 @@ +/** + * Code.gs — Google Apps Script for ClearLine Contact Form → Google Sheets + * + * Deployment instructions (see README for full details): + * 1. Open your target Google Sheet in Google Drive. + * 2. Click Extensions → Apps Script. + * 3. Replace any existing code with this file's contents. + * 4. Click Deploy → New deployment → Web app. + * - Execute as: Me + * - Who has access: Anyone with the link (recommended) + * 5. Click Deploy and copy the Web App URL. + * 6. Paste the URL into SCRIPT_URL in form-handler.js. + * + * The script automatically creates a "Leads" sheet tab (if absent) with + * the correct column headers on the first run. + * + * Anti-abuse controls: + * - Honeypot: silently discards submissions where the hidden `hp` field + * is non-empty (bots fill every field; real users never see it). + * - Required-field guard: rejects submissions missing name or email. + * - Rate limiting: each email address is capped at MAX_PER_WINDOW + * submissions per WINDOW_SECONDS using CacheService. + * - Formula injection: values starting with =, +, -, @, |, or % are + * prefixed with a tab so Sheets stores them as plain text, not formulas. + * - Concurrent write safety: LockService serialises simultaneous POSTs + * to prevent race conditions on first-run sheet creation and appendRow. + */ + +var SHEET_NAME = 'Leads'; +var MAX_PER_WINDOW = 5; // max submissions per email per window +var WINDOW_SECONDS = 3600; // rate-limit window: 1 hour + +/** + * sanitizeCell — strips leading formula-trigger characters so values are + * always stored as plain text in the spreadsheet. + */ +function sanitizeCell(value) { + if (typeof value !== 'string') return ''; + var s = value.trim(); + return /^[=+\-@|%]/.test(s) ? '\t' + s : s; +} + +/** + * isRateLimited — returns true (and does NOT increment) when the email has + * already hit the cap; otherwise increments the counter and returns false. + * Normalises the email by lowercasing and stripping the + alias suffix so + * variants like user+spam@example.com share the same rate-limit bucket. + */ +function isRateLimited(email) { + if (!email) return false; + // Normalise: lowercase, strip + alias (e.g. user+tag@example.com → user@example.com) + var normalised = email.toLowerCase().replace(/\+[^@]*(?=@)/, ''); + var cacheKey = 'rl_' + normalised.replace(/[^a-z0-9@._-]/g, ''); + var cache = CacheService.getScriptCache(); + var count = parseInt(cache.get(cacheKey) || '0', 10); + if (count >= MAX_PER_WINDOW) return true; + cache.put(cacheKey, String(count + 1), WINDOW_SECONDS); + return false; +} + +/** + * doPost — called each time the contact form submits. + * Parameters are read from e.parameter (URL-encoded form body). + */ +function doPost(e) { + try { + var p = (e && e.parameter) ? e.parameter : {}; + + // 1. Honeypot — bots fill every visible/hidden field; real users don't + if (p.hp && p.hp.trim() !== '') { + return okResponse(); // silently discard without hinting to the bot + } + + // 2. Required-field guard + if (!p.name || !p.email) { + return errResponse('Missing required fields.'); + } + + // 3. Rate limiting per email address + if (isRateLimited(p.email)) { + return errResponse('Too many submissions. Please try again later.'); + } + + // 4. Serialise concurrent writes so sheet creation and appendRow are safe + var lock = LockService.getScriptLock(); + try { + lock.waitLock(10000); // waits up to 10 s; throws TimeoutException if busy + } catch (lockErr) { + return errResponse('Service temporarily busy. Please try again in a moment.'); + } + + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sheet = ss.getSheetByName(SHEET_NAME); + + // Create the Leads tab with frozen headers on first run + if (!sheet) { + sheet = ss.insertSheet(SHEET_NAME); + sheet.appendRow([ + 'Timestamp', + 'Name', + 'Email', + 'Company', + 'Address', + 'City', + 'State', + 'Zip', + 'Message' + ]); + sheet.setFrozenRows(1); + } + + sheet.appendRow([ + // timestamp is always provided by form-handler.js; the fallback here + // guards against future clients that may not include it + sanitizeCell(p.timestamp || new Date().toISOString()), + sanitizeCell(p.name), + sanitizeCell(p.email), + sanitizeCell(p.company || ''), + sanitizeCell(p.address || ''), + sanitizeCell(p.city || ''), + sanitizeCell(p.state || ''), + sanitizeCell(p.zip || ''), + sanitizeCell(p.message || '') + ]); + } finally { + lock.releaseLock(); + } + + return okResponse(); + + } catch (err) { + return ContentService + .createTextOutput(JSON.stringify({ result: 'error', error: err.toString() })) + .setMimeType(ContentService.MimeType.JSON); + } +} + +function okResponse() { + return ContentService + .createTextOutput(JSON.stringify({ result: 'success' })) + .setMimeType(ContentService.MimeType.JSON); +} + +function errResponse(msg) { + return ContentService + .createTextOutput(JSON.stringify({ result: 'error', error: msg })) + .setMimeType(ContentService.MimeType.JSON); +} diff --git a/index.html b/index.html index 3de0ef1..e19a6bc 100644 --- a/index.html +++ b/index.html @@ -219,7 +219,7 @@

Stack & Tools

Say Hello

Whether it's a question about compliance, a project idea, or just connecting — I'd like to hear from you.

- +
@@ -230,15 +230,45 @@

Say Hello

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