diff --git a/app/api/charming-overlords/route.ts b/app/api/charming-overlords/route.ts new file mode 100644 index 00000000..08db8483 --- /dev/null +++ b/app/api/charming-overlords/route.ts @@ -0,0 +1,29 @@ +import { OAuth2Client } from "google-auth-library"; +import { NextRequest, NextResponse } from "next/server"; + +const GOOGLE_APP_ID = process.env.NEXT_PUBLIC_GOOGLE_APP_ID; +const GOOGLE_APP_SECRET = process.env.GOOGLE_APP_SECRET; + +const oAuth2Client = new OAuth2Client( + GOOGLE_APP_ID, + GOOGLE_APP_SECRET, + "postmessage" +); + +export async function POST(request: NextRequest): Promise { + const { code } = await request.json(); + + if (!code) { + return new NextResponse("Missing authorization code", { status: 422 }); + } + + try { + const { + tokens: { id_token: idToken }, + } = await oAuth2Client.getToken(code); + + return NextResponse.json({ idToken }, { status: 200 }); + } catch (err) { + return new NextResponse("Not authorized", { status: 401 }); + } +} diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts new file mode 100644 index 00000000..46bf3e11 --- /dev/null +++ b/app/api/preview/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { draftMode } from "next/headers"; +import { redirect } from "next/navigation"; +import { gql } from "@urql/core"; +import { fallbackLng } from "@/lib/i18n/settings"; +import queryAPI from "@/lib/api/client/query"; +import { getLocaleString, getSiteFromLocale } from "@/lib/helpers/site"; + +const PREVIEW_SECRET_TOKEN = process.env.CRAFT_SECRET_TOKEN; +const CRAFT_HOMEPAGE_URI = "__home__"; + +const Query = gql(` + query PagePreviewQuery($site: [String], $uri: [String]) { + entry(site: $site, uri: $uri) { + __typename + uri + title + } + } +`); + +function isCraftPreview(params: URLSearchParams): boolean { + return ( + !!params.get("x-craft-preview") || !!params.get("x-craft-live-preview") + ); +} + +export async function GET(request: NextRequest): Promise { + const { searchParams } = request.nextUrl; + const secret = searchParams.get("secret"); + const previewToken = searchParams.get("token") || undefined; + const site = getSiteFromLocale( + (searchParams.get("site") || fallbackLng).toLowerCase() + ); + const locale = getLocaleString(site); + const uri = searchParams.get("uri"); + + // Check that this request came from Craft + if (!isCraftPreview) { + return new NextResponse("Invalid client", { status: 401 }); + } + + // Check the secret and next parameters + // This secret should only be known to this route handler and the CMS + if (secret !== PREVIEW_SECRET_TOKEN) { + return new NextResponse("Invalid token", { status: 401 }); + } + + if (!uri) { + return new NextResponse("URI missing", { status: 422 }); + } + + const { data } = await queryAPI({ + query: Query, + variables: { + site: [site], + uri: [uri], + }, + previewToken, + fetchOptions: { cache: "no-store" }, + }); + + // If the uri doesn't exist prevent draft mode from being enabled + if (!data?.entry?.uri) { + return new NextResponse("Invalid uri", { status: 422 }); + } + + // Enable Draft Mode by setting the cookie + draftMode().enable(); + + const segments = [locale]; + + if (data.entry.uri !== CRAFT_HOMEPAGE_URI) { + segments.push(data.entry.uri); + } + + const params = new URLSearchParams({}); + + if (previewToken) { + params.set("preview", previewToken); + } + + // Redirect to the path from the fetched entry + // We don't redirect to searchParams.uri as that might lead to open redirect vulnerabilities + const redirectPath = `/${segments.join("/")}?${params.toString()}`; + + redirect(redirectPath, "replace"); +} diff --git a/app/api/app-revalidate/route.ts b/app/api/revalidate/route.ts similarity index 100% rename from app/api/app-revalidate/route.ts rename to app/api/revalidate/route.ts index 80033c4a..f00f5f44 100644 --- a/app/api/app-revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -1,7 +1,7 @@ -import tags from "@/lib/api/client/tags"; -import { fallbackLng, languages } from "@/lib/i18n/settings"; import { revalidatePath, revalidateTag } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; +import tags from "@/lib/api/client/tags"; +import { fallbackLng, languages } from "@/lib/i18n/settings"; const REVALIDATE_SECRET_TOKEN = process.env.CRAFT_REVALIDATE_SECRET_TOKEN; const CRAFT_HOMEPAGE_URI = "__home__"; diff --git a/cypress.config.js b/cypress.config.js index 10e7b429..61ca1a73 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,4 +1,7 @@ +import { loadEnvConfig } from "@next/env"; + const { defineConfig } = require("cypress"); +const projectDir = process.cwd(); module.exports = defineConfig({ experimentalModifyObstructiveThirdPartyCode: true, @@ -13,4 +16,5 @@ module.exports = defineConfig({ bundler: "webpack", }, }, + env: loadEnvConfig(projectDir).combinedEnv, }); diff --git a/cypress/e2e/api/charming-overlords.cy.js b/cypress/e2e/api/charming-overlords.cy.js new file mode 100644 index 00000000..79b10c18 --- /dev/null +++ b/cypress/e2e/api/charming-overlords.cy.js @@ -0,0 +1,22 @@ +context("POST /charming-overlords", () => { + it("rejects requests without a code", () => { + cy.request({ + url: "/api/charming-overlords", + method: "POST", + body: {}, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(422); + }); + }); + it("rejects invalid codes", () => { + cy.request({ + url: "/api/charming-overlords", + body: { code: "somecode" }, + method: "POST", + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(401); + }); + }); +}); diff --git a/cypress/e2e/api/preview.cy.js b/cypress/e2e/api/preview.cy.js new file mode 100644 index 00000000..c50df25b --- /dev/null +++ b/cypress/e2e/api/preview.cy.js @@ -0,0 +1,65 @@ +const url = "/api/preview"; +const uri = "news"; +const invalidUri = "badnews"; +const secret = Cypress.env("CRAFT_SECRET_TOKEN"); +const token = "previewToken"; + +context("GET /preview", () => { + it("rejects requests that do not come from Craft", () => { + cy.request({ + url, + method: "GET", + failOnStatusCode: false, + }).then(({ status }) => { + expect(status).to.eq(401); + }); + }); + it("rejects requests that do not have a secret", () => { + const params = new URLSearchParams({ "x-craft-preview": true }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + failOnStatusCode: false, + }).then(({ status }) => { + expect(status).to.eq(401); + }); + }); + it("rejects requests that do not have a preview URI", () => { + const params = new URLSearchParams({ "x-craft-preview": true, secret }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + failOnStatusCode: false, + }).then(({ status }) => { + expect(status).to.eq(422); + }); + }); + it("rejects requests that have an invalid URI", () => { + const params = new URLSearchParams({ + "x-craft-preview": true, + secret, + uri: invalidUri, + }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + failOnStatusCode: false, + }).then(({ status }) => { + expect(status).to.eq(422); + }); + }); + it("redirects to a preview page", () => { + const params = new URLSearchParams({ + "x-craft-preview": true, + secret, + uri, + token, + }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + }).then(({ redirects, headers }) => { + expect(redirects.length).to.be.gt(0); + }); + }); +}); diff --git a/cypress/e2e/api/revalidate.cy.js b/cypress/e2e/api/revalidate.cy.js new file mode 100644 index 00000000..2a2c7e61 --- /dev/null +++ b/cypress/e2e/api/revalidate.cy.js @@ -0,0 +1,37 @@ +const url = "/api/revalidate"; +const uri = "news"; +const secret = Cypress.env("CRAFT_REVALIDATE_SECRET_TOKEN"); + +context("GET /revalidate", () => { + it("rejects missing URI segments", () => { + cy.request({ + url, + method: "GET", + }).then(({ body }) => { + const { revalidated, message } = body; + expect(revalidated).to.eq(false); + expect(message).to.eq("Missing path to revalidate"); + }); + }); + it("rejects missing secret", () => { + const params = new URLSearchParams({ uri }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + }).then(({ body }) => { + const { revalidated, message } = body; + expect(revalidated).to.eq(false); + expect(message).to.eq("Invalid token"); + }); + }); + it("revalidates", () => { + const params = new URLSearchParams({ uri, secret }); + cy.request({ + url: `${url}?${params.toString()}`, + method: "GET", + }).then(({ body }) => { + const { revalidated } = body; + expect(revalidated).to.eq(true); + }); + }); +}); diff --git a/helpers/index.js b/helpers/index.js index d841328d..000d77a9 100644 --- a/helpers/index.js +++ b/helpers/index.js @@ -29,10 +29,6 @@ export function fileSize(size) { ); } -export function isCraftPreview(query) { - return query["x-craft-preview"] || query["x-craft-live-preview"]; -} - export function wait(seconds) { return new Promise((resolve) => { setTimeout(resolve, seconds * 1000); diff --git a/pages/api/charming-overlords.js b/pages/api/charming-overlords.js deleted file mode 100644 index c9fee7a6..00000000 --- a/pages/api/charming-overlords.js +++ /dev/null @@ -1,25 +0,0 @@ -import { OAuth2Client } from "google-auth-library"; - -const GOOGLE_APP_ID = process.env.NEXT_PUBLIC_GOOGLE_APP_ID; -const GOOGLE_APP_SECRET = process.env.GOOGLE_APP_SECRET; - -const oAuth2Client = new OAuth2Client( - GOOGLE_APP_ID, - GOOGLE_APP_SECRET, - "postmessage" -); - -// exchanges Auth Code for Tokens -async function getTokens(code) { - const { tokens } = await oAuth2Client.getToken(code); - return tokens; -} - -export default async function handler(req, res) { - try { - const { id_token: idToken } = await getTokens(req.body.code); - res.status(200).json({ idToken }); - } catch (err) { - res.status(500).json({ error: "failed to load data" }); - } -} diff --git a/pages/api/preview.js b/pages/api/preview.js deleted file mode 100644 index a9cca3ee..00000000 --- a/pages/api/preview.js +++ /dev/null @@ -1,84 +0,0 @@ -import { gql } from "graphql-request"; -import { queryAPI } from "@/lib/fetch"; -import { getSiteFromLocale, getLocaleString } from "@/lib/helpers/site"; - -const PREVIEW_SECRET_TOKEN = process.env.CRAFT_SECRET_TOKEN; -const CRAFT_HOMEPAGE_URI = "__home__"; - -const Query = gql` - query PagePreviewQuery($site: [String], $uri: [String]) { - entry(site: $site, uri: $uri) { - uri - title - } - } -`; - -/** - * @function preview - * @param {import("next").NextApiRequest} request - * @param {import("next").NextApiResponse} response - */ -const preview = async (request, response) => { - const searchParams = new URLSearchParams(request.query); - - const secret = searchParams.get("secret"); - const previewToken = searchParams.get("token"); - const site = getSiteFromLocale( - (searchParams.get("site") || "en").toLowerCase() - ); - const isDefaultSite = site === "default"; - const locale = getLocaleString(site); - const uri = searchParams.get("uri"); - - // Check the secret and next parameters - // This secret should only be known to this route handler and the CMS - if (secret !== PREVIEW_SECRET_TOKEN) { - return response.status(401).send({ message: "Invalid token" }); - } - - if (!uri) { - return response.status(401).send({ message: "URI missing" }); - } - - const res = await queryAPI(Query, undefined, previewToken, { site, uri }); - - // If the uri doesn't exist prevent draft mode from being enabled - if (!res?.entry?.uri) { - return response.status(401).send({ message: "Invalid uri" }); - } - - // Enable Draft Mode by setting the cookie - response.setDraftMode({ enable: true }); - - const params = new URLSearchParams({}); - - if (previewToken) { - params.set("preview", previewToken); - } - - if (res.entry.uri === CRAFT_HOMEPAGE_URI) { - const redirect = `/${locale}?${params.toString()}`; - const cookiePath = isDefaultSite ? "/" : redirect; - - response.setPreviewData( - { previewToken }, - { path: cookiePath, maxAge: 120 } - ); - response.redirect(redirect); - } else { - const redirectUri = `/${res.entry.uri}`; - const redirect = `/${locale}${redirectUri}?${params.toString()}`; - const cookiePath = `${ - site === "default" ? "" : `/${locale}` - }${redirectUri}`; - - response.setPreviewData( - { previewToken }, - { path: cookiePath, maxAge: 120 } - ); - response.redirect(redirect); - } -}; - -export default preview; diff --git a/pages/api/revalidate.js b/pages/api/revalidate.js deleted file mode 100644 index 9878dcf0..00000000 --- a/pages/api/revalidate.js +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable no-console */ -import { isCraftPreview } from "@/helpers"; - -const REVALIDATE_SECRET_TOKEN = process.env.CRAFT_REVALIDATE_SECRET_TOKEN; -const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; - -/** - * @function preview - * @param {import("next").NextApiRequest} req - * @param {import("next").NextApiResponse} res - */ -async function handler(req, res) { - const { query } = req; - const isPreview = isCraftPreview(query); - - if (isPreview) { - return res.status(401).json({ - message: - "Revalidate failed because request included Craft Preview header", - }); - } - - if (query.secret !== REVALIDATE_SECRET_TOKEN) { - console.warn("Invalid token"); - return res.status(401).json({ error: "Invalid token" }); - } - - if (!query.uri) { - console.warn(`The parameter "uri" is required.`); - return res.status(500).json({ error: `The parameter "uri" is required.` }); - } - - const searchParams = new URLSearchParams(query); - const response = await fetch( - `${BASE_URL}/api/app-revalidate?${searchParams.toString()}` - ); - - if (response.ok) { - const body = await response.json(); - - return res.status(200).json(body); - } else { - // If there was an error, Next.js will continue - // to show the last successfully generated page - return res.status(500).send("Error revalidating"); - } -} - -export default handler;