Luciano Mammino (@loige)
Global Summit for Node.js'23
2023-01-25
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 airtable
export 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-4863b6239fcf
We 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%27
Which 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 Francesco Ungaro on Unsplash
TNX 🍕❤️