Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ jobs:
with:
files: |
docker-bake.hcl
targets: releaser-build
targets: |
releaser-build
releaser-test

build:
runs-on: ubuntu-24.04
Expand Down
7 changes: 7 additions & 0 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 6 additions & 0 deletions hack/releaser/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 100 additions & 78 deletions hack/releaser/cloudfront-lambda-redirects.js
Original file line number Diff line number Diff line change
@@ -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);
};
142 changes: 142 additions & 0 deletions hack/releaser/cloudfront-lambda-redirects.test.js
Original file line number Diff line number Diff line change
@@ -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");
});