/*
VERSION:
    File: 8k-art-directory-browser.txt
    Package archive: s3-cloudfront-indexer-html-package-v36.zip
    Top folder: s3-cloudfront-indexer-html-package-v36
    Package version: v36
    Build name: 8k-art-directory-browser.txt

CHANGE NOTE:
    Folder timestamps now default to fast direct-child scans. Recursive folder
    timestamp scans remain available as an opt-in setting.

*/
/*
VERSION:
    8K ART S3 + CloudFront Directory Browser
    Version: v10.1
    Release date: 2026-05-18
    Build name: nested-slash-html-v10.1

CHANGE NOTE:
    Adds a visible source-code-only version/date marker near the top of this file.
    This comment is not displayed in generated directory listings.

*/
/*
================================================================================
8K ART S3 + CLOUDFRONT DIRECTORY BROWSER
================================================================================

Project file:
    https://8k.art/beta/s3-cloudfront-indexer/8k-art-directory-browser.txt

Live example locations:
    https://8k.art/beta/
    https://8k.art/_docs/
    https://8k.press/images/

Created for:
    The 8K ART / 8K PRESS open source software publishing workflow.

Shared for:
    Open source developers, AWS builders, web publishers, educators,
    documentation authors, software archivists, and communities such as
    Experts Exchange where practical infrastructure solutions are shared,
    studied, improved, and reused.

--------------------------------------------------------------------------------
WHAT THIS CODE DOES
--------------------------------------------------------------------------------

This is a Lambda@Edge directory browser for Amazon S3 content served through
Amazon CloudFront.

Amazon S3 does not provide Apache-style directory browsing for static website
hosting the way a traditional Apache VPS can with mod_autoindex. If you upload a
folder full of files to Amazon S3 and visit that folder URL through CloudFront,
Amazon S3 will normally look for an index document, return an error, or show
only whatever static index.html file you pre-generated.

This function restores the workflow many developers love from Apache:

    Upload a file.
    Refresh the folder URL.
    See the file appear.

No build step.
No generated index.html files.
No VPS.
No Apache server.
No cron job.
No manual directory-page maintenance.

The function runs at the CloudFront edge on an origin-request event. When a
visitor requests an approved folder-style URL such as:

    /beta/
    /beta/some-subfolder/
    /_docs/
    /images/

the function calls Amazon S3 ListObjectsV2 using the requested path as the S3
prefix. It then renders a simple Apache-like HTML directory listing containing:

    Name, with multilingual-friendly folder/document icons
    Last modified
    Size
    Parent Directory

Requests for actual files, such as:

    /beta/example.zip
    /_docs/readme.md
    /images/photo.png

are passed through normally to CloudFront and Amazon S3.

--------------------------------------------------------------------------------
WHY THIS IS USEFUL
--------------------------------------------------------------------------------

This is useful anywhere you want the convenience of Apache directory browsing
without running a VPS or web server.

Common open source uses include:

    1. Public beta builds
       Upload new beta builds every few minutes and let testers immediately see
       the newest files.

    2. Nightly builds
       Publish automated builds into dated folders without regenerating an index
       page after every build.

    3. Documentation drops
       Upload docs, examples, screenshots, text files, and changelogs directly to
       Amazon S3 and let users browse them.

    4. Source code previews
       Combine this with a CloudFront Function that forces .php, .md, .txt,
       .json, .xml, .css, and .js files to display inline as UTF-8 text.

    5. Public project archives
       Host old releases, dependency bundles, firmware images, ZIP files, or
       research artifacts in a browsable archive.

    6. Educational publishing
       Teachers, students, and technical writers can upload folder trees and
       make them immediately visible without learning static-site generators.

    7. VPS replacement
       If the only reason you kept an Apache VPS was directory browsing, this
       pattern can replace that VPS with Amazon S3 + CloudFront + Lambda@Edge.

    8. Lightweight release servers
       Useful for indie developers, small teams, and open source maintainers who
       want a simple public file drop that still benefits from CloudFront caching.

    9. Transparent public work-in-progress folders
       Let users, contributors, testers, and collaborators see what is changing
       without requiring a CMS or repository browser.

    10. Multi-site browsing
        This example can map multiple URL prefixes to multiple Amazon S3 buckets,
        allowing one Lambda@Edge function to support more than one public site.

--------------------------------------------------------------------------------
HOW IT WORKS
--------------------------------------------------------------------------------

The important configuration is BROWSABLE_PREFIX_CONFIGS.

Each entry maps a public folder prefix to an Amazon S3 bucket:

    {
        prefix: "beta/",
        bucket: "www.8k.art"
    }

That means:

    https://8k.art/beta/

will list objects under:

    s3://www.8k.art/beta/

This function intentionally allows only configured prefixes. That prevents the
entire bucket from becoming browsable accidentally.

For each approved prefix, the function uses:

    ListObjectsV2
    Prefix: requested folder path
    Delimiter: "/"

The delimiter makes Amazon S3 return folder-like CommonPrefixes, which lets the
function show subfolders separately from files.

--------------------------------------------------------------------------------
DEPLOYMENT NOTES
--------------------------------------------------------------------------------

This is intended for Lambda@Edge, not a CloudFront Function.

CloudFront Functions are excellent for tiny header edits, redirects, and URL
rewrites, but they cannot call Amazon S3. Directory browsing requires an Amazon
S3 ListObjectsV2 call, so this belongs in Lambda@Edge.

Typical deployment:

    1. Create a Lambda function in us-east-1.
    2. Use Node.js with this index.mjs code.
    3. Give the Lambda execution role s3:ListBucket permission for only the
       browsable prefixes.
    4. Publish a numbered Lambda version.
    5. Attach that numbered version to the CloudFront behavior as:
           Event type: Origin request
           Include body: No
    6. Wait for CloudFront deployment.
    7. Visit an approved folder URL.

Important Lambda@Edge notes:

    - The function must be in us-east-1.
    - CloudFront must use a published numbered version, not $LATEST.
    - The execution role trust policy must allow both:
          lambda.amazonaws.com
          edgelambda.amazonaws.com
    - After changing code, publish a new version and update the CloudFront
      Lambda@Edge association to the new version ARN.

--------------------------------------------------------------------------------
IAM POLICY EXAMPLE
--------------------------------------------------------------------------------

Limit list access to only the folders you intend to make browsable:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowList8kArtBrowsableFolders",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::www.8k.art",
      "Condition": {
        "StringLike": {
          "s3:prefix": [
            "beta/",
            "beta/*",
            "_docs/",
            "_docs/*"
          ]
        }
      }
    },
    {
      "Sid": "AllowList8kPressImagesFolder",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::www.8k.press",
      "Condition": {
        "StringLike": {
          "s3:prefix": [
            "images/",
            "images/*"
          ]
        }
      }
    }
  ]
}

--------------------------------------------------------------------------------
OPTIONAL COMPANION CLOUDFRONT FUNCTION
--------------------------------------------------------------------------------

A separate CloudFront Function can be used on viewer-response to normalize
headers, enable CORS, and make source files display inline in Chrome.

For example, you may want:

    .php      -> text/plain; charset=utf-8
    .md       -> text/markdown; charset=utf-8
    .txt      -> text/plain; charset=utf-8
    .json     -> application/json; charset=utf-8
    .xml      -> application/xml; charset=utf-8

That companion function is separate from this Lambda@Edge directory browser.

--------------------------------------------------------------------------------
CACHE BEHAVIOR
--------------------------------------------------------------------------------

This function returns:

    Cache-Control: public, max-age=30

That means directory listings can be cached briefly at CloudFront. New uploads
will normally appear after the cache expires, or immediately after an
invalidation.

For very active beta folders, 15 to 30 seconds is a good balance. For slow
archives, you can increase the max-age.


--------------------------------------------------------------------------------
SEARCH ENGINE / GOOGLE INDEXING NOTES
--------------------------------------------------------------------------------

This version includes small SEO-friendly improvements for public directory pages:

    - Slashless folder URLs such as /beta, /_docs, and /images are redirected
      to their canonical trailing-slash versions.
    - Nested slashless folder URLs such as /beta/s3-cloudfront-indexer are also
      redirected to their trailing-slash versions when they look like folders.
    - Generated directory pages include a robots meta tag.
    - Generated directory pages include a canonical link when the Host header is
      available.
    - Generated directory pages include a short human-readable description above
      the listing so the page is not just a bare list of filenames.

For Google Search Console, inspect the trailing-slash URL, for example:

    https://8k.art/beta/

not:

    https://8k.art/beta

Host-safe routing prevents one public domain from accidentally showing another
domain's bucket contents. For example, 8k.press/beta/ will not list 8k.art/beta/.

This version checks several CloudFront request clues because Lambda@Edge
origin-request events may see the origin host instead of the public viewer
hostname. If CloudFront provides no useful hostname clue, it falls back to the
8k.art rules only, while still refusing to let clearly identified 8k.press
requests browse /beta/.

--------------------------------------------------------------------------------
SECURITY NOTES
--------------------------------------------------------------------------------

Only make prefixes browsable if the contents are intended to be public.

This function does not grant public read access by itself. It only generates a
listing for approved prefixes. Your CloudFront/S3 origin setup still controls
whether file objects can actually be fetched.

Recommended practices:

    - Restrict s3:ListBucket to only the prefixes you want listed.
    - Do not include private prefixes in BROWSABLE_PREFIX_CONFIGS.
    - Use separate buckets or prefixes for public and private content.
    - Keep the function attached only to the CloudFront behaviors that need it.

--------------------------------------------------------------------------------
CUSTOMIZATION IDEAS
--------------------------------------------------------------------------------

You can easily modify this code to add:

    - File icons
    - Sorting links
    - Client-side sortable directory tables
    - File type labels
    - Human-readable descriptions
    - README injection at the top of a folder
    - Custom branding
    - Dark mode
    - JSON directory output for APIs
    - Markdown rendering
    - Download buttons
    - Checksums
    - Version badges
    - Release notes
    - Directory-level access rules
    - Different cache durations per prefix

--------------------------------------------------------------------------------
WHY THIS MATTERS
--------------------------------------------------------------------------------

For open source developers, friction matters.

When publishing beta builds, documentation, sample files, screenshots, helper
scripts, or experimental releases, the maintenance cost of repeatedly generating
index.html files can become absurd. A traditional Apache server solved that
problem with directory browsing, but keeping a VPS alive only for autoindex is
unnecessary when Amazon S3 and CloudFront can do the heavy lifting.

This file demonstrates a tiny, practical bridge between the old web and the
modern cloud:

    Apache-style browsing convenience
    Amazon S3 storage durability
    CloudFront global delivery
    Lambda@Edge dynamic folder listings

It is simple, useful, and intentionally boring infrastructure — the best kind.

--------------------------------------------------------------------------------
PUBLIC COPY
--------------------------------------------------------------------------------

The public copy of this code is intended to live here:

    https://8k.art/beta/s3-cloudfront-indexer/8k-art-directory-browser.txt

If this helped you, adapt it, improve it, fork the idea, write about it, and use
it to publish your own open source work with less tedium.

================================================================================
*/


