Skip to content

Commit

Permalink
Merge pull request #49 from contentful/feature/pages-router
Browse files Browse the repository at this point in the history
Feature/pages router
  • Loading branch information
jsdalton authored May 6, 2024
2 parents 98a4bd3 + 87b89fe commit c068d0a
Show file tree
Hide file tree
Showing 20 changed files with 491 additions and 179 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ create a `route.ts` or `route.js` [route handler](https://nextjs.org/docs/app/
```ts
// app/api/enable-draft/route.ts|js

export { enableDraftHandler as GET } from "@contentful/nextjs-toolkit/app-router"
export { enableDraftHandler as GET } from "@contentful/vercel-nextjs-toolkit/app-router"
```


Expand All @@ -68,7 +68,7 @@ If your NextJs project is using [Pages Router](https://nextjs.org/docs/pages), c
```ts
// pages/api/enable-draft.ts|js

export { enableDraftHandler as handler } from "@contentful/nextjs-toolkit/pages-router";
export { enableDraftHandler as default } from "@contentful/vercel-nextjs-toolkit/pages-router";
```


Expand Down
148 changes: 0 additions & 148 deletions lib/app-router/handlers/app.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { enableDraftHandler as GET } from './app';
import { enableDraftHandler as GET } from './enable-draft';

vi.mock('next/navigation', () => {
return {
Expand Down
73 changes: 73 additions & 0 deletions lib/app-router/handlers/enable-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { buildRedirectUrl, parseRequestUrl } from '../../utils/url';
import { getVercelJwtCookie, parseVercelJwtCookie } from '../../utils/vercelJwt';
import { type VercelJwt } from '../../types';

export async function enableDraftHandler(
request: NextRequest,
): Promise<Response | void> {
const {
origin: base,
path,
host,
bypassToken: bypassTokenFromQuery,
} = parseRequestUrl(request.url);

// if we're in development, we don't need to check for a bypass token, and we can just enable draft mode
if (process.env.NODE_ENV === 'development') {
draftMode().enable();
const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery });
return redirect(redirectUrl);
}

let bypassToken: string;
let aud: string;

if (bypassTokenFromQuery) {
bypassToken = bypassTokenFromQuery;
aud = host;
} else {
// if x-vercel-protection-bypass not provided in query, we defer to parsing the _vercel_jwt cookie
// which bundlees the bypass token value in its payload
let vercelJwt: VercelJwt
try {
const vercelJwtCookie = getVercelJwtCookie(request)
vercelJwt = parseVercelJwtCookie(vercelJwtCookie);
} catch (e) {
if (!(e instanceof Error)) throw e;
return new Response(
'Missing or malformed bypass authorization token in _vercel_jwt cookie',
{ status: 401 },
);
}
bypassToken = vercelJwt.bypass;
aud = vercelJwt.aud;
}

if (bypassToken !== process.env.VERCEL_AUTOMATION_BYPASS_SECRET) {
return new Response(
'The bypass token you are authorized with does not match the bypass secret for this deployment. You might need to redeploy or go back and try the link again.',
{ status: 403 },
);
}

if (aud !== host) {
return new Response(
`The bypass token you are authorized with is not valid for this host (${host}). You might need to redeploy or go back and try the link again.`,
{ status: 403 },
);
}

if (!path) {
return new Response('Missing required value for query parameter `path`', {
status: 400,
});
}

draftMode().enable();

const redirectUrl = buildRedirectUrl({ path, base, bypassTokenFromQuery });
redirect(redirectUrl);
}
2 changes: 1 addition & 1 deletion lib/app-router/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './app';
export * from './enable-draft';
1 change: 0 additions & 1 deletion lib/index.ts

This file was deleted.

133 changes: 133 additions & 0 deletions lib/pages-router/handlers/enable-draft.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { MockInstance, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextApiRequest, NextApiResponse } from 'next';
import { enableDraftHandler as handler } from './enable-draft';
import { makeNextApiRequest } from '../../../test/helpers';

vi.mock('next/navigation', () => {
return {
redirect: vi.fn(),
};
});

const makeNextApiResponse = (): NextApiResponse => (nextApiResponseMock as NextApiResponse)

const nextApiResponseMock: Partial<NextApiResponse> = {
status(_code: number) { return this as NextApiResponse },
send(_bodyString: string) { },
redirect(_statusCode: number | string, _url?: string) { return this as NextApiResponse },
setDraftMode() { return this as NextApiResponse },
}

interface ApiResponseSpy {
status: MockInstance
send: MockInstance
redirect: MockInstance
setDraftMode: MockInstance
}


describe('handler', () => {
const bypassToken = 'kByQez2ke5Jl4ulCY6kxQrpFMp1UIohs';
const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`;
const response = makeNextApiResponse()

// based on a real vercel token
const vercelJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJieXBhc3MiOiJrQnlRZXoya2U1Smw0dWxDWTZreFFycEZNcDFVSW9ocyIsImF1ZCI6InZlcmNlbC1hcHAtcm91dGVyLWludGVncmF0aW9ucy1sbDl1eHdiNGYudmVyY2VsLmFwcCIsImlhdCI6MTcxMzgwOTE2Nywic3ViIjoicHJvdGVjdGlvbi1ieXBhc3MtYXV0b21hdGlvbiJ9.ktyaHnYQXj-3dDnEn0ZVYkwpnQt1gc2sZ6qrgg3GIOs';
let request: NextApiRequest = makeNextApiRequest(url);
let apiResponseSpy: ApiResponseSpy
request.cookies['_vercel_jwt'] = vercelJwt;

beforeEach(() => {
apiResponseSpy = {
redirect: vi.spyOn(nextApiResponseMock, 'redirect'),
status: vi.spyOn(nextApiResponseMock, 'status'),
send: vi.spyOn(nextApiResponseMock, 'send'),
setDraftMode: vi.spyOn(nextApiResponseMock, 'setDraftMode')
}
vi.stubEnv('VERCEL_AUTOMATION_BYPASS_SECRET', bypassToken);
});

it('redirects safely to the provided path, without passing through the token and bypass cookie query params', async () => {
const result = await handler(request, response);
expect(result).to.be.undefined;
expect(apiResponseSpy.setDraftMode).toHaveBeenCalledWith({ enable: true });
expect(apiResponseSpy.redirect).toHaveBeenCalledWith(
'https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat',
);
});

describe('when the path is missing', () => {
beforeEach(() => {
const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft`;
request = makeNextApiRequest(url);
request.cookies['_vercel_jwt'] = vercelJwt;
});

it('returns a response with status 400', async () => {
await handler(request, response);
expect(apiResponseSpy.status).toHaveBeenCalledWith(400);
expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String));
});
});

