Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/pages router #49

Merged
merged 15 commits into from
May 6, 2024
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';
matthew-gordon marked this conversation as resolved.
Show resolved Hide resolved
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
Loading