import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";

const REGION = "us-east-1";

const BROWSABLE_PREFIX_CONFIGS = [
    {
        site: "8k-art",
        hosts: ["8k.art", "www.8k.art"],
        originDomainContains: ["www.8k.art", "8k.art"],
        prefix: "beta/",
        bucket: "www.8k.art"
    },
    {
        site: "8k-art",
        hosts: ["8k.art", "www.8k.art"],
        originDomainContains: ["www.8k.art", "8k.art"],
        prefix: "_docs/",
        bucket: "www.8k.art"
    },
    {
        site: "8k-press",
        hosts: ["8k.press", "www.8k.press"],
        originDomainContains: ["www.8k.press", "8k.press"],
        prefix: "images/",
        bucket: "www.8k.press"
    }

    // Important:
    // There is intentionally no 8k.press "beta/" rule here.
    // That prevents https://8k.press/beta/ from showing the 8k.art beta folder.
];

/*
================================================================================
CANONICAL URL RULES
================================================================================

Set ENABLE_CANONICAL_URL_RULES to false if you want the generated page to use
whatever host CloudFront gives the Lambda. Most public sites should leave this
set to true.

Each rule maps one or more incoming hostnames/origin hostnames to one clean
public canonical hostname.

Examples:

    www.example.com  -> example.com
    example.com      -> example.com
    example-bucket.s3-website-us-east-1.amazonaws.com -> example.com

How to add your own site:

    {
        canonicalHost: "example.com",
        hosts: [
            "example.com",
            "www.example.com",
            "example-bucket.s3-website-us-east-1.amazonaws.com"
        ]
    }

These rules affect:

    <link rel="canonical" href="...">
    <title>canonical-host/path/ - Directory Index</title>

They do not change which S3 bucket is listed. Bucket listing is controlled by
BROWSABLE_PREFIX_CONFIGS above.

================================================================================
*/

