// EDIT THIS FIRST: public debug detail level. Allowed: "off", "safe", "verbose".
const PUBLIC_DEBUG_DETAIL_LEVEL = "safe";

// =============================================================================
// HUMAN-EDITABLE CONFIGURATION NOTES
// =============================================================================
//
// PUBLIC_DEBUG_DETAIL_LEVEL controls the optional ?debug=1 panel.
//
//     "off"     = no visible debug panel, even with ?debug=1
//     "safe"    = public-safe educational diagnostics; open-source default
//     "verbose" = full operator diagnostics for site owners
//
// For your own 8k.art / 8k.press / DEFINE.COM deployments, change "safe" to
// "verbose". For shared/open-source default packages, keep it "safe".
//
// =============================================================================
// END HUMAN-EDITABLE CONFIGURATION NOTES
// =============================================================================

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

CHANGE NOTE:
    Moves PUBLIC_DEBUG_DETAIL_LEVEL to the first lines of the viewer source.

    The first real editable setting is now:
        */

/*
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/
    https://8k.press/beta/
    https://define.com/beta/

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: no-store, no-cache, max-age=0, s-maxage=0, must-revalidate

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, HeadObjectCommand, GetObjectCommand } 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",
        usePrebuiltDirectoryIndex: true
    },
    {
        site: "8k-art",
        hosts: ["8k.art", "www.8k.art"],
        originDomainContains: ["www.8k.art", "8k.art"],
        prefix: "_docs/",
        bucket: "www.8k.art",
        usePrebuiltDirectoryIndex: true
    },
    {
        site: "8k-press",
        hosts: ["8k.press", "www.8k.press"],
        originDomainContains: ["www.8k.press", "8k.press"],
        prefix: "images/",
        bucket: "www.8k.press",
        usePrebuiltDirectoryIndex: true
    },
    {
        site: "8k-press",
        hosts: ["8k.press", "www.8k.press"],
        originDomainContains: ["www.8k.press", "8k.press"],
        prefix: "beta/",
        bucket: "www.8k.press",
        usePrebuiltDirectoryIndex: true
    },
    {
        site: "define-com",
        hosts: ["define.com", "www.define.com"],
        originDomainContains: ["www.define.com", "define.com"],
        prefix: "beta/",
        bucket: "www.define.com",
        usePrebuiltDirectoryIndex: true
    }
];

/*
================================================================================
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"
        ]
    },
    {
        canonicalHost: "define.com",
        hosts: [
            "define.com",
            "www.define.com",
            "www.define.com.s3-website-us-east-1.amazonaws.com",
            "define.com.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 filesystem folders. However, many S3 programs create
tiny zero-byte "folder marker" objects whose names end with "/":

    beta/_daily-builds/
    beta/s3-cloudfront-indexer/

Those marker objects have normal S3 LastModified timestamps.

This directory browser can show folder Last modified dates by reading those
folder marker timestamps.

No folder-size calculation is performed.
No recursive scan is performed.
No file listing scan is performed.

Folder date behavior:

    In super-fast prebuilt index mode, folder dates come from .directory-index.json.
    That index is maintained by the S3 write/delete event updater Lambda.

    In live fallback mode, folder dates are left blank by default. This avoids
    viewer-time HeadObject metadata requests and keeps the live fallback simple.

    Result:
        indexed folders = fast folder dates
        live fallback   = fresh listing, no folder-date tax

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

const SHOW_FOLDER_LAST_MODIFIED = true;

/*
================================================================================
SPEED SETTINGS
================================================================================

These are safe beginner-facing performance settings.

DIRECTORY_HTML_CACHE_SECONDS:
    How long CloudFront and browsers may cache generated directory pages.

    Higher = faster repeat browsing and fewer Lambda/S3 calls.
    Lower  = new uploads appear sooner.

LAMBDA_MEMORY_CACHE_SECONDS:
    Short-lived cache inside warm Lambda containers.

    Live non-JSON speed note:
        A tiny value such as 3 seconds helps repeated navigation without making
        public listings feel stale. This is not browser cache and not CloudFront
        cache. It only helps when the same warm edge Lambda container receives
        nearby requests.

    This helps when the same edge container receives repeat requests. It is not
    permanent storage and it is not shared across all edge locations.

FOLDER_MARKER_MEMORY_CACHE_SECONDS:
    Short-lived cache for S3 folder marker timestamps.

LOAD_FOLDER_TIMESTAMPS_AFTER_PAGE_RENDER:
    true = show the directory immediately, then fill folder dates in after load.
    This keeps navigation fast even when folder timestamp metadata is enabled.

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

const DIRECTORY_HTML_CACHE_SECONDS = 0;
const DIRECTORY_HTML_SHARED_CACHE_SECONDS = 0;
const DIRECTORY_HTML_STALE_WHILE_REVALIDATE_SECONDS = 0;
const PREFETCH_FOLDER_PAGES_AFTER_RENDER = false;
const MAX_FOLDER_PAGES_TO_PREFETCH = 12;
const LAMBDA_MEMORY_CACHE_SECONDS = 3;
const FOLDER_MARKER_MEMORY_CACHE_SECONDS = 0;

const LISTING_MEMORY_CACHE = new Map();
const FOLDER_MARKER_MEMORY_CACHE = new Map();

const LOAD_FOLDER_TIMESTAMPS_AFTER_PAGE_RENDER = false;





/*
================================================================================
SUPER-FAST PREBUILT DIRECTORY INDEXES
================================================================================

This is the "best of both worlds" mode for high-traffic public sites such as:

    8k.art
    8k.press
    define.com

Instead of making viewers wait while Lambda discovers a directory at request time,
an S3 Event Lambda can maintain a tiny JSON file inside each folder:

    .directory-index.json

Example:

    beta/.directory-index.json
    beta/_images/.directory-index.json
    images/.directory-index.json

The viewer Lambda then needs one small GetObject request to render:

    file names
    file timestamps
    file sizes
    folder names
    folder timestamps

This is dramatically faster than scanning a folder and then making separate
folder-marker metadata requests.

Fallback:

    If .directory-index.json is missing, the browser falls back to live S3 listing
    so beginners can still deploy the simple version first.

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

const ENABLE_PREBUILT_DIRECTORY_INDEX = true;
const SHOW_DIRECTORY_SOURCE_FOOTER = false;
const DIRECTORY_INDEX_FILE_NAME = ".directory-index.json";
const DIRECTORY_BROWSER_VIEWER_VERSION = "v91";
const DIRECTORY_BROWSER_UPDATER_BASELINE_VERSION = "v67";

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

async function shouldRedirectNoSlashDirectory(bucket, prefixWithoutSlash) {
    const normalized = String(prefixWithoutSlash || "").replace(/^\/+/, "").replace(/\/+$/, "");

    if (!normalized) {
        return false;
    }

    const fileExists = await s3ObjectExists(bucket, normalized);

    if (fileExists) {
        return false;
    }

    return s3DirectoryPrefixExists(bucket, normalized + "/");
}

async function s3ObjectExists(bucket, key) {
    try {
        await s3.send(new HeadObjectCommand({
            Bucket: bucket,
            Key: key
        }));

        return true;
    } catch (err) {
        const status = err && err.$metadata && err.$metadata.httpStatusCode;

        if (status === 404 || status === 403 || err.name === "NotFound" || err.name === "NoSuchKey") {
            return false;
        }

        return false;
    }
}

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

        return Boolean((result.Contents && result.Contents.length) ||
            (result.CommonPrefixes && result.CommonPrefixes.length));
    } catch (err) {
        return false;
    }
}


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;
        }
    }

    // Directory URLs should be canonical with a trailing slash at any depth.
    // Examples:
    // /beta                  -> /beta/
    // /beta/_daily-builds    -> /beta/_daily-builds/
    // /_docs/en              -> /_docs/en/
    // /images/file.png       -> no redirect, real file
    if (!uri.endsWith("/")) {
        const noSlashPrefix = uri.replace(/^\/+/g, "");
        const slashPrefix = noSlashPrefix + "/";
        const slashPrefixConfig = getPrefixConfig(requestInfo, slashPrefix);

        if (
            slashPrefixConfig &&
            await shouldRedirectNoSlashDirectory(slashPrefixConfig.bucket, noSlashPrefix)
        ) {
            return makeSlashRedirect(uri, request.querystring || "");
        }

        return request;
    }

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

    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, prefixConfig);

        if (isFolderTimestampJsonRequest(request.querystring || "")) {
            await addFolderLastModifiedDates(prefixConfig.bucket, listing);

            return {
                status: "200",
                statusDescription: "OK",
                headers: noStoreHtmlHeaders(),
                body: renderFolderTimestampJson(prefix, listing)
            };
        }

        const body = renderIndex(uri, prefix, listing, requestInfo.publicHost, request.querystring || "");

        return {
            status: "200",
            statusDescription: "OK",
            headers: {
                "content-type": [
                    { key: "Content-Type", value: "text/html; charset=utf-8" }
                ],
                "cache-control": [
                    { key: "Cache-Control", value: `no-store, no-cache, max-age=0, s-maxage=0, must-revalidate` }
                ],
                "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;

    const looksLikeDefine = allText.indexOf("define.com") !== -1 ||
        allText.indexOf("www.define.com") !== -1;

    if (looksLikeDefine) {
        return config.site === "define-com";
    }

    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) {
    // Fast, extensionless-file-safe slashless directory redirect.
    //
    // Public links often omit trailing slashes:
    //   /beta
    //   /beta/_images
    //   /images
    //
    // Rule:
    //   1. Known browsable root folders redirect immediately.
    //   2. Nested slashless paths redirect only when the S3 folder marker object
    //      exists at path + "/".
    //
    // This protects extensionless files:
    //   /beta/README
    //   /beta/LICENSE
    //   /beta/Makefile
    if (uri.endsWith("/")) {
        return null;
    }

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

    if (!matchedConfig) {
        return null;
    }

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

    // Known public roots are definitely folders.
    if (slashlessPath === configSlashless) {
        return makeSlashRedirect(uri, querystring);
    }

    const folderMarkerPrefix = slashlessPath + "/";
    const markerSummary = await getFolderMarkerLastModifiedSummary(matchedConfig.bucket, folderMarkerPrefix);

    if (!markerSummary || !markerSummary.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);
    });
}


function noStoreHtmlHeaders(extraHeaders = {}) {
    return {
        "content-type": [{
            key: "Content-Type",
            value: "text/html; charset=utf-8"
        }],
        "cache-control": [{
            key: "Cache-Control",
            value: "no-store, no-cache, max-age=0, s-maxage=0, must-revalidate"
        }],
        "pragma": [{
            key: "Pragma",
            value: "no-cache"
        }],
        "expires": [{
            key: "Expires",
            value: "0"
        }],
        "x-directory-cache": [{
            key: "X-Directory-Cache",
            value: "no-store"
        }],
        ...extraHeaders
    };
}


function makeSlashRedirect(uri, querystring) {
    const safeUri = String(uri || "/");
    const location = safeUri + "/" + (querystring ? "?" + String(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 isFolderTimestampJsonRequest(querystring) {
    return String(querystring || "")
        .split("&")
        .some((part) => {
            return decodeURIComponent(part) === "directory-meta=folder-timestamps";
        });
}


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

async function fetchFreshS3Listing(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 commonPrefix of result.CommonPrefixes || []) {
            if (commonPrefix.Prefix) {
                dirs.push({
                    Prefix: commonPrefix.Prefix
                });
            }
        }

        for (const object of result.Contents || []) {
            if (!object.Key || object.Key === prefix || object.Key.endsWith("/") || object.Key.endsWith(DIRECTORY_INDEX_FILE_NAME)) {
                continue;
            }

            files.push({
                Key: object.Key,
                LastModified: object.LastModified || null,
                Size: Number(object.Size || 0)
            });
        }

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

    return {
        dirs,
        files,
        folderInfo: {},
        source: "live-s3-listing"
    };
}


function shouldTryPrebuiltDirectoryIndex(prefixConfig) {
    if (!ENABLE_PREBUILT_DIRECTORY_INDEX) {
        return false;
    }

    if (!prefixConfig) {
        return false;
    }

    // Default to true for browsable prefixes. This prevents JSON mode from
    // silently staying off just because a prefix config omitted the flag.
    return prefixConfig.usePrebuiltDirectoryIndex !== false;
}



async function listAll(bucket, prefix, prefixConfig) {
    const attemptedPrebuiltIndex = shouldTryPrebuiltDirectoryIndex(prefixConfig);
    const indexKey = prefix + DIRECTORY_INDEX_FILE_NAME;

    const cacheKey = [
        "listing",
        bucket,
        prefix,
        attemptedPrebuiltIndex ? "prebuilt-first" : "live-only"
    ].join("\n");

    const cachedListing = getMemoryCache(LISTING_MEMORY_CACHE, cacheKey);

    if (cachedListing) {
        cachedListing.debug = {
            ...(cachedListing.debug || {}),
            cache: "memory-hit"
        };

        return cachedListing;
    }

    let prebuiltResult = {
        listing: null,
        debug: {
            attempted: attemptedPrebuiltIndex,
            bucket,
            prefix,
            indexKey,
            outcome: attemptedPrebuiltIndex ? "not-attempted-yet" : "disabled-by-config",
            reason: attemptedPrebuiltIndex ? "" : "usePrebuiltDirectoryIndex is not enabled for this prefix"
        }
    };

    if (attemptedPrebuiltIndex) {
        prebuiltResult = await getPrebuiltDirectoryIndexResult(bucket, prefix);
    }

    if (prebuiltResult.listing) {
        prebuiltResult.listing.debug = {
            bucket,
            prefix,
            indexKey,
            source: "prebuilt-json-index",
            attemptedPrebuiltIndex: true,
            prebuiltIndex: prebuiltResult.debug,
            liveFallback: false,
            cache: "miss"
        };

        setMemoryCache(LISTING_MEMORY_CACHE, cacheKey, prebuiltResult.listing, LAMBDA_MEMORY_CACHE_SECONDS);
        return prebuiltResult.listing;
    }

    const liveListing = await fetchFreshS3Listing(bucket, prefix);
    liveListing.source = attemptedPrebuiltIndex
        ? "live-s3-fallback-after-missing-or-unreadable-index"
        : "live-s3-listing";
    liveListing.indexKey = indexKey;
    liveListing.attemptedPrebuiltIndex = attemptedPrebuiltIndex;
    liveListing.debug = {
        bucket,
        prefix,
        indexKey,
        source: "live-s3-listing",
        attemptedPrebuiltIndex,
        prebuiltIndex: prebuiltResult.debug,
        liveFallback: true,
        fallbackReason: attemptedPrebuiltIndex
            ? prebuiltResult.debug.reason || prebuiltResult.debug.outcome || "prebuilt index unavailable"
            : "prebuilt index disabled by config",
        cache: "miss"
    };

    setMemoryCache(LISTING_MEMORY_CACHE, cacheKey, liveListing, LAMBDA_MEMORY_CACHE_SECONDS);
    return liveListing;
}




async function getPrebuiltDirectoryIndexResult(bucket, prefix) {
    const key = prefix + DIRECTORY_INDEX_FILE_NAME;

    const debug = {
        attempted: true,
        bucket,
        prefix,
        indexKey: key,
        outcome: "started",
        reason: "",
        schema: "",
        generatedAt: "",
        folderCount: 0,
        fileCount: 0,
        totalSize: 0
    };

    try {
        const result = await s3.send(new GetObjectCommand({
            Bucket: bucket,
            Key: key
        }));

        debug.outcome = "get-object-ok";

        const raw = await streamToString(result.Body);
        debug.bytesRead = raw.length;

        const data = JSON.parse(raw);

        debug.schema = data.schema || "";
        debug.generatedAt = data.generatedAt || "";
        debug.folderCount = Array.isArray(data.folders) ? data.folders.length : 0;
        debug.fileCount = Array.isArray(data.files) ? data.files.length : 0;
        debug.totalSize = Number(data.totalSize ?? data.size ?? 0) || 0;

        const listing = listingFromDirectoryIndexJson(data, prefix);
        listing.indexKey = key;
        listing.attemptedPrebuiltIndex = true;
        listing.prebuiltIndexGeneratedAt = data.generatedAt || "";
        listing.prebuiltIndexSchema = data.schema || "";

        debug.outcome = "accepted";
        debug.reason = "prebuilt JSON index loaded and normalized";

        return {
            listing,
            debug
        };
    } catch (err) {
        debug.outcome = "unavailable";
        debug.reason = formatError(err);
        debug.errorName = err && err.name ? String(err.name) : "";
        debug.httpStatusCode = err && err.$metadata && err.$metadata.httpStatusCode
            ? err.$metadata.httpStatusCode
            : "";

        console.log("Prebuilt directory index unavailable:", key, formatError(err));

        return {
            listing: null,
            debug
        };
    }
}

async function getPrebuiltDirectoryIndexListing(bucket, prefix) {
    const result = await getPrebuiltDirectoryIndexResult(bucket, prefix);
    return result.listing;
}



function listingFromDirectoryIndexJson(data, prefix) {
    const rawFolders = Array.isArray(data.folders) ? data.folders : [];
    const rawFiles = Array.isArray(data.files) ? data.files : [];
    const dirs = [];
    const files = [];
    const folderInfo = {};

    for (const dir of rawFolders) {
        const dirPrefix = safePrefixText(normalizeDirectoryIndexFolderPrefix(dir, prefix));
        const name = normalizeDirectoryIndexFolderName(dir, prefix, dirPrefix);
        const modifiedValue = dir.lastModified || dir.LastModified || dir.modified || "";
        const sizeValue = Number(dir.totalSize ?? dir.size ?? dir.Size ?? dir.bytes ?? -1);

        if (!dirPrefix || !name) {
            continue;
        }

        folderInfo[safePrefixText(dirPrefix)] = {
            exists: Boolean(modifiedValue),
            newestDate: modifiedValue ? new Date(modifiedValue) : null,
            sizeBytes: Number.isFinite(sizeValue) && sizeValue >= 0 ? sizeValue : -1,
            source: "prebuilt-directory-index"
        };

        dirs.push({
            Prefix: safePrefixText(dirPrefix),
            LastModified: modifiedValue || "",
            Size: Number.isFinite(sizeValue) && sizeValue >= 0 ? sizeValue : -1
        });
    }

    for (const file of rawFiles) {
        const key = safeSortText(file.key || file.Key || (prefix + (file.name || file.Name || "")));
        const size = Number(file.size ?? file.Size ?? 0);
        const lastModified = file.lastModified || file.LastModified || file.modified || "";

        if (!key || key.endsWith("/")) {
            continue;
        }

        files.push({
            Key: key,
            LastModified: lastModified ? new Date(lastModified) : null,
            Size: Number.isFinite(size) ? size : 0
        });
    }

    return {
        dirs,
        files,
        folderInfo,
        totalSize: Number(data.totalSize ?? data.size ?? 0),
        lastModified: data.lastModified || "",
        source: "prebuilt-directory-index"
    };
}

function normalizeDirectoryIndexFolderPrefix(dir, parentPrefix) {
    const explicitPrefix = safeSortText(dir.prefix || dir.Prefix || dir.key || dir.Key || "");
    const name = safeSortText(dir.name || dir.Name || "");

    if (explicitPrefix) {
        return String(explicitPrefix).endsWith("/")
            ? String(explicitPrefix)
            : String(explicitPrefix) + "/";
    }

    if (!name) {
        return "";
    }

    return parentPrefix + String(name).replace(/^\/+/, "").replace(/\/+$/, "") + "/";
}

function normalizeDirectoryIndexFolderName(dir, parentPrefix, dirPrefix) {
    const name = safeSortText(dir.name || dir.Name || "");

    if (name) {
        return String(name).replace(/\/+$/, "");
    }

    if (!dirPrefix) {
        return "";
    }

    return dirPrefix
        .slice(parentPrefix.length)
        .replace(/\/+$/, "");
}


function getMemoryCache(cache, key) {
    const item = cache.get(key);

    if (!item) {
        return null;
    }

    if (Date.now() > item.expiresAt) {
        cache.delete(key);
        return null;
    }

    return item.value;
}

function setMemoryCache(cache, key, value, ttlSeconds) {
    const seconds = Number(ttlSeconds) || 0;

    if (seconds <= 0) {
        return;
    }

    cache.set(key, {
        value,
        expiresAt: Date.now() + seconds * 1000
    });
}


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

    listing.folderInfo = listing.folderInfo || {};

    await Promise.all(listing.dirs.map(async (dirPrefix) => {
        if (listing.folderInfo[dirPrefix] && listing.folderInfo[dirPrefix].newestDate) {
            return;
        }

        listing.folderInfo[dirPrefix] = await getFolderMarkerLastModifiedSummary(bucket, dirPrefix);
    }));
}

async function getFolderMarkerLastModifiedSummary(bucket, dirPrefix) {
    // S3 Browser-style folder timestamp:
    // use the LastModified value from the marker object whose key ends in "/".
    // No scanning. No recursion. No size calculation.
    const cacheKey = bucket + "\n" + dirPrefix;
    const cached = getMemoryCache(FOLDER_MARKER_MEMORY_CACHE, cacheKey);

    if (cached) {
        return cached;
    }

    try {
        const result = await s3.send(new HeadObjectCommand({
            Bucket: bucket,
            Key: dirPrefix
        }));

        const summary = {
            exists: true,
            newestDate: result.LastModified
                ? new Date(result.LastModified)
                : null,
            source: "folder-marker"
        };

        setMemoryCache(FOLDER_MARKER_MEMORY_CACHE, cacheKey, summary, FOLDER_MARKER_MEMORY_CACHE_SECONDS);
        return summary;
    } catch (err) {
        const summary = {
            exists: false,
            newestDate: null,
            source: "folder-marker-missing"
        };

        setMemoryCache(FOLDER_MARKER_MEMORY_CACHE, cacheKey, summary, FOLDER_MARKER_MEMORY_CACHE_SECONDS);
        return summary;
    }
}



function renderFolderTimestampJson(prefix, listing) {
    const folders = buildCanonicalDirectoryRows(listing, prefix)
        .map((row) => {
            return {
                href: row.href,
                modified: row.modified,
                modifiedEpoch: row.modifiedEpoch
            };
        });

    return JSON.stringify({
        folders
    });
}




function getDirectoryPrefixValue(dir) {
    if (typeof dir === "string") {
        return safePrefixText(dir);
    }

    if (!dir || typeof dir !== "object") {
        return "";
    }

    return safePrefixText(dir.Prefix || dir.prefix || dir.Key || dir.key || "");
}

function getDirectoryDisplayName(dir, prefix, dirPrefix) {
    if (dir && typeof dir === "object") {
        const explicitName = safeSortText(dir.name || dir.Name || "");

        if (explicitName) {
            return explicitName.replace(/\/+$/, "");
        }
    }

    const parentPrefix = safePrefixText(prefix);
    const safeDirPrefix = safePrefixText(dirPrefix);

    if (safeDirPrefix.startsWith(parentPrefix)) {
        return safeDirPrefix
            .slice(parentPrefix.length)
            .replace(/\/+$/, "");
    }

    return safeDirPrefix
        .split("/")
        .filter(Boolean)
        .pop() || "";
}

function getFolderInfoForPrefix(listing, dirPrefix) {
    if (!listing || !listing.folderInfo) {
        return null;
    }

    const safeDirPrefix = safePrefixText(dirPrefix);
    const withSlash = safeDirPrefix.endsWith("/")
        ? safeDirPrefix
        : safeDirPrefix + "/";

    return listing.folderInfo[safeDirPrefix] ||
        listing.folderInfo[withSlash] ||
        null;
}

function buildCanonicalDirectoryRows(listing, prefix) {
    return (listing.dirs || [])
        .map((dir) => {
            const dirPrefix = getDirectoryPrefixValue(dir);
            const name = getDirectoryDisplayName(dir, prefix, dirPrefix);

            if (!dirPrefix || !name || name.includes("/")) {
                return null;
            }

            const folderInfo = listing.source === "prebuilt-directory-index"
                ? getFolderInfoForPrefix(listing, dirPrefix)
                : null;

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

            const sizeBytes = folderInfo && Number.isFinite(folderInfo.sizeBytes) && folderInfo.sizeBytes >= 0
                ? folderInfo.sizeBytes
                : -1;

            return {
                href: encodePathSegment(name) + "/",
                name: name + "/",
                sortName: name,
                icon: "📁",
                modified: folderModifiedDate ? formatApacheDate(folderModifiedDate) : "",
                modifiedEpoch: folderModifiedDate ? folderModifiedDate.getTime() : 0,
                size: sizeBytes >= 0 ? formatApacheSize(sizeBytes) : "-",
                sizeTitle: sizeBytes >= 0 ? formatExactByteCount(sizeBytes) : "",
                sizeBytes,
                type: "dir"
            };
        })
        .filter(Boolean);
}

function buildCanonicalFileRows(listing, prefix) {
    const parentPrefix = safePrefixText(prefix);

    return (listing.files || [])
        .map((obj) => {
            const key = safePrefixText(obj && (obj.Key || obj.key || ""));
            const name = key.startsWith(parentPrefix)
                ? key.slice(parentPrefix.length)
                : key;

            if (!key || !name || name.includes("/")) {
                return null;
            }

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

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


function renderIndex(uri, prefix, listing, host, querystring = "") {
    const directoryMarker = directorySourceMarker(listing);
    const directoryDebugHtml = isDirectoryDebugRequest(querystring)
        ? renderDirectoryDebugPanel(listing, prefix, uri, host, querystring)
        : "";

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

    const dirRows = buildCanonicalDirectoryRows(listing, prefix)
        .sort((a, b) => compareSortText(a.sortName, b.sortName))
        .map((row) => apacheRow(row))
        .join("\n");

    const fileRows = buildCanonicalFileRows(listing, prefix)
        .sort((a, b) => compareSortText(a.sortName, b.sortName))
        .map((row) => apacheRow(row))
        .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">
<!-- directory-browser-viewer-version: ${DIRECTORY_BROWSER_VIEWER_VERSION} -->\n<!-- directory-browser-updater-baseline: ${DIRECTORY_BROWSER_UPDATER_BASELINE_VERSION} -->\n<!-- directory-source: ${directoryMarker} -->\n<!-- directory-index-attempted: ${String(Boolean(listing.attemptedPrebuiltIndex))} -->\n<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;
}


td.name a {
    display: inline-flex;
    align-items: center;
    min-height: 1.65em;
    padding: 0.08em 0.42em 0.08em 0;
    margin: -0.08em 0;
    cursor: pointer;
    text-underline-offset: 0.12em;
}

td.name a:hover,
td.name a:focus-visible {
    background: rgba(0, 0, 238, 0.06);
    outline: none;
}

td.name a:focus-visible {
    box-shadow: 0 0 0 2px rgba(0, 0, 238, 0.35);
    border-radius: 0.18em;
}

.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;
}


.loading-feedback {
    display: none;
    position: fixed;
    left: 0.75em;
    bottom: 0.75em;
    z-index: 9999;
    padding: 0.35em 0.55em;
    background: rgba(255, 255, 255, 0.95);
    border: 1px solid #999;
    border-radius: 0.25em;
    color: #333;
    font-family: sans-serif;
    font-size: 0.85em;
}
body.is-navigating .loading-feedback {
    display: block;
}


.size {
    text-align: right;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.01em;
}
.modified {
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.debug-panel {
    margin-top: 1rem;
    max-width: 980px;
    border: 1px solid #cbd5e1;
    border-radius: 8px;
    padding: .75rem;
    background: #f8fafc;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    white-space: pre-wrap;
}
.source-good {
    color: #166534;
}
.source-warn {
    color: #9a3412;
}
.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>

${directoryDebugHtml}\n<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 nameCollator = new Intl.Collator(undefined, {
        numeric: true,
        sensitivity: "base"
    });

    function compareText(a, b) {
        return nameCollator.compare(String(a || ""), String(b || ""));
    }
    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 = compareText(valueA, 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);
        });
    }

    function loadFolderTimestampsAfterPageRender() {
        if (${LOAD_FOLDER_TIMESTAMPS_AFTER_PAGE_RENDER ? "false" : "true"}) return;

        var folderRows = Array.prototype.slice.call(tbody.querySelectorAll('tr[data-type="dir"]'));

        if (folderRows.length === 0) {
            return;
        }

        var metaUrl = new URL(window.location.href);
        metaUrl.search = "";
        metaUrl.searchParams.set("directory-meta", "folder-timestamps");

        fetch(metaUrl.toString(), {
            cache: "force-cache"
        })
            .then(function (response) {
                if (!response.ok) {
                    throw new Error("Folder timestamp request failed");
                }

                return response.json();
            })
            .then(function (data) {
                var byHref = {};
                var folders = data && data.folders ? data.folders : [];

                folders.forEach(function (item) {
                    byHref[item.href] = item;
                });

                folderRows.forEach(function (row) {
                    var link = row.querySelector("td.name a");
                    var modifiedCell = row.querySelector("td.modified");

                    if (!link || !modifiedCell) {
                        return;
                    }

                    var item = byHref[link.getAttribute("href")];

                    if (!item || !item.modified) {
                        return;
                    }

                    modifiedCell.textContent = item.modified;
                    row.setAttribute("data-modified", String(item.modifiedEpoch || 0));
                });

                if (state.key === "modified") {
                    sortTable("modified");
                    sortTable("modified");
                }

                syncScrollWidths();
            })
            .catch(function () {
                // Keep the page fast and usable even if metadata is unavailable.
            });
    }

    function addInstantNavigationFeedback() {
        var links = Array.prototype.slice.call(tbody.querySelectorAll("td.name a"));

        links.forEach(function (link) {
            link.addEventListener("click", function () {
                document.body.classList.add("is-navigating");
            });
        });
    }

    function prefetchFolderPagesAfterRender() {
        if (!${PREFETCH_FOLDER_PAGES_AFTER_RENDER ? "true" : "false"}) {
            return;
        }

        if (!("requestIdleCallback" in window)) {
            setTimeout(runPrefetch, 800);
            return;
        }

        window.requestIdleCallback(runPrefetch, { timeout: 1500 });
    }

    function runPrefetch() {
        var folderLinks = Array.prototype.slice.call(tbody.querySelectorAll('tr[data-type="dir"] td.name a'))
            .slice(0, ${MAX_FOLDER_PAGES_TO_PREFETCH});

        folderLinks.forEach(function (link) {
            var href = link.getAttribute("href");

            if (!href || href === "../") {
                return;
            }

            try {
                var url = new URL(href, window.location.href);

                fetch(url.toString(), {
                    method: "GET",
                    cache: "force-cache",
                    credentials: "omit"
                }).catch(function () {
                    // Prefetch is an optimization only.
                });
            } catch (err) {
                // Ignore malformed URLs.
            }
        });
    }

    window.addEventListener("resize", syncScrollWidths);

    updateMarkers();
    syncScrollWidths();
    addInstantNavigationFeedback();
    loadFolderTimestampsAfterPageRender();
    prefetchFolderPagesAfterRender();
    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.sortName || 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" title="${escapeHtml(item.sizeTitle || item.size || "")}">${escapeHtml(item.size)}</td>
</tr>`;
}

function directorySourceMarker(listing) {
    return listing && listing.source === "prebuilt-directory-index"
        ? "prebuilt-json-index"
        : "live-s3-listing";
}


function formatDirectorySourceLabel(listing) {
    if (!listing || !listing.source) {
        return "unknown";
    }

    if (listing.source === "prebuilt-directory-index") {
        return ".directory-index.json";
    }

    if (listing.source === "live-s3-fallback-after-missing-or-unreadable-index") {
        return "live directory view";
    }

    return String(listing.source);
}


function isDirectoryDebugRequest(querystring) {
    if (PUBLIC_DEBUG_DETAIL_LEVEL === "off") {
        return false;
    }

    return String(querystring || "")
        .split("&")
        .some((part) => {
            const decoded = decodeURIComponent(part || "");
            return decoded === "debug=1" || decoded === "debug=safe" || decoded === "debug=verbose";
        });
}





function getRequestedDebugLevel(querystring) {
    const requestedVerbose = String(querystring || "")
        .split("&")
        .some((part) => decodeURIComponent(part || "") === "debug=verbose");

    if (PUBLIC_DEBUG_DETAIL_LEVEL === "verbose" && requestedVerbose) {
        return "verbose";
    }

    if (PUBLIC_DEBUG_DETAIL_LEVEL === "verbose" && isDirectoryDebugRequest(querystring)) {
        return "verbose";
    }

    if (PUBLIC_DEBUG_DETAIL_LEVEL === "safe" && isDirectoryDebugRequest(querystring)) {
        return "safe";
    }

    return "off";
}

function isVerboseDirectoryDebug(querystring) {
    return getRequestedDebugLevel(querystring) === "verbose";
}


function renderDirectoryDebugPanel(listing, prefix, uri = "", host = "", querystring = "") {
    const debugLevel = getRequestedDebugLevel(querystring);

    if (debugLevel === "off") {
        return "";
    }

    const source = listing && listing.source ? listing.source : "unknown";
    const dirs = listing && Array.isArray(listing.dirs) ? listing.dirs : [];
    const files = listing && Array.isArray(listing.files) ? listing.files : [];
    const folderInfo = listing && listing.folderInfo ? listing.folderInfo : {};
    const folderInfoCount = Object.keys(folderInfo).length;
    const foldersWithDates = Object.values(folderInfo).filter((info) => info && info.newestDate).length;
    const foldersWithSizes = Object.values(folderInfo).filter((info) => info && Number.isFinite(info.sizeBytes) && info.sizeBytes >= 0).length;
    const debug = listing && listing.debug ? listing.debug : {};
    const prebuilt = debug.prebuiltIndex || {};

    const jsonAccepted = source === "prebuilt-directory-index";
    const attempted = Boolean(debug.attemptedPrebuiltIndex || listing.attemptedPrebuiltIndex);
    const generalFallback = jsonAccepted
        ? "none"
        : attempted
            ? "prebuilt index unavailable; live listing used"
            : "prebuilt index disabled or not configured";

    const safeLines = [
        "Directory debug",
        "debug level: " + debugLevel,
        "viewer version: " + DIRECTORY_BROWSER_VIEWER_VERSION,
        "updater baseline: " + DIRECTORY_BROWSER_UPDATER_BASELINE_VERSION,
        "source mode: " + formatDirectorySourceLabel(listing),
        "source marker: " + directorySourceMarker(listing),
        "JSON attempted: " + attempted,
        "JSON accepted: " + jsonAccepted,
        "JSON outcome: " + (prebuilt.outcome || (jsonAccepted ? "accepted" : "fallback")),
        "general fallback: " + generalFallback,
        "schema: " + (prebuilt.schema || listing.prebuiltIndexSchema || ""),
        "generated at: " + (prebuilt.generatedAt || listing.prebuiltIndexGeneratedAt || ""),
        "folder rows: " + dirs.length,
        "file rows: " + files.length,
        "folder metadata rows: " + folderInfoCount,
        "folders with dates: " + foldersWithDates,
        "folders with sizes: " + foldersWithSizes,
        "cache: " + (debug.cache || "")
    ];

    const verboseLines = [
        "public host: " + (host || ""),
        "uri: " + (uri || ""),
        "bucket: " + (debug.bucket || ""),
        "prefix: " + (prefix || debug.prefix || ""),
        "index key: " + (debug.indexKey || listing.indexKey || prefix + DIRECTORY_INDEX_FILE_NAME),
        "prebuilt reason: " + (prebuilt.reason || ""),
        "prebuilt error name: " + (prebuilt.errorName || ""),
        "prebuilt http status: " + (prebuilt.httpStatusCode || ""),
        "prebuilt folders: " + (prebuilt.folderCount ?? ""),
        "prebuilt files: " + (prebuilt.fileCount ?? ""),
        "prebuilt bytes read: " + (prebuilt.bytesRead ?? ""),
        "live fallback: " + Boolean(debug.liveFallback),
        "fallback reason: " + (debug.fallbackReason || "")
    ];

    const lines = debugLevel === "verbose"
        ? safeLines.concat(["", "Verbose operator details"], verboseLines)
        : safeLines;

    const className = source === "prebuilt-directory-index"
        ? "debug-panel source-good"
        : "debug-panel source-warn";

    return `<div class="${className}">${escapeHtml(lines.join("\n"))}</div>`;
}






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) {
    const value = Number(bytes) || 0;

    if (value < 1024) {
        return value.toLocaleString("en-US") + " B";
    }

    const units = ["KB", "MB", "GB", "TB"];
    let scaled = value / 1024;
    let unitIndex = 0;

    while (scaled >= 1024 && unitIndex < units.length - 1) {
        scaled = scaled / 1024;
        unitIndex += 1;
    }

    return formatCompactDecimal(scaled) + " " + units[unitIndex];
}

function formatCompactDecimal(value) {
    if (value >= 100) {
        return value.toLocaleString("en-US", {
            maximumFractionDigits: 0
        });
    }

    if (value >= 10) {
        return value.toLocaleString("en-US", {
            minimumFractionDigits: 1,
            maximumFractionDigits: 1
        });
    }

    return value.toLocaleString("en-US", {
        minimumFractionDigits: 2,
        maximumFractionDigits: 2
    });
}

const DIRECTORY_NAME_COLLATOR = new Intl.Collator(
    undefined,
    {
        numeric: true,
        sensitivity: "base"
    }
);

function safePrefixText(value) {
    return safeSortText(value).replace(/^\/+/, "");
}


function safeSortText(value) {
    if (value === null || value === undefined) {
        return "";
    }

    return String(value);
}

function compareSortText(a, b) {
    return DIRECTORY_NAME_COLLATOR.compare(
        safeSortText(a),
        safeSortText(b)
    );
}


function formatExactByteCount(bytes) {
    const value = Number(bytes) || 0;
    const label = value === 1 ? "byte" : "bytes";

    return value.toLocaleString("en-US") + " " + label;
}

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);
}