Luciano Mammino (@loige)
2022-12-07
META_SLIDE!
👋 I'm Luciano (🇮🇹🍕🍝🤌)
👨💻 Senior Architect @ fourTheorem (Dublin 🇮🇪)
📔 Co-Author of Node.js Design Patterns 👉
Accelerated Serverless | AI as a Service | Platform Modernisation
✉️ Reach out to us at hello@fourTheorem.com
😇 We are always looking for talent: fth.link/careers
✅
❌
❌
Access denied
Hello, Micky
you are invited...
1️⃣ Load React SPA
2️⃣ Code validation
3️⃣ View invite (or error)
Base (project)
Table
Records
Fields
npx create-next-app@12.2 --typescript --use-npm(used Next.js 12.2)
export interface Invite {
  code: string,
  name: string,
  favouriteColor: string,
  weapon: string,
  coming?: boolean,
}npm i --save airtableexport AIRTABLE_API_KEY="put your api key here"
export AIRTABLE_BASE_ID="put your base id here"// utils/airtable.ts
import Airtable from 'airtable'
import { Invite } from '../types/invite'
if (!process.env.AIRTABLE_API_KEY) {
  throw new Error('AIRTABLE_API_KEY is not set')
}
if (!process.env.AIRTABLE_BASE_ID) {
  throw new Error('AIRTABLE_BASE_ID is not set')
}
const airtable = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY })
const base = airtable.base(process.env.AIRTABLE_BASE_ID)export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = ${escape(inviteCode)}`, // <- we'll talk more about escape
        maxRecords: 1
      })
      .firstPage((err, records) => {
        if (err) {
          console.error(err)
          return reject(err)
        }
        if (!records || records.length === 0) {
          return reject(new Error('Invite not found'))
        }
        resolve({
          code: String(records[0].fields.invite),
          name: String(records[0].fields.name),
          favouriteColor: String(records[0].fields.favouriteColor),
          weapon: String(records[0].fields.weapon),
          coming: typeof records[0].fields.coming === 'undefined'
            ? undefined
            : records[0].fields.coming === 'yes'
        })
      })
  })
}Files inside pages/api are API endpoints
// pages/api/hello.ts -> <host>/api/hello
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<{ message: string }>
) {
  return res.status(200).json({ message: 'Hello World' })
}// pages/api/invite.ts
import { InviteResponse } from '../../types/invite'
import { getInvite } from '../../utils/airtable'
export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<InviteResponse | { error: string }>
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method Not Allowed' })
  }
  if (!req.query.code) {
    return res.status(400).json({ error: 'Missing invite code' })
  }
  
  const code = Array.isArray(req.query.code) ? req.query.code[0] : req.query.code
  
  try {
    const invite = await getInvite(code)
    res.status(200).json({ invite })
  } catch (err) {
    if ((err as Error).message === 'Invite not found') {
      return res.status(401).json({ error: 'Invite not found' })
    }
    res.status(500).json({ error: 'Internal server error' })
  }
}{
  "invite":{
    "code":"14b25700-fe5b-45e8-a9be-4863b6239fcf",
    "name":"Leonardo",
    "favouriteColor":"blue",
    "weapon":"Twin Katana"
  }
}curl -XGET "http://localhost:3000/api/invite?code=14b25700-fe5b-45e8-a9be-4863b6239fcf"use” and that may call other HooksOnly call Hooks at the top level
Don’t call Hooks inside loops, conditions, or nested functions
// components/hooks/useInvite.tsx
import { useState, useEffect } from 'react'
import { InviteResponse } from '../../types/invite'
async function fetchInvite (code: string): Promise<InviteResponse> {
  // makes a fetch request to the invite api (elided for brevity)
}
export default function useInvite (): [InviteResponse | null, string | null] {
  const [inviteResponse, setInviteResponse] = useState<InviteResponse | null>(null)
  const [error, setError] = useState<string | null>(null)
  useEffect(() => {
    const url = new URL(window.location.toString())
    const code = url.searchParams.get('code')
    if (!code) {
      setError('No code provided')
    } else {
      fetchInvite(code)
        .then(setInviteResponse)
        .catch(err => {
          setError(err.message)
        })
    }
  }, [])
  return [inviteResponse, error]
}import React from 'react'
import useInvite from './hooks/useInvite'
export default function SomeExampleComponent () {
  const [inviteResponse, error] = useInvite()
  // there was an error
  if (error) {
    return <div>... some error happened</div>
  }
  // still loading the data from the backend
  if (!inviteResponse) {
    return <div>Loading ...</div>
  }
  // has the data!
  return <div>
    actual component markup when inviteResponse is available
  </div>
}New field
("yes", "no", or undefined)
// utils/airtable.ts
import Airtable, { FieldSet, Record } from 'airtable'
// ...
export function getInviteRecord (inviteCode: string): Promise<Record<FieldSet>> {
  // gets the raw record for a given invite, elided for brevity
}
export async function updateRsvp (inviteCode: string, rsvp: boolean): Promise<void> {
  const { id } = await getInviteRecord(inviteCode)
  return new Promise((resolve, reject) => {
    base('invites').update(id, { coming: rsvp ? 'yes' : 'no' }, (err) => {
      if (err) {
        return reject(err)
      }
      resolve()
    })
  })
}// pages/api/rsvp.ts
type RequestBody = {coming?: boolean}
export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<{updated: boolean} | { error: string }>
) {
  if (req.method !== 'PUT') {
    return res.status(405).json({ error: 'Method Not Allowed' })
  }
  if (!req.query.code) {
    return res.status(400).json({ error: 'Missing invite code' })
  }
  const reqBody = req.body as RequestBody
  if (typeof reqBody.coming === 'undefined') {
    return res.status(400).json({ error: 'Missing `coming` field in body' })
  }
  const code = Array.isArray(req.query.code) ? req.query.code[0] : req.query.code
  try {
    await updateRsvp(code, reqBody.coming)
    return res.status(200).json({ updated: true })
  } catch (err) {
    if ((err as Error).message === 'Invite not found') {
      return res.status(401).json({ error: 'Invite not found' })
    }
    res.status(500).json({ error: 'Internal server error' })
  }
}// components/hooks/useInvite.tsx
// ...
interface HookResult {
  inviteResponse: InviteResponse | null,
  error: string | null,
  updating: boolean,
  updateRsvp: (coming: boolean) => Promise<void>
}
async function updateRsvpRequest (code: string, coming: boolean): Promise<void> {
  // Helper function that uses fetch to invoke the rsvp API endpoint (elided)
}// ...
export default function useInvite (): HookResult {
  const [inviteResponse, setInviteResponse] = useState<InviteResponse | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [updating, setUpdating] = useState<boolean>(false)
  useEffect(() => {
    // load the invite using the code from URL, same as before
  }, [])
  async function updateRsvp (coming: boolean) {
    if (inviteResponse) {
      setUpdating(true)
      await updateRsvpRequest(inviteResponse.invite.code, coming)
      setInviteResponse({
        ...inviteResponse,
        invite: { ...inviteResponse.invite, coming }
      })
      setUpdating(false)
    }
  }
  return { inviteResponse, error, updating, updateRsvp }
}import useInvite from './hooks/useInvite'
export default function Home () {
  const { inviteResponse, error, updating, updateRsvp } = useInvite()
  if (error) { return <div>Duh! {error}</div> }
  if (!inviteResponse) { return <div>Loading...</div> }
  function onRsvpChange (e: ChangeEvent<HTMLInputElement>) {
    const coming = e.target.value === 'yes'
    updateRsvp(coming)
  }
  return (<fieldset disabled={updating}><legend>Are you coming?</legend>
    <label htmlFor="yes">
      <input type="radio" id="yes" name="coming" value="yes"
        onChange={onRsvpChange}
        checked={inviteResponse.invite.coming === true}
      /> YES
    </label>
    <label htmlFor="no">
      <input type="radio" id="no" name="coming" value="no"
        onChange={onRsvpChange}
        checked={inviteResponse.invite.coming === false}
      /> NO
    </label>
  </fieldset>)
}What if I don't have an invite code and I want to hack into the website anyway?
export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = ${escape(inviteCode)}`,
        maxRecords: 1
      })
      .firstPage((err, records) => {
        // ...
      })
  })
}export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = '${inviteCode}'`,
        maxRecords: 1
      })
      .firstPage((err, records) => {
        // ...
      })
  })
}inviteCode is user controlled!
The user can change this value arbitrarily! 😈
If the user inputs the following query string:
?code=14b25700-fe5b-45e8-a9be-4863b6239fcfWe get the following filter formula
{invite} = '14b25700-fe5b-45e8-a9be-4863b6239fcf'👌
But, if the user inputs this other query string: 😈
?code=%27%20>%3D%200%20%26%20%27Which is basically the following unencoded query string:
{invite} = '' >= 0 & ''😰
?code=' >= 0 & 'Now we get:
which is TRUE for EVERY RECORD!
function escape (value: string): string {
  if (value === null || 
      typeof value === 'undefined') {
    return 'BLANK()'
  }
  if (typeof value === 'string') {
    const escapedString = value
      .replace(/'/g, "\\'")
      .replace(/\r/g, '')
      .replace(/\\/g, '\\\\')
      .replace(/\n/g, '\\n')
      .replace(/\t/g, '\\t')
    return `'${escapedString}'`
  }
  if (typeof value === 'number') {
    return String(value)
  }
  if (typeof value === 'boolean') {
    return value ? '1' : '0'
  }
  throw Error('Invalid value received')
}Airtable API rate limiting: 5 req/sec 😰
(We actually do 2 calls when we update a record!)
With the full codebase on GitHub!
Cover photo by Jakob Owens on Unsplash
GRAZIE! 🍕❤️