const ENABLE_CANONICAL_URL_RULES = true;

const CANONICAL_HOST_RULES = [
    {
        canonicalHost: "8k.art",
        hosts: [
            "8k.art",
            "www.8k.art",
            "www.8k.art.s3-website-us-east-1.amazonaws.com",
            "8k.art.s3-website-us-east-1.amazonaws.com"
        ]
    },
    {
        canonicalHost: "8k.press",
        hosts: [
            "8k.press",
            "www.8k.press",
            "www.8k.press.s3-website-us-east-1.amazonaws.com",
            "8k.press.s3-website-us-east-1.amazonaws.com"
        ]
    }
];

const STRIP_LEADING_WWW_FOR_UNKNOWN_HOSTS = true;

/*
================================================================================
DIRECTORY LISTING TIME ZONE
================================================================================

Amazon S3 object timestamps are returned in UTC. This setting converts the
"Last modified" column into a human-friendly local time zone.

Los Angeles example:

    DIRECTORY_LISTING_TIME_ZONE = "America/Los_Angeles"

That will show times like:

    2026-05-18 03:24:12 PM PDT
    2026-12-18 03:24:12 PM PST

Use any valid IANA time zone, such as:

    America/New_York
    America/Chicago
    Europe/London
    Europe/Berlin
    Asia/Tokyo
    Australia/Sydney

Set DIRECTORY_LISTING_TIME_ZONE_LABEL to something short if you want a friendly
hint in comments or future UI. The actual PDT/PST/EST/EDT-style abbreviation is
generated automatically by Intl.DateTimeFormat when available.

================================================================================
*/

const DIRECTORY_LISTING_TIME_ZONE = "America/Los_Angeles";
const DIRECTORY_LISTING_LOCALE = "en-US";
const DIRECTORY_LISTING_TIME_ZONE_LABEL = "Los Angeles";

/*
================================================================================
FOLDER LAST MODIFIED DATES
================================================================================

Amazon S3 does not have real folders. A "folder" is just a prefix, such as:

    beta/_images/

This feature gives folder rows a useful Last modified value by looking inside
each displayed subfolder and finding the newest object timestamp.

Human meaning:

    folder Last modified = newest file found inside that folder prefix

Cost / performance note:

    This requires extra small S3 ListObjectsV2 calls for the folder rows shown
    on the current page. It is very useful for public beta/documentation folders.
    If you have thousands of folders on one page, you may want to turn it off.

Settings:

    SHOW_FOLDER_LAST_MODIFIED:
        true  = show calculated folder timestamps
        false = folders keep blank Last modified values

    FOLDER_LAST_MODIFIED_RECURSIVE:
        false = fastest default; newest direct child file only
        true  = deeper scan; newest file anywhere under the folder prefix

    MAX_FOLDER_LAST_MODIFIED_KEYS:
        safety limit for how many S3 objects to scan per displayed folder.
        Keep this modest if you want pages to feel instant.

    FOLDER_LAST_MODIFIED_CONCURRENCY:
        how many folder-summary checks can run at the same time

================================================================================
*/

const SHOW_FOLDER_LAST_MODIFIED = true;
const FOLDER_LAST_MODIFIED_RECURSIVE = false;
const MAX_FOLDER_LAST_MODIFIED_KEYS = 1000;
const FOLDER_LAST_MODIFIED_CONCURRENCY = 3;




