diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 161fd2bdfffc..f92187e766b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,9 @@ jobs: with: files: | docker-bake.hcl - targets: releaser-build + targets: | + releaser-build + releaser-test build: runs-on: ubuntu-24.04 diff --git a/docker-bake.hcl b/docker-bake.hcl index cd32840eb6c8..ce783485b7ed 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -76,6 +76,13 @@ target "test-go-redirects" { provenance = false } +target "releaser-test" { + context = "hack/releaser" + target = "releaser-test" + output = ["type=cacheonly"] + provenance = false +} + target "dockerfile-lint" { call = "check" } diff --git a/hack/releaser/Dockerfile b/hack/releaser/Dockerfile index c2983b304c56..fa4c6f6dc46e 100644 --- a/hack/releaser/Dockerfile +++ b/hack/releaser/Dockerfile @@ -15,6 +15,12 @@ RUN --mount=type=bind,target=. \ --mount=type=cache,target=/root/.cache/go-build \ go build -o /out/releaser . +# releaser-test runs the unit tests for the CloudFront redirect Lambda function. +FROM base AS releaser-test +RUN apk add --no-cache nodejs +RUN --mount=type=bind,target=. \ + node --test cloudfront-lambda-redirects.test.js + FROM base AS aws-lambda-invoke ARG DRY_RUN=false ARG AWS_REGION diff --git a/hack/releaser/cloudfront-lambda-redirects.js b/hack/releaser/cloudfront-lambda-redirects.js index 6fbc40e5b182..505d73fdab81 100644 --- a/hack/releaser/cloudfront-lambda-redirects.js +++ b/hack/releaser/cloudfront-lambda-redirects.js @@ -1,91 +1,113 @@ -'use strict'; +"use strict"; exports.handler = (event, context, callback) => { - //console.log("event", JSON.stringify(event)); - const request = event.Records[0].cf.request; - const requestUrl = request.uri.replace(/\/$/, "") + //console.log("event", JSON.stringify(event)); + const request = event.Records[0].cf.request; + const requestUrl = request.uri.replace(/\/$/, ""); - const redirects = JSON.parse(`{{.RedirectsJSON}}`); - for (let key in redirects) { - const redirectTarget = key.replace(/\/$/, "") - if (redirectTarget !== requestUrl) { - continue; - } - //console.log(`redirect: ${requestUrl} to ${redirects[key]}`); - const response = { - status: '301', - statusDescription: 'Moved Permanently', - headers: { - location: [{ - key: 'Location', - value: redirects[key], - }], - }, - } - callback(null, response); - return + // Preserve the query string (e.g. UTM tags) when issuing a redirect. + const withQuery = (location) => { + if (!request.querystring) { + return location; } + const isRelative = location.startsWith("/") && !location.startsWith("//"); + const url = new URL(location, "https://docs.docker.com"); + const query = new URLSearchParams(request.querystring); + for (const [key, value] of query) { + url.searchParams.append(key, value); + } + return isRelative ? url.pathname + url.search + url.hash : url.href; + }; + + const redirects = JSON.parse(`{{.RedirectsJSON}}`); + for (let key in redirects) { + const redirectTarget = key.replace(/\/$/, ""); + if (redirectTarget !== requestUrl) { + continue; + } + //console.log(`redirect: ${requestUrl} to ${redirects[key]}`); + const response = { + status: "301", + statusDescription: "Moved Permanently", + headers: { + location: [ + { + key: "Location", + value: withQuery(redirects[key]), + }, + ], + }, + }; + callback(null, response); + return; + } - const redirectsPrefixes = JSON.parse(`{{.RedirectsPrefixesJSON}}`); - for (let x in redirectsPrefixes) { - const rp = redirectsPrefixes[x]; - if (!request.uri.startsWith(`/${rp['prefix']}`)) { - continue; - } - let newlocation = "/"; - if (rp['strip']) { - let re = new RegExp(`(^/${rp['prefix']})`, 'gi'); - newlocation = request.uri.replace(re,'/'); - } - //console.log(`redirect: ${request.uri} to ${redirectsPrefixes[key]}`); - const response = { - status: '301', - statusDescription: 'Moved Permanently', - headers: { - location: [{ - key: 'Location', - value: newlocation, - }], - }, - } - callback(null, response); - return + const redirectsPrefixes = JSON.parse(`{{.RedirectsPrefixesJSON}}`); + for (let x in redirectsPrefixes) { + const rp = redirectsPrefixes[x]; + if (!request.uri.startsWith(`/${rp["prefix"]}`)) { + continue; + } + let newlocation = "/"; + if (rp["strip"]) { + let re = new RegExp(`(^/${rp["prefix"]})`, "gi"); + newlocation = request.uri.replace(re, "/"); } + //console.log(`redirect: ${request.uri} to ${redirectsPrefixes[key]}`); + const response = { + status: "301", + statusDescription: "Moved Permanently", + headers: { + location: [ + { + key: "Location", + value: withQuery(newlocation), + }, + ], + }, + }; + callback(null, response); + return; + } - // Check Accept header for markdown/text requests - const headers = request.headers; - const acceptHeader = headers.accept ? headers.accept[0].value : ''; - const wantsMarkdown = acceptHeader.includes('text/markdown') || - acceptHeader.includes('text/plain'); + // Check Accept header for markdown/text requests + const headers = request.headers; + const acceptHeader = headers.accept ? headers.accept[0].value : ""; + const wantsMarkdown = + acceptHeader.includes("text/markdown") || + acceptHeader.includes("text/plain"); - // Handle directory requests by appending index.html or index.md for requests without file extensions - let uri = request.uri; + // Handle directory requests by appending index.html or index.md for requests without file extensions + let uri = request.uri; - // Check if the URI has a dot after the last slash (indicating a filename) - // This is more accurate than just checking the end of the URI - const hasFileExtension = /\.[^/]*$/.test(uri.split('/').pop()); + // Check if the URI has a dot after the last slash (indicating a filename) + // This is more accurate than just checking the end of the URI + const hasFileExtension = /\.[^/]*$/.test(uri.split("/").pop()); - // If it's not a file, treat it as a directory - if (!hasFileExtension) { - if (wantsMarkdown) { - // Markdown files are flattened: /path/to/page.md not /path/to/page/index.md. - // The homepage markdown output remains at /index.md. - const stripped = uri.replace(/\/$/, ''); - uri = stripped === '' ? '/index.md' : stripped + '.md'; - } else { - // HTML uses directory structure with index.html - if (!uri.endsWith("/")) { - uri += "/"; - } - uri += "index.html"; - } - request.uri = uri; - } else if (wantsMarkdown && uri.endsWith('/index.html')) { - // If requesting index.html but wants markdown, use the flattened .md file. - // The homepage markdown output lives at /index.md. - uri = uri === '/index.html' ? '/index.md' : uri.replace(/\/index\.html$/, '.md'); - request.uri = uri; + // If it's not a file, treat it as a directory + if (!hasFileExtension) { + if (wantsMarkdown) { + // Markdown files are flattened: /path/to/page.md not /path/to/page/index.md. + // The homepage markdown output remains at /index.md. + const stripped = uri.replace(/\/$/, ""); + uri = stripped === "" ? "/index.md" : stripped + ".md"; + } else { + // HTML uses directory structure with index.html + if (!uri.endsWith("/")) { + uri += "/"; + } + uri += "index.html"; } + request.uri = uri; + } else if (wantsMarkdown && uri.endsWith("/index.html")) { + // If requesting index.html but wants markdown, use the flattened .md file. + // The homepage markdown output lives at /index.md. + uri = + uri === "/index.html" + ? "/index.md" + : uri.replace(/\/index\.html$/, ".md"); + request.uri = uri; + } - callback(null, request); + callback(null, request); }; diff --git a/hack/releaser/cloudfront-lambda-redirects.test.js b/hack/releaser/cloudfront-lambda-redirects.test.js new file mode 100644 index 000000000000..f07430fd2cc3 --- /dev/null +++ b/hack/releaser/cloudfront-lambda-redirects.test.js @@ -0,0 +1,142 @@ +"use strict"; + +// Tests for cloudfront-lambda-redirects.js. +// +// The function source is a Go text/template (see aws.go) with two +// placeholders that are filled with JSON at build time. These tests render +// the template the same way aws.go does, then load and exercise the handler. +// +// Run with: node --test hack/releaser/cloudfront-lambda-redirects.test.js + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const Module = require("node:module"); + +const SRC = path.join(__dirname, "cloudfront-lambda-redirects.js"); + +const REDIRECTS = { + "/old/page/": "/new/page/", + "/target-with-query/": "/dest/?ref=docs", + "/target-with-fragment/": "/dest/?ref=docs#install", + "/external-target/": "https://www.docker.com/example?ref=docs#install", +}; + +const REDIRECTS_PREFIXES = [ + { prefix: "keep/", strip: false }, + { prefix: "strip/", strip: true }, +]; + +// Render the template the same way getLambdaFunctionZip in aws.go does, then +// evaluate it as a CommonJS module so we can call handler() directly. +function loadHandler() { + const rendered = fs + .readFileSync(SRC, "utf8") + // Function replacers avoid String.replace's special handling of `$` + // sequences in the replacement, in case a redirect target contains one. + .replace("{{.RedirectsJSON}}", () => JSON.stringify(REDIRECTS)) + .replace("{{.RedirectsPrefixesJSON}}", () => + JSON.stringify(REDIRECTS_PREFIXES), + ); + + const m = new Module(SRC); + m._compile(rendered, SRC); + return m.exports.handler; +} + +const handler = loadHandler(); + +// invoke wraps the callback-style handler in a promise. +function invoke({ uri, querystring = "", accept = "text/html" }) { + const request = { + uri, + querystring, + headers: { accept: [{ key: "Accept", value: accept }] }, + }; + const event = { Records: [{ cf: { request } }] }; + return new Promise((resolve, reject) => { + handler(event, {}, (err, result) => { + if (err) return reject(err); + resolve({ result, request }); + }); + }); +} + +function locationOf(result) { + return result.headers.location[0].value; +} + +test("exact redirect preserves the query string", async () => { + const { result } = await invoke({ + uri: "/old/page/", + querystring: "utm_source=newsletter&utm_campaign=launch", + }); + assert.equal(result.status, "301"); + assert.equal( + locationOf(result), + "/new/page/?utm_source=newsletter&utm_campaign=launch", + ); +}); + +test("exact redirect without a query string is unchanged", async () => { + const { result } = await invoke({ uri: "/old/page/" }); + assert.equal(result.status, "301"); + assert.equal(locationOf(result), "/new/page/"); +}); + +test("exact redirect appends with & when target already has a query string", async () => { + const { result } = await invoke({ + uri: "/target-with-query/", + querystring: "utm_medium=email", + }); + assert.equal(locationOf(result), "/dest/?ref=docs&utm_medium=email"); +}); + +test("exact redirect inserts the query string before a fragment", async () => { + const { result } = await invoke({ + uri: "/target-with-fragment/", + querystring: "utm_medium=email", + }); + assert.equal(locationOf(result), "/dest/?ref=docs&utm_medium=email#install"); +}); + +test("exact redirect preserves an absolute target URL", async () => { + const { result } = await invoke({ + uri: "/external-target/", + querystring: "utm_medium=email", + }); + assert.equal( + locationOf(result), + "https://www.docker.com/example?ref=docs&utm_medium=email#install", + ); +}); + +test("prefix redirect (strip) preserves the query string", async () => { + const { result } = await invoke({ + uri: "/strip/some/path", + querystring: "utm_source=x", + }); + assert.equal(result.status, "301"); + assert.equal(locationOf(result), "/some/path?utm_source=x"); +}); + +test("prefix redirect (no strip) preserves the query string", async () => { + const { result } = await invoke({ + uri: "/keep/anything", + querystring: "utm_source=x", + }); + assert.equal(result.status, "301"); + assert.equal(locationOf(result), "/?utm_source=x"); +}); + +test("directory rewrite passes the request through with query string intact", async () => { + const { result, request } = await invoke({ + uri: "/some/page", + querystring: "utm_source=x", + }); + // No redirect response: the handler forwards the (mutated) request. + assert.equal(result, request); + assert.equal(request.uri, "/some/page/index.html"); + assert.equal(request.querystring, "utm_source=x"); +});