From e8b15a255bfb508cab51df2cd2d6dc29dbb20fbe Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 8 Jul 2024 13:07:16 +0200 Subject: [PATCH] feat(api): handle missing endpoints (#55429) --- api/package.json | 1 + api/src/app.ts | 2 + api/src/plugins/not-found.test.ts | 73 +++++++++++++++++++++++++++++++ api/src/plugins/not-found.ts | 37 ++++++++++++++++ pnpm-lock.yaml | 11 +++++ 5 files changed, 124 insertions(+) create mode 100644 api/src/plugins/not-found.test.ts create mode 100644 api/src/plugins/not-found.ts diff --git a/api/package.json b/api/package.json index d7b01990c6f..6a43d32a49f 100644 --- a/api/package.json +++ b/api/package.json @@ -5,6 +5,7 @@ }, "dependencies": { "@aws-sdk/client-ses": "3.521.0", + "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.3.1", "@fastify/csrf-protection": "6.4.1", "@fastify/express": "^2.3.0", diff --git a/api/src/app.ts b/api/src/app.ts index 96d720e235e..3a35946f413 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -25,6 +25,7 @@ import mailer from './plugins/mailer'; import redirectWithMessage from './plugins/redirect-with-message'; import security from './plugins/security'; import codeFlowAuth from './plugins/code-flow-auth'; +import notFound from './plugins/not-found'; import { mobileAuth0Routes } from './routes/auth'; import { devAuthRoutes } from './routes/auth-dev'; import { @@ -181,6 +182,7 @@ export const build = async ( // redirectWithMessage must be registered before codeFlowAuth void fastify.register(redirectWithMessage); void fastify.register(codeFlowAuth); + void fastify.register(notFound); void fastify.register(prismaPlugin); void fastify.register(mobileAuth0Routes); if (FCC_ENABLE_DEV_LOGIN_MODE) { diff --git a/api/src/plugins/not-found.test.ts b/api/src/plugins/not-found.test.ts new file mode 100644 index 00000000000..7481d8a3ea3 --- /dev/null +++ b/api/src/plugins/not-found.test.ts @@ -0,0 +1,73 @@ +import Fastify, { type FastifyInstance } from 'fastify'; + +import notFound from './not-found'; +import redirectWithMessage, { formatMessage } from './redirect-with-message'; + +describe('fourOhFour', () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = Fastify(); + await fastify.register(redirectWithMessage); + await fastify.register(notFound); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it('should redirect to origin/404 if the request does not Accept json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + accept: 'text/plain' + } + }); + + expect(res.headers['location']).toEqual( + 'https://www.freecodecamp.org/404?' + + formatMessage({ + type: 'danger', + content: "We couldn't find path /test" + }) + ); + expect(res.statusCode).toEqual(302); + }); + + it('should return a 404 json response if the request does Accept json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + accept: 'application/json,text/plain' + } + }); + + expect(res.json()).toEqual({ error: 'path not found' }); + expect(res.statusCode).toEqual(404); + }); + + it('should redirect if the request prefers text/html to json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + // this does accept json, (via the */*), but prefers text/html + accept: 'text/html,*/*' + } + }); + + expect(res.headers['location']).toEqual( + 'https://www.freecodecamp.org/404?' + + formatMessage({ + type: 'danger', + content: "We couldn't find path /test" + }) + ); + expect(res.statusCode).toEqual(302); + }); +}); diff --git a/api/src/plugins/not-found.ts b/api/src/plugins/not-found.ts new file mode 100644 index 00000000000..ff59bfd25bc --- /dev/null +++ b/api/src/plugins/not-found.ts @@ -0,0 +1,37 @@ +import type { FastifyPluginCallback } from 'fastify'; + +import fp from 'fastify-plugin'; +import accepts from '@fastify/accepts'; + +import { getRedirectParams } from '../utils/redirection'; + +/** + * Plugin for handling missing endpoints. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done Callback to signal that the logic has completed. + */ +const fourOhFour: FastifyPluginCallback = (fastify, _options, done) => { + void fastify.register(accepts); + + // If the request accepts JSON and does not specifically prefer text/html, + // this will return a 404 JSON response. Everything else will be redirected. + fastify.setNotFoundHandler((request, reply) => { + const accepted = request.accepts().type(['json', 'html']); + + if (accepted == 'json') { + void reply.code(404).send({ error: 'path not found' }); + } else { + const { origin } = getRedirectParams(request); + void reply.status(302); + void reply.redirectWithMessage(`${origin}/404`, { + type: 'danger', + content: `We couldn't find path ${request.url}` + }); + } + }); + done(); +}; + +export default fp(fourOhFour, { dependencies: ['redirect-with-message'] }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d92a123f87..05564135d52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@aws-sdk/client-ses': specifier: 3.521.0 version: 3.521.0 + '@fastify/accepts': + specifier: 4.3.0 + version: 4.3.0 '@fastify/cookie': specifier: 9.3.1 version: 9.3.1 @@ -2962,6 +2965,9 @@ packages: resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} engines: {node: '>=14'} + '@fastify/accepts@4.3.0': + resolution: {integrity: sha512-QK4FoqXdwwPmaPOLL6NrxsyaXVvdviYVoS6ltHyOLdFlUyREIaMykHQIp+x0aJz9hB3B3n/Ht6QRdvBeGkptGQ==} + '@fastify/ajv-compiler@3.5.0': resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} @@ -17078,6 +17084,11 @@ snapshots: '@fastify/accept-negotiator@1.1.0': {} + '@fastify/accepts@4.3.0': + dependencies: + accepts: 1.3.8 + fastify-plugin: 4.5.1 + '@fastify/ajv-compiler@3.5.0': dependencies: ajv: 8.12.0