const s3 = new S3Client({ region: REGION });

export const handler = async (event) => {
    const request = event.Records[0].cf.request;
    const uri = decodeURIComponent(request.uri || "/");
    const requestInfo = getRequestInfo(request);

    // Required public slashless directory redirects.
    //
    // Public links are often shared without a trailing slash:
    //   /beta
    //   /beta/_images
    //   /beta/_images/some-subfolder
    //   /images
    //
    // This only runs for URLs that do NOT already end in slash. For nested paths,
    // it asks Amazon S3 whether the corresponding folder prefix actually exists.
    // That means real directories redirect, while files pass through normally.
    if (!uri.endsWith("/")) {
        const slashlessDirectoryRedirect = await getSlashlessDirectoryRedirect(
            requestInfo,
            uri,
            request.querystring || ""
        );

        if (slashlessDirectoryRedirect) {
            return slashlessDirectoryRedirect;
        }
    }

    // Only handle folder-style URLs.
    // Examples:
    // /beta/                  yes
    // /beta/_daily-builds/    yes
    // /_docs/                 yes
    // /images/                yes
    // /images/file.png        no
    if (!uri.endsWith("/")) {
        return request;
    }

    const prefix = uri.replace(/^\/+/, "");

    const prefixConfig = getPrefixConfig(requestInfo, prefix);

    // If this path is not one of the approved browsable folders,
    // pass through to S3 normally.
    if (!prefixConfig) {
        return request;
    }

    try {
        const listing = await listAll(prefixConfig.bucket, prefix);
        await addFolderLastModifiedDates(prefixConfig.bucket, listing);
        const body = renderIndex(uri, prefix, listing, requestInfo.publicHost);

        return {
            status: "200",
            statusDescription: "OK",
            headers: {
                "content-type": [
                    { key: "Content-Type", value: "text/html; charset=utf-8" }
                ],
                "cache-control": [
                    { key: "Cache-Control", value: "public, max-age=30" }
                ],
                "access-control-allow-origin": [
                    { key: "Access-Control-Allow-Origin", value: "*" }
                ],
                "access-control-allow-methods": [
                    { key: "Access-Control-Allow-Methods", value: "GET, HEAD, OPTIONS" }
                ],
                "access-control-allow-headers": [
                    { key: "Access-Control-Allow-Headers", value: "*" }
                ],
                "x-myanything-source": [
                    { key: "X-Myanything-Source", value: "lambda-edge-directory-browser" }
                ]
            },
            body
        };
    } catch (err) {
        return {
            status: "500",
            statusDescription: "Internal Server Error",
            headers: {
                "content-type": [
                    { key: "Content-Type", value: "text/plain; charset=utf-8" }
                ],
                "cache-control": [
                    { key: "Cache-Control", value: "no-store" }
                ],
                "access-control-allow-origin": [
                    { key: "Access-Control-Allow-Origin", value: "*" }
                ]
            },
            body: "Directory listing failed: " + formatError(err)
        };
    }
};

function getRequestInfo(request) {
    const hostHeader = request.headers && request.headers.host && request.headers.host[0];
    const host = hostHeader && hostHeader.value
        ? hostHeader.value.toLowerCase()
        : "";

    const originDomain = getOriginDomainName(request).toLowerCase();
    const distributionDomain = getHeaderValue(request, "x-forwarded-host").toLowerCase();

    // In an origin-request Lambda@Edge event, the Host header may be the public
    // viewer host or it may already be the origin host. Keep multiple clues.
    return {
        publicHost: host,
        host,
        originDomain,
        distributionDomain,
        allText: [
            host,
            originDomain,
            distributionDomain
        ].join(" ").toLowerCase()
    };
}

function getOriginDomainName(request) {
    if (!request || !request.origin) {
        return "";
    }

    if (request.origin.s3 && request.origin.s3.domainName) {
        return request.origin.s3.domainName;
    }

    if (request.origin.custom && request.origin.custom.domainName) {
        return request.origin.custom.domainName;
    }

    return "";
}

function getHeaderValue(request, headerName) {
    const key = String(headerName).toLowerCase();
    const header = request.headers && request.headers[key] && request.headers[key][0];

    if (!header || !header.value) {
        return "";
    }

    return header.value;
}


function requestMatchesConfig(config, requestInfo) {
    const allText = requestInfo.allText || "";

    // Strongly identify 8k.press first so /beta/ on 8k.press can never fall
    // through and display the 8k.art beta bucket.
    const looksLikePress = allText.indexOf("8k.press") !== -1 ||
        allText.indexOf("www.8k.press") !== -1;

    const looksLikeArt = allText.indexOf("8k.art") !== -1 ||
        allText.indexOf("www.8k.art") !== -1;

    if (looksLikePress) {
        return config.site === "8k-press";
    }

    if (looksLikeArt) {
        return config.site === "8k-art";
    }

    // Conservative fallback:
    // If CloudFront provides no useful host/origin clue, only allow 8k.art rules.
    // This restores /beta/ on 8k.art-style distributions without making /beta/
    // browsable on clearly identified 8k.press requests.
    return config.site === "8k-art";
}