describe('when aud in token mismatches domain of request', () => {
beforeEach(() => {
const url = `https://vercel-app-router-integrations-foobar.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`;
request = makeNextApiRequest(url);
request.cookies['_vercel_jwt'] = vercelJwt;
});

it('returns a response with status 403', async () => {
await handler(request, response);
expect(apiResponseSpy.status).toHaveBeenCalledWith(403);
expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String));
});
});

describe('when the _vercel_jwt cookie is missing', () => {
beforeEach(() => {
const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`;
request = makeNextApiRequest(url);
});

it('returns a response with status 401', async () => {
await handler(request, response);
expect(apiResponseSpy.status).toHaveBeenCalledWith(401);
expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String));
});

describe('when a x-vercel-protection-bypass token is provided as a query param', () => {
beforeEach(() => {
const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat&x-vercel-protection-bypass=${bypassToken}`;
request = makeNextApiRequest(url);
});

it('redirects safely to the provided path AND passes through the token and bypass cookie query params', async () => {
const result = await handler(request, response);
expect(result).to.be.undefined;
expect(apiResponseSpy.setDraftMode).toHaveBeenCalledWith({ enable: true });
expect(apiResponseSpy.redirect).toHaveBeenCalledWith(
`https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/blogs/my-cat?x-vercel-protection-bypass=${bypassToken}&x-vercel-set-bypass-cookie=samesitenone`,
);
});
});
});

describe('when the bypass token is wrong', () => {
beforeEach(() => {
const url = `https://vercel-app-router-integrations-ll9uxwb4f.vercel.app/api/enable-draft?path=%2Fblogs%2Fmy-cat`;
const tokenWithBadBypass =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJieXBhc3MiOiJiYWQtYnlwYXNzLXRva2VuIiwiYXVkIjoidmVyY2VsLWFwcC1yb3V0ZXItaW50ZWdyYXRpb25zLWxsOXV4d2I0Zi52ZXJjZWwuYXBwIiwiaWF0IjoxNzEzODA5MTY3LCJzdWIiOiJwcm90ZWN0aW9uLWJ5cGFzcy1hdXRvbWF0aW9uIn0=.ktyaHnYQXj-3dDnEn0ZVYkwpnQt1gc2sZ6qrgg3GIOs';
request = makeNextApiRequest(url);
request.cookies['_vercel_jwt'] = tokenWithBadBypass;
});

it('returns a response with status 403', async () => {
await handler(request, response);
expect(apiResponseSpy.status).toHaveBeenCalledWith(403);
expect(apiResponseSpy.send).toHaveBeenCalledWith(expect.any(String));
});
});
});

Loading

0 comments on commit c068d0a

Please sign in to comment.