180 lines
4.9 KiB
TypeScript
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
|