async function getSlashlessDirectoryRedirect(requestInfo, uri, querystring) {
    // This function is called only for slashless URLs.
    // It must never be called for /beta/ or /images/.
    if (uri.endsWith("/")) {
        return null;
    }

    const slashlessPath = uri.replace(/^\/+/, "");

    const matchedConfig = getPrefixConfigForSlashlessPath(requestInfo, slashlessPath);

    if (!matchedConfig) {
        return null;
    }

    const configSlashless = matchedConfig.prefix.replace(/\/$/, "");

    // Top-level configured folders are always known directories.
    if (slashlessPath === configSlashless) {
        return makeSlashRedirect(uri, querystring);
    }

    // Nested slashless paths are redirected only if S3 confirms there is
    // content under the folder prefix.
    const folderPrefix = slashlessPath + "/";
    const exists = await s3PrefixHasChildren(matchedConfig.bucket, folderPrefix);

    if (!exists) {
        return null;
    }

    return makeSlashRedirect(uri, querystring);
}

function getPrefixConfigForSlashlessPath(requestInfo, slashlessPath) {
    return BROWSABLE_PREFIX_CONFIGS.find((config) => {
        if (!requestMatchesConfig(config, requestInfo)) {
            return false;
        }

        const configSlashless = config.prefix.replace(/\/$/, "");

        return slashlessPath === configSlashless || slashlessPath.startsWith(config.prefix);
    });
}

async function s3PrefixHasChildren(bucket, prefix) {
    const result = await s3.send(new ListObjectsV2Command({
        Bucket: bucket,
        Prefix: prefix,
        MaxKeys: 1
    }));

    return Boolean(
        (result.Contents && result.Contents.length > 0) ||
        (result.CommonPrefixes && result.CommonPrefixes.length > 0)
    );
}

function makeSlashRedirect(uri, querystring) {
    const location = uri + "/" + (querystring ? "?" + querystring : "");

    return {
        status: "302",
        statusDescription: "Found",
        headers: {
            "location": [
                { key: "Location", value: location }
            ],
            "cache-control": [
                { key: "Cache-Control", value: "no-store, max-age=0" }
            ],
            "content-type": [
                { key: "Content-Type", value: "text/plain; charset=utf-8" }
            ]
        },
        body: "Found: " + location + "\n"
    };
}

function getPrefixConfig(requestInfo, prefix) {
    return BROWSABLE_PREFIX_CONFIGS.find((config) => {
        return requestMatchesConfig(config, requestInfo) &&
            (prefix === config.prefix || prefix.startsWith(config.prefix));
    });
}

async function listAll(bucket, prefix) {
    const dirs = [];
    const files = [];
    let ContinuationToken = undefined;

    do {
        const result = await s3.send(new ListObjectsV2Command({
            Bucket: bucket,
            Prefix: prefix,
            Delimiter: "/",
            MaxKeys: 1000,
            ContinuationToken
        }));

        for (const p of result.CommonPrefixes || []) {
            if (p.Prefix) {
                dirs.push(p.Prefix);
            }
        }

        for (const o of result.Contents || []) {
            if (o.Key && o.Key !== prefix) {
                files.push(o);
            }
        }

        ContinuationToken = result.IsTruncated
            ? result.NextContinuationToken
            : undefined;
    } while (ContinuationToken);

    return { dirs, files };
}

async function addFolderLastModifiedDates(bucket, listing) {
    if (!SHOW_FOLDER_LAST_MODIFIED || !listing || !Array.isArray(listing.dirs)) {
        return;
    }

    listing.folderInfo = {};

    await runWithConcurrency(
        listing.dirs,
        FOLDER_LAST_MODIFIED_CONCURRENCY,
        async (dirPrefix) => {
            const summary = await getFolderLastModifiedSummary(bucket, dirPrefix);
            listing.folderInfo[dirPrefix] = summary;
        }
    );
}

async function getFolderLastModifiedSummary(bucket, dirPrefix) {
    let newestDate = null;
    let scannedKeys = 0;
    let ContinuationToken = undefined;

    do {
        const commandInput = {
            Bucket: bucket,
            Prefix: dirPrefix,
            MaxKeys: Math.min(1000, Math.max(1, MAX_FOLDER_LAST_MODIFIED_KEYS - scannedKeys)),
            ContinuationToken
        };

        if (!FOLDER_LAST_MODIFIED_RECURSIVE) {
            commandInput.Delimiter = "/";
        }

        const result = await s3.send(new ListObjectsV2Command(commandInput));

        for (const obj of result.Contents || []) {
            if (!obj.Key || obj.Key === dirPrefix) {
                continue;
            }

            scannedKeys += 1;

            if (obj.LastModified) {
                const modifiedDate = new Date(obj.LastModified);

                if (!newestDate || modifiedDate.getTime() > newestDate.getTime()) {
                    newestDate = modifiedDate;
                }
            }

            if (scannedKeys >= MAX_FOLDER_LAST_MODIFIED_KEYS) {
                break;
            }
        }

        if (scannedKeys >= MAX_FOLDER_LAST_MODIFIED_KEYS) {
            break;
        }

        ContinuationToken = result.IsTruncated
            ? result.NextContinuationToken
            : undefined;
    } while (ContinuationToken);

    return {
        newestDate,
        scannedKeys
    };
}

async function runWithConcurrency(items, concurrency, worker) {
    const queue = Array.isArray(items) ? items.slice() : [];
    const workerCount = Math.max(1, Math.min(Number(concurrency) || 1, queue.length || 1));

    async function runWorker() {
        while (queue.length > 0) {
            const item = queue.shift();
            await worker(item);
        }
    }

    const workers = [];

    for (let i = 0; i < workerCount; i += 1) {
        workers.push(runWorker());
    }

    await Promise.all(workers);
}


