diff --git a/app/api/indexnow/[keyFile]/route.ts b/app/api/indexnow/[keyFile]/route.ts new file mode 100644 index 00000000..7cab4727 --- /dev/null +++ b/app/api/indexnow/[keyFile]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; + +type IndexNowParams = { + keyFile: string; +}; + +interface IndexNowProps { + params: IndexNowParams; +} + +const BING_INDEXNOW_KEY = process.env.BING_INDEXNOW_KEY; + +export async function GET( + request: NextRequest, + { params: { keyFile } }: IndexNowProps +): Promise { + const [fileName, fileType] = keyFile.split("."); + + if (!BING_INDEXNOW_KEY) { + return new Response("No key found", { status: 500 }); + } + + if (fileType !== "txt") { + return new Response("Invalid file type", { status: 400 }); + } + + if (fileName !== BING_INDEXNOW_KEY) { + return new Response("Invalid key", { status: 400 }); + } + + return new Response(BING_INDEXNOW_KEY, { + headers: { + "Content-Type": "text/plain", + }, + }); +} diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index f00f5f44..680167e9 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -3,8 +3,51 @@ import { NextRequest, NextResponse } from "next/server"; import tags from "@/lib/api/client/tags"; import { fallbackLng, languages } from "@/lib/i18n/settings"; +const HOST = process.env.NEXT_PUBLIC_BASE_URL; const REVALIDATE_SECRET_TOKEN = process.env.CRAFT_REVALIDATE_SECRET_TOKEN; const CRAFT_HOMEPAGE_URI = "__home__"; +const BING_INDEXNOW_KEY = process.env.BING_INDEXNOW_KEY; +const ENV = process.env.CLOUD_ENV; + +/** + * Derived from https://www.indexnow.org/documentation#response + */ +const indexNowStatusText: Record = { + 200: "OK, URL submitted successfully", + 202: "Accepted, URL received. IndexNow key validation pending.", + 400: "Bad request, Invalid format", + 403: "Forbidden, key not valid (e.g. key not found, file found but key not in the file)", + 422: "Unprocessable Entity, URL does not belong to the host or the key is not matching the schema in the protocol", + 429: "Too Many Requests, potential spam", +}; + +const indexNow = async (uri: string) => { + if (!HOST || !BING_INDEXNOW_KEY) return; + + const urlList = languages.map((locale) => { + const parts: Array = uri === CRAFT_HOMEPAGE_URI ? [] : [uri]; + if (locale !== fallbackLng) { + parts.unshift(locale); + } + + return `${HOST}/${parts.join("/")}`; + }); + + const body = { + host: HOST, + key: BING_INDEXNOW_KEY, + keyLocation: `${HOST}/api/indexnow/${BING_INDEXNOW_KEY}.txt`, + urlList, + }; + + const { status } = await fetch("https://www.bing.com/indexnow", { + body: JSON.stringify(body), + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + + console.info(`${status}: ${indexNowStatusText[status]}`); +}; export async function GET(request: NextRequest): Promise { const uri = request.nextUrl.searchParams.get("uri"); @@ -33,11 +76,17 @@ export async function GET(request: NextRequest): Promise { parts.unshift(locale); } - revalidatePath(`/${parts.join("/")}`); + const path = `/${parts.join("/")}`; + + revalidatePath(path); }); revalidateTag(tags.globals); + if (ENV === "PROD") { + await indexNow(uri); + } + return NextResponse.json({ revalidated: true, now: Date.now() }); } diff --git a/cypress/e2e/api/indexnow.cy.js b/cypress/e2e/api/indexnow.cy.js new file mode 100644 index 00000000..bc3eea2b --- /dev/null +++ b/cypress/e2e/api/indexnow.cy.js @@ -0,0 +1,38 @@ +const indexNowKey = Cypress.env("BING_INDEXNOW_KEY"); +const baseUrl = "/api/indexnow/"; +const keyFile = `${indexNowKey}.txt`; +const invalidFileName = `123.txt`; +const invalidFileType = `${indexNowKey}.pdf`; + +context("GET /api/indexnow/[keyFile]", () => { + it("rejects requests with incorrect file type", () => { + cy.request({ + url: `${baseUrl}${invalidFileType}`, + method: "GET", + failOnStatusCode: false, + }).then(({ status, body }) => { + expect(body).to.eq("Invalid file type"); + expect(status).to.eq(400); + }); + }); + it("rejects requests with incorrect filename", () => { + cy.request({ + url: `${baseUrl}${invalidFileName}`, + method: "GET", + failOnStatusCode: false, + }).then(({ status, body }) => { + expect(body).to.eq("Invalid key"); + expect(status).to.eq(400); + }); + }); + it("returns an indexNow key", () => { + cy.request({ + url: `${baseUrl}${keyFile}`, + method: "GET", + }).then(({ status, body, headers }) => { + expect(body).to.eq(indexNowKey); + expect(headers["content-type"]).to.eq("text/plain"); + expect(status).to.eq(200); + }); + }); +});