Luciano Mammino PRO
Cloud developer, entrepreneur, fighter, butterfly maker! #nodejs #javascript - Author of https://www.nodejsdesignpatterns.com , Founder of https://fullstackbulletin.com
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 🍕❤️
By Luciano Mammino
Imagine you are hosting a private event and you want to create a website to invite all your guests. Of course, you’d like to have an easy way to just share a URL with every guest and they should be able to access all the details of the event. Everyone else should not be allowed to see the page. Even nicer if the website is customized for every guest and if you could use the same website to collect information from the guests (who is coming and who is not). Ok, how do we build all of this? But, most importantly, how do we build it quickly? How do we keep it simple and possibly host it 100% for FREE? I had to do something like this recently so, in this talk, I am going to share my solution, which involves a React SPA (built with Next.js & Vercel) and AirTable as a backend! In the process, we are going to learn some tricks, like how to build a custom React Hook and how to protect our app from AirTable query injection (yes, it’s a thing)!
Cloud developer, entrepreneur, fighter, butterfly maker! #nodejs #javascript - Author of https://www.nodejsdesignpatterns.com , Founder of https://fullstackbulletin.com