function renderIndex(uri, prefix, listing, host) {
    const parentRow = apacheRow({
        href: "../",
        name: "Parent Directory",
        icon: "↰",
        modified: "",
        modifiedEpoch: 0,
        size: "-",
        sizeBytes: -1,
        type: "parent"
    });

    const dirRows = listing.dirs
        .sort((a, b) => a.localeCompare(b))
        .map((dirPrefix) => {
            const name = dirPrefix.slice(prefix.length).replace(/\/$/, "");

            const folderInfo = listing.folderInfo && listing.folderInfo[dirPrefix]
                ? listing.folderInfo[dirPrefix]
                : null;

            const folderModifiedDate = folderInfo && folderInfo.newestDate
                ? folderInfo.newestDate
                : null;

            return apacheRow({
                href: encodePathSegment(name) + "/",
                name: name + "/",
                icon: "📁",
                modified: folderModifiedDate
                    ? formatApacheDate(folderModifiedDate)
                    : "",
                modifiedEpoch: folderModifiedDate
                    ? folderModifiedDate.getTime()
                    : 0,
                size: "-",
                sizeBytes: -1,
                type: "dir"
            });
        })
        .join("\n");

    const fileRows = listing.files
        .sort((a, b) => a.Key.localeCompare(b.Key))
        .map((obj) => {
            const name = obj.Key.slice(prefix.length);

            // With Delimiter "/", this should already only be direct children,
            // but this keeps nested files from appearing twice.
            if (!name || name.includes("/")) {
                return "";
            }

            const modifiedDate = obj.LastModified ? new Date(obj.LastModified) : null;
            const sizeBytes = obj.Size || 0;

            return apacheRow({
                href: encodePathSegment(name),
                name,
                icon: getFileIcon(name),
                modified: modifiedDate ? formatApacheDate(modifiedDate) : "",
                modifiedEpoch: modifiedDate ? modifiedDate.getTime() : 0,
                size: formatApacheSize(sizeBytes),
                sizeBytes,
                type: "file"
            });
        })
        .filter(Boolean)
        .join("\n");

    const canonicalHost = getCanonicalHost(host);
    const canonicalUrl = canonicalHost ? `https://${canonicalHost}${uri}` : uri;
    const pageTitle = canonicalHost
        ? `${canonicalHost}${uri} - Directory Index`
        : `Index of ${uri}`;
    const pageDescription = "Live public directory index of open source files, documentation, beta builds, images, and downloads hosted on Amazon S3 and delivered through CloudFront.";

    return `<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>${escapeHtml(pageTitle)}</title>
<meta name="robots" content="index, follow">
<meta name="description" content="${escapeHtml(pageDescription)}">
<link rel="canonical" href="${escapeHtml(canonicalUrl)}">
<style>
html {
    -webkit-text-size-adjust: 100%;
}

body {
    background-color: #fff;
    color: #000;
    font-family: serif;
    margin: 0.65em 1em 1em 1em;
}

h1 {
    font-size: 2em;
    margin: 0.15em 0 0.35em 0;
}

.intro {
    max-width: none;
    width: min(1500px, 96vw);
    margin: 0 0 0.55em 0;
    font-family: sans-serif;
    font-size: 1em;
    line-height: 1.3;
}

.scroll-instructions {
    max-width: none;
    width: min(1500px, 96vw);
    margin: 0 0 0.25em 0;
    color: #555;
    font-family: sans-serif;
    font-size: 0.85em;
}

.scroll-top {
    width: 100%;
    overflow-x: scroll;
    overflow-y: hidden;
    height: 16px;
    margin: 0 0 0.15em 0;
    -webkit-overflow-scrolling: touch;
    scrollbar-gutter: stable;
}

.scroll-top-inner {
    height: 1px;
}

.listing-wrap {
    overflow-x: scroll;
    overflow-y: visible;
    width: 100%;
    -webkit-overflow-scrolling: touch;
    scrollbar-gutter: stable;
    padding-bottom: 0.65em;
}

.listing-wrap.no-horizontal-scroll {
    overflow-x: visible;
    padding-bottom: 0;
}

table {
    border-collapse: separate;
    border-spacing: 0 0.08em;
    table-layout: auto;
    width: max-content;
    min-width: 900px;
    font-family: monospace;
    font-size: 1em;
    line-height: 1.35;
}

thead th {
    text-align: left;
    font-weight: bold;
    border-bottom: 1px solid #999;
    padding: 0 1.5em 0.25em 0;
}

tbody td {
    padding: 0.04em 1.5em 0.04em 0;
    vertical-align: middle;
    white-space: nowrap;
}

td.name {
    width: auto;
    max-width: none;
}

.icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.15em;
    min-width: 1.15em;
    margin-right: 0.14em;
    text-align: center;
    font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla", "Segoe UI Symbol", sans-serif;
    font-size: 1.05em;
    line-height: 1;
    vertical-align: -0.08em;
}

td.modified {
    text-align: left;
}

td.size {
    text-align: left;
}

.sort-button {
    appearance: none;
    background: none;
    border: 0;
    color: #0000ee;
    cursor: pointer;
    font: inherit;
    font-weight: bold;
    padding: 0;
    text-decoration: underline;
}

.sort-button:visited {
    color: #551a8b;
}

.sort-button:hover,
.sort-button:focus {
    text-decoration: underline;
}

.sort-marker {
    color: #000;
    font-weight: normal;
    text-decoration: none;
}

a {
    color: #0000ee;
    text-decoration: underline;
}

a:visited {
    color: #551a8b;
}

.footer {
    margin-top: 1.2em;
    color: #555;
    font-family: sans-serif;
    font-size: 0.9em;
}

@media (pointer: coarse) {
    table {
        line-height: 1.42;
    }

    tbody td {
        padding-top: 0.07em;
        padding-bottom: 0.07em;
    }

    .icon {
        margin-right: 0.14em;
    }
}
</style>
</head>
<body>
<h1>Index of ${escapeHtml(uri)}</h1>

<p class="intro">${escapeHtml(pageDescription)}</p>
<p class="scroll-instructions">Tip: drag sideways to see Last modified and Size when filenames are very long.</p>

<div class="scroll-top" id="directory-scroll-top" aria-hidden="true">
    <div class="scroll-top-inner" id="directory-scroll-top-inner"></div>
</div>

<div class="listing-wrap" id="directory-scroll-main">
<table id="directory-listing">
<thead>
<tr>
<th><button type="button" class="sort-button" data-sort="name">Name <span class="sort-marker" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort="modified">Last modified <span class="sort-marker" aria-hidden="true"></span></button></th>
<th><button type="button" class="sort-button" data-sort="size">Size <span class="sort-marker" aria-hidden="true"></span></button></th>
</tr>
</thead>
<tbody>
${parentRow}
${dirRows}
${fileRows}
</tbody>
</table>
</div>

<div class="footer">Generated automatically from Amazon S3 via CloudFront Lambda@Edge.</div>

<script>
(function () {
    var table = document.getElementById("directory-listing");
    if (!table || !table.tBodies || !table.tBodies[0]) return;

    var tbody = table.tBodies[0];
    var buttons = Array.prototype.slice.call(table.querySelectorAll(".sort-button"));
    var state = {
        key: "name",
        order: "asc"
    };

    function valueFor(row, key) {
        if (key === "modified") return Number(row.getAttribute("data-modified") || "0");
        if (key === "size") return Number(row.getAttribute("data-size") || "-1");
        return (row.getAttribute("data-name") || "").toLowerCase();
    }

    function compareRows(a, b) {
        var typeA = a.getAttribute("data-type") || "file";
        var typeB = b.getAttribute("data-type") || "file";

        // Keep directories grouped before files, Apache-style.
        if (typeA !== typeB) {
            if (typeA === "dir") return -1;
            if (typeB === "dir") return 1;
        }

        var valueA = valueFor(a, state.key);
        var valueB = valueFor(b, state.key);
        var result = 0;

        if (typeof valueA === "string" || typeof valueB === "string") {
            result = String(valueA).localeCompare(String(valueB));
        } else {
            result = valueA - valueB;
        }

        return state.order === "asc" ? result : -result;
    }

    function updateMarkers() {
        buttons.forEach(function (button) {
            var marker = button.querySelector(".sort-marker");
            if (!marker) return;

            if (button.getAttribute("data-sort") === state.key) {
                marker.textContent = state.order === "asc" ? "▲" : "▼";
            } else {
                marker.textContent = "";
            }
        });
    }

    function sortTable(key) {
        if (state.key === key) {
            state.order = state.order === "asc" ? "desc" : "asc";
        } else {
            state.key = key;
            state.order = key === "modified" || key === "size" ? "desc" : "asc";
        }

        var parentRow = tbody.querySelector("tr.parent-row");
        var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr:not(.parent-row)"));

        rows.sort(compareRows);

        if (parentRow) {
            tbody.appendChild(parentRow);
        }

        rows.forEach(function (row) {
            tbody.appendChild(row);
        });

        updateMarkers();
        syncScrollWidths();
    }

    buttons.forEach(function (button) {
        button.addEventListener("click", function () {
            sortTable(button.getAttribute("data-sort"));
        });
    });

    function syncScrollWidths() {
        var main = document.getElementById("directory-scroll-main");
        var top = document.getElementById("directory-scroll-top");
        var topInner = document.getElementById("directory-scroll-top-inner");

        if (!main || !top || !topInner || !table) return;

        topInner.style.width = table.scrollWidth + "px";

        if (table.scrollWidth <= main.clientWidth + 2) {
            top.style.display = "none";
            main.classList.add("no-horizontal-scroll");
        } else {
            top.style.display = "block";
            main.classList.remove("no-horizontal-scroll");
        }
    }

    function syncScrollPositions(source, target) {
        if (!source || !target) return;
        target.scrollLeft = source.scrollLeft;
    }

    var mainScroll = document.getElementById("directory-scroll-main");
    var topScroll = document.getElementById("directory-scroll-top");

    if (mainScroll && topScroll) {
        mainScroll.addEventListener("scroll", function () {
            syncScrollPositions(mainScroll, topScroll);
        });

        topScroll.addEventListener("scroll", function () {
            syncScrollPositions(topScroll, mainScroll);
        });
    }

    window.addEventListener("resize", syncScrollWidths);

    updateMarkers();
    syncScrollWidths();
    setTimeout(syncScrollWidths, 250);
    setTimeout(syncScrollWidths, 1000);
}());
</script>
</body>
</html>`;
}

