// 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/utils/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((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