From d80634e0d037d3d60d78650e9f06ebe1c140bb84 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 23 Oct 2024 22:03:10 +0000 Subject: [PATCH] fix: cors --- apps/openpage-api/app/github/forks/route.ts | 29 ++-- apps/openpage-api/app/github/issues/route.ts | 29 ++-- apps/openpage-api/app/github/prs/route.ts | 29 ++-- apps/openpage-api/app/github/route.ts | 23 +++- apps/openpage-api/app/github/stars/route.ts | 29 ++-- apps/openpage-api/app/request-handler.ts | 76 ---------- apps/openpage-api/app/route.ts | 30 ++-- apps/openpage-api/lib/cors.ts | 138 +++++++++++++++++++ 8 files changed, 264 insertions(+), 119 deletions(-) delete mode 100644 apps/openpage-api/app/request-handler.ts create mode 100644 apps/openpage-api/lib/cors.ts diff --git a/apps/openpage-api/app/github/forks/route.ts b/apps/openpage-api/app/github/forks/route.ts index f13d0503c..f512dd57d 100644 --- a/apps/openpage-api/app/github/forks/route.ts +++ b/apps/openpage-api/app/github/forks/route.ts @@ -1,12 +1,25 @@ -import { NextResponse } from 'next/server'; +import cors from '@/lib/cors'; -import { requestHandler } from '@/app/request-handler'; - -export const GET = requestHandler(async () => { +export async function GET(request: Request) { const res = await fetch('https://api.github.com/repos/documenso/documenso'); const { forks_count } = await res.json(); - return NextResponse.json({ - data: forks_count, - }); -}); + return cors( + request, + new Response(JSON.stringify({ data: forks_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/issues/route.ts b/apps/openpage-api/app/github/issues/route.ts index a55878421..eccc1bd32 100644 --- a/apps/openpage-api/app/github/issues/route.ts +++ b/apps/openpage-api/app/github/issues/route.ts @@ -1,14 +1,27 @@ -import { NextResponse } from 'next/server'; +import cors from '@/lib/cors'; -import { requestHandler } from '@/app/request-handler'; - -export const GET = requestHandler(async () => { +export async function GET(request: Request) { const res = await fetch( 'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1', ); const { total_count } = await res.json(); - return NextResponse.json({ - data: total_count, - }); -}); + return cors( + request, + new Response(JSON.stringify({ data: total_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/prs/route.ts b/apps/openpage-api/app/github/prs/route.ts index fe0b8505c..7516e6986 100644 --- a/apps/openpage-api/app/github/prs/route.ts +++ b/apps/openpage-api/app/github/prs/route.ts @@ -1,14 +1,27 @@ -import { NextResponse } from 'next/server'; +import cors from '@/lib/cors'; -import { requestHandler } from '@/app/request-handler'; - -export const GET = requestHandler(async () => { +export async function GET(request: Request) { const res = await fetch( 'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1', ); const { total_count } = await res.json(); - return NextResponse.json({ - data: total_count, - }); -}); + return cors( + request, + new Response(JSON.stringify({ data: total_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/github/route.ts b/apps/openpage-api/app/github/route.ts index ddce7429a..fecab9ec9 100644 --- a/apps/openpage-api/app/github/route.ts +++ b/apps/openpage-api/app/github/route.ts @@ -1,4 +1,6 @@ -import { type NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; const paths = [ { path: '/forks', description: 'GitHub Forks' }, @@ -13,5 +15,22 @@ export function GET(request: NextRequest) { return { path: url + path, description }; }); - return NextResponse.json(apis); + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); } diff --git a/apps/openpage-api/app/github/stars/route.ts b/apps/openpage-api/app/github/stars/route.ts index c89cb5c6c..79c03f0cd 100644 --- a/apps/openpage-api/app/github/stars/route.ts +++ b/apps/openpage-api/app/github/stars/route.ts @@ -1,12 +1,25 @@ -import { NextResponse } from 'next/server'; +import cors from '@/lib/cors'; -import { requestHandler } from '@/app/request-handler'; - -export const GET = requestHandler(async () => { +export async function GET(request: Request) { const res = await fetch('https://api.github.com/repos/documenso/documenso'); const { stargazers_count } = await res.json(); - return NextResponse.json({ - data: stargazers_count, - }); -}); + return cors( + request, + new Response(JSON.stringify({ data: stargazers_count }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); +} diff --git a/apps/openpage-api/app/request-handler.ts b/apps/openpage-api/app/request-handler.ts deleted file mode 100644 index 721b5f0d7..000000000 --- a/apps/openpage-api/app/request-handler.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; - -type RouteHandler> = ( - req: NextRequest, - ctx: { params: T }, -) => Promise | Response; - -const ALLOWED_ORIGINS = new Set(['documenso.com', 'prd-openpage-api.vercel.app']); - -const CORS_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', -}; - -function isAllowedOrigin(req: NextRequest): boolean { - const referer = req.headers.get('referer'); - const host = req.headers.get('host'); - - if (host?.includes('localhost')) { - return true; - } - - if (!referer || !host) { - return false; - } - - try { - const refererUrl = new URL(referer); - const hostUrl = new URL(`http://${host}`); - - const isRefererAllowed = ALLOWED_ORIGINS.has(refererUrl.host); - const isHostAllowed = ALLOWED_ORIGINS.has(hostUrl.host); - - return isRefererAllowed || isHostAllowed; - } catch (error) { - console.error('Error parsing URLs:', error); - return false; - } -} - -export function requestHandler>( - handler: RouteHandler, -): RouteHandler { - return async (req: NextRequest, ctx: { params: T }) => { - try { - // if (!isAllowedOrigin(req)) { - // return NextResponse.json( - // { error: 'Forbidden' }, - // { - // status: 403, - // headers: CORS_HEADERS, - // }, - // ); - // } - - const response = await handler(req, ctx); - - Object.entries(CORS_HEADERS).forEach(([key, value]) => { - response.headers.set(key, value); - }); - - return response; - } catch (error) { - console.log(error); - return NextResponse.json( - { error: 'Internal Server Error' }, - { - status: 500, - headers: CORS_HEADERS, - }, - ); - } - }; -} diff --git a/apps/openpage-api/app/route.ts b/apps/openpage-api/app/route.ts index 9c87ff111..dd487f707 100644 --- a/apps/openpage-api/app/route.ts +++ b/apps/openpage-api/app/route.ts @@ -1,4 +1,6 @@ -import { type NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +import cors from '@/lib/cors'; const paths = [{ path: 'github', description: 'GitHub Data' }]; @@ -8,12 +10,22 @@ export function GET(request: NextRequest) { return { path: url + path, description }; }); - return NextResponse.json(apis, { - headers: { - // TODO: Update for marketing page - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }); + return cors( + request, + new Response(JSON.stringify(apis), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }), + ); +} + +export function OPTIONS(request: Request) { + return cors( + request, + new Response(null, { + status: 204, + }), + ); } diff --git a/apps/openpage-api/lib/cors.ts b/apps/openpage-api/lib/cors.ts new file mode 100644 index 000000000..ca41e4336 --- /dev/null +++ b/apps/openpage-api/lib/cors.ts @@ -0,0 +1,138 @@ +/** + * Multi purpose CORS lib. + * Note: Based on the `cors` package in npm but using only web APIs. + * Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts + */ + +type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[]; + +type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise; + +interface CorsOptions { + origin?: StaticOrigin | OriginFn; + methods?: string | string[]; + allowedHeaders?: string | string[]; + exposedHeaders?: string | string[]; + credentials?: boolean; + maxAge?: number; + preflightContinue?: boolean; + optionsSuccessStatus?: number; +} + +const defaultOptions: CorsOptions = { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204, +}; + +function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean { + return Array.isArray(allowed) + ? allowed.some((o) => isOriginAllowed(origin, o)) + : typeof allowed === 'string' + ? origin === allowed + : allowed instanceof RegExp + ? allowed.test(origin) + : !!allowed; +} + +function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) { + const headers = new Headers(); + + if (origin === '*') { + // Allow any origin + headers.set('Access-Control-Allow-Origin', '*'); + } else if (typeof origin === 'string') { + // Fixed origin + headers.set('Access-Control-Allow-Origin', origin); + headers.append('Vary', 'Origin'); + } else { + const allowed = isOriginAllowed(reqOrigin ?? '', origin); + + if (allowed && reqOrigin) { + headers.set('Access-Control-Allow-Origin', reqOrigin); + } + headers.append('Vary', 'Origin'); + } + + return headers; +} + +async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) { + const reqOrigin = req.headers.get('Origin') || undefined; + const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin; + + if (!value) return; + return getOriginHeaders(reqOrigin, value); +} + +function getAllowedHeaders(req: Request, allowed?: string | string[]) { + const headers = new Headers(); + + if (!allowed) { + allowed = req.headers.get('Access-Control-Request-Headers')!; + headers.append('Vary', 'Access-Control-Request-Headers'); + } else if (Array.isArray(allowed)) { + // If the allowed headers is an array, turn it into a string + allowed = allowed.join(','); + } + if (allowed) { + headers.set('Access-Control-Allow-Headers', allowed); + } + + return headers; +} + +export default async function cors(req: Request, res: Response, options?: CorsOptions) { + const opts = { ...defaultOptions, ...options }; + const { headers } = res; + const originHeaders = await originHeadersFromReq(req, opts.origin ?? false); + const mergeHeaders = (v: string, k: string) => { + if (k === 'Vary') headers.append(k, v); + else headers.set(k, v); + }; + + // If there's no origin we won't touch the response + if (!originHeaders) return res; + + originHeaders.forEach(mergeHeaders); + + if (opts.credentials) { + headers.set('Access-Control-Allow-Credentials', 'true'); + } + + const exposed = Array.isArray(opts.exposedHeaders) + ? opts.exposedHeaders.join(',') + : opts.exposedHeaders; + + if (exposed) { + headers.set('Access-Control-Expose-Headers', exposed); + } + + // Handle the preflight request + if (req.method === 'OPTIONS') { + if (opts.methods) { + const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods; + + headers.set('Access-Control-Allow-Methods', methods); + } + + getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders); + + if (typeof opts.maxAge === 'number') { + headers.set('Access-Control-Max-Age', String(opts.maxAge)); + } + + if (opts.preflightContinue) return res; + + headers.set('Content-Length', '0'); + return new Response(null, { status: opts.optionsSuccessStatus, headers }); + } + + // If we got here, it's a normal request + return res; +} + +export function initCors(options?: CorsOptions) { + return async (req: Request, res: Response) => cors(req, res, options); +}