function getCanonicalHost(host) {
    const lowerHost = String(host || "").toLowerCase();

    if (!lowerHost) {
        return "";
    }

    if (ENABLE_CANONICAL_URL_RULES) {
        const rule = CANONICAL_HOST_RULES.find((candidate) => {
            return (candidate.hosts || []).some((ruleHost) => {
                return lowerHost === String(ruleHost).toLowerCase();
            });
        });

        if (rule && rule.canonicalHost) {
            return String(rule.canonicalHost).toLowerCase();
        }
    }

    // Safe default for users who have not configured explicit rules.
    if (STRIP_LEADING_WWW_FOR_UNKNOWN_HOSTS && lowerHost.startsWith("www.")) {
        return lowerHost.substring(4);
    }

    return lowerHost;
}

function apacheRow(item) {
    const rowClass = item.type === "parent" ? "parent-row" : "";
    const dataType = item.type || "file";
    const dataName = item.name || "";
    const dataModified = Number.isFinite(item.modifiedEpoch) ? item.modifiedEpoch : 0;
    const dataSize = Number.isFinite(item.sizeBytes) ? item.sizeBytes : -1;
    const icon = item.icon || (dataType === "dir" ? "📁" : "📄");

    return `<tr class="${escapeHtml(rowClass)}" data-type="${escapeHtml(dataType)}" data-name="${escapeHtml(dataName)}" data-modified="${escapeHtml(dataModified)}" data-size="${escapeHtml(dataSize)}">
<td class="name"><a href="${escapeHtml(item.href)}"><span class="icon" aria-hidden="true">${escapeHtml(icon)}</span>${escapeHtml(item.name)}</a></td>
<td class="modified">${escapeHtml(item.modified)}</td>
<td class="size">${escapeHtml(item.size)}</td>
</tr>`;
}

