// 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-v88.zip
Top folder: s3-cloudfront-indexer-html-package-v88
Package version: v88
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:
canonical-host/path/ - Directory Index
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 = "v88";
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 `
\n\n\n\n
${escapeHtml(pageTitle)}
Index of ${escapeHtml(uri)}
${escapeHtml(pageDescription)}
Tip: drag sideways to see Last modified and Size when filenames are very long.