personal-website/pages/api/notion-asset/[...assetRequest].ts
Filipe Medeiros 9a9638237f
feat: initial commit
Signed-off-by: Filipe Medeiros <hello@filipesm.eu>
2023-12-03 12:25:52 +01:00

180 lines
4.9 KiB
TypeScript

// NOTE: this assumes that we are deploying on Vercel
// heavy inspiration from https://jake.tl/projects/notion-api
import { isFullBlock, isFullPage } from '@notionhq/client'
import { type BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints'
import { get } from 'https'
import { type NextApiHandler } from 'next'
import notion from '@/lib/notion/client'
import extractUrlFromFile from '@/lib/notion/extractUrlFromFile'
const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable'
const REVALIDATE_CACHE_CONTROL = 'public, s-maxage=5, stale-while-revalidate=59'
const accepttedBlockTypes = ['file', 'image', 'pdf', 'audio', 'video']
const isBlockOfValidType = (
block: BlockObjectResponse,
): block is Extract<
BlockObjectResponse,
{ type: 'file' | 'image' | 'pdf' | 'audio' | 'video' }
> => accepttedBlockTypes.includes(block.type)
const handler: NextApiHandler = async (req, res) => {
if (req.method !== 'GET') {
res.status(405).send('Method Not Allowed')
return
}
const { query } = req
const [type, blockId, propertyId] = query['assetRequest'] as string[]
// If we have this timestamp, the Vercel CDN will only let the request get to this API route
// if the timestamp hasn't been asked for before. I.e.: if you ask for the same asset, with the same
// timestamp twice, it will hit the cache and won't get to this API route
// If you request an asset without a `lastEditedTime`, then you need to refresh it every few seconds
// Thus, we can consider "timestamped assets" immutable
const lastEditedTime = query['lastEditedTime']
const isAssetImmutable = !!lastEditedTime
const validateParams = () => {
if (type !== 'page' && type !== 'block') return false // invalid type
if (!blockId) return false // invlaid block/page ID
return true
}
if (!validateParams()) {
res.status(400).send('Bad Request')
return
}
const getAssetUrlFromPageProperty = async ({
pageId,
propertyId,
}: {
pageId: string
propertyId: string
}) => {
const property = await notion.pages.properties.retrieve({
page_id: pageId,
property_id: propertyId,
})
const type = property.type
if (type !== 'files') {
res.status(400).send('Requested page property is not an asset')
return null
}
const file = property.files[0]
if (!file) {
res.status(400).send('Requested page property is empty')
return null
}
return extractUrlFromFile(file)
}
const getAssetUrlFromPageCover = async (pageId: string) => {
const page = await notion.pages.retrieve({
page_id: pageId,
})
if (!isFullPage(page)) {
res.status(404).send('Not Found')
return null
}
const cover = page.cover
if (!cover) {
res.status(404).send('Not Found')
return null
}
return extractUrlFromFile(cover)
}
const getAssetUrlFromBlock = async (blockId: string) => {
const block = await notion.blocks.retrieve({ block_id: blockId })
if (!isFullBlock(block)) {
res.status(404).send('Not Found')
return null
}
if (!isBlockOfValidType(block)) {
res.status(400).send('The requested block is not an asset')
return null
}
// @ts-expect-error we know for sure that `block.type` will index the property on `block`
return extractUrlFromFile(block[block.type])
}
let assetUrl: string | null
if (type === 'page') {
if (propertyId)
assetUrl = await getAssetUrlFromPageProperty({
pageId: blockId,
propertyId,
})
else assetUrl = await getAssetUrlFromPageCover(blockId)
} else {
assetUrl = await getAssetUrlFromBlock(blockId!) // we know for sure that here blockId exists because of `validateParams`
}
// we already sent a response to the client, close to where the error occured, so we just have to return
if (!assetUrl) return
return new Promise<void>((resolve, reject) => {
if (!assetUrl) {
res.status(404).send('Not Found')
reject()
return
}
get(assetUrl, (getResponse) => {
const proxyHeader = (header: string) => {
const value =
getResponse.headers[header] ||
getResponse.headers[header.toLowerCase()]
if (value) {
res.setHeader(header, value)
}
}
proxyHeader('Content-Type')
proxyHeader('Content-Length')
if (getResponse.statusCode === 200) {
res.setHeader(
'Cache-Control',
isAssetImmutable ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL,
)
res.status(200)
} else {
res.status(getResponse.statusCode || 500)
reject()
return
}
getResponse
.pipe(res)
.on('finish', () => {
res.end()
resolve()
})
.on('error', (err) => {
res.status(500).send(err.toString())
reject(err)
})
})
})
}
export default handler