function escapeHtml(value) {
    return String(value)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;");
}

function encodePathSegment(value) {
    return encodeURIComponent(value).replace(/%2F/g, "/");
}

function getFileIcon(name) {
    const lower = String(name).toLowerCase();

    if (
        lower.endsWith(".png") ||
        lower.endsWith(".jpg") ||
        lower.endsWith(".jpeg") ||
        lower.endsWith(".gif") ||
        lower.endsWith(".webp") ||
        lower.endsWith(".svg") ||
        lower.endsWith(".ico") ||
        lower.endsWith(".avif")
    ) {
        return "🖼️";
    }

    if (
        lower.endsWith(".mp4") ||
        lower.endsWith(".webm") ||
        lower.endsWith(".mov") ||
        lower.endsWith(".mkv") ||
        lower.endsWith(".avi")
    ) {
        return "🎬";
    }

    if (
        lower.endsWith(".mp3") ||
        lower.endsWith(".wav") ||
        lower.endsWith(".ogg") ||
        lower.endsWith(".flac") ||
        lower.endsWith(".m4a")
    ) {
        return "🎵";
    }

    if (
        lower.endsWith(".zip") ||
        lower.endsWith(".tar") ||
        lower.endsWith(".gz") ||
        lower.endsWith(".tgz") ||
        lower.endsWith(".7z") ||
        lower.endsWith(".rar")
    ) {
        return "📦";
    }

    if (
        lower.endsWith(".md") ||
        lower.endsWith(".markdown") ||
        lower.endsWith(".txt") ||
        lower.endsWith(".log")
    ) {
        return "📝";
    }

    if (
        lower.endsWith(".html") ||
        lower.endsWith(".htm") ||
        lower.endsWith(".css") ||
        lower.endsWith(".js") ||
        lower.endsWith(".mjs") ||
        lower.endsWith(".json") ||
        lower.endsWith(".xml") ||
        lower.endsWith(".php")
    ) {
        return "🌐";
    }

    if (lower.endsWith(".pdf")) {
        return "📕";
    }

    if (
        lower.endsWith(".csv") ||
        lower.endsWith(".tsv") ||
        lower.endsWith(".xls") ||
        lower.endsWith(".xlsx") ||
        lower.endsWith(".ods")
    ) {
        return "📊";
    }

    if (
        lower.endsWith(".ppt") ||
        lower.endsWith(".pptx") ||
        lower.endsWith(".odp")
    ) {
        return "📽️";
    }

    return "📄";
}

function formatApacheDate(value) {
    return formatDirectoryListingDate(value);
}

function formatDirectoryListingDate(value) {
    const d = new Date(value);

    const formatter = new Intl.DateTimeFormat(DIRECTORY_LISTING_LOCALE, {
        timeZone: DIRECTORY_LISTING_TIME_ZONE,
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: true,
        timeZoneName: "short"
    });

    const parts = formatter.formatToParts(d);
    const lookup = {};

    for (const part of parts) {
        if (part.type !== "literal") {
            lookup[part.type] = part.value;
        }
    }

    const year = lookup.year || "0000";
    const month = lookup.month || "00";
    const day = lookup.day || "00";
    const hour = lookup.hour || "00";
    const minute = lookup.minute || "00";
    const second = lookup.second || "00";
    const dayPeriod = lookup.dayPeriod || "";
    const zoneName = lookup.timeZoneName || DIRECTORY_LISTING_TIME_ZONE;

    return `${year}-${month}-${day} ${hour}:${minute}:${second} ${dayPeriod} ${zoneName}`.trim();
}

function formatApacheSize(bytes) {
    if (bytes < 1024) {
        return String(bytes);
    }

    if (bytes < 1024 * 1024) {
        return Math.round(bytes / 1024) + "K";
    }

    if (bytes < 1024 * 1024 * 1024) {
        return Math.round(bytes / 1024 / 1024) + "M";
    }

    return Math.round(bytes / 1024 / 1024 / 1024) + "G";
}

function formatError(err) {
    if (!err) {
        return "Unknown error";
    }

    if (err.name && err.message) {
        return `${err.name}: ${err.message}`;
    }

    if (err.message) {
        return err.message;
    }

    return String(err);
}