/* ================================================================================ 8K ART S3 + CLOUDFRONT DIRECTORY BROWSER ================================================================================ Project file: https://8k.art/beta/s3-cloudfront-indexer/8k-art-directory-browser.txt Live example locations: https://8k.art/beta/ https://8k.art/_docs/ https://8k.press/images/ Created for: The 8K ART / 8K PRESS open source software publishing workflow. Shared for: Open source developers, AWS builders, web publishers, educators, documentation authors, software archivists, and communities such as Experts Exchange where practical infrastructure solutions are shared, studied, improved, and reused. -------------------------------------------------------------------------------- WHAT THIS CODE DOES -------------------------------------------------------------------------------- This is a Lambda@Edge directory browser for Amazon S3 content served through Amazon CloudFront. Amazon S3 does not provide Apache-style directory browsing for static website hosting the way a traditional Apache VPS can with mod_autoindex. If you upload a folder full of files to Amazon S3 and visit that folder URL through CloudFront, Amazon S3 will normally look for an index document, return an error, or show only whatever static index.html file you pre-generated. This function restores the workflow many developers love from Apache: Upload a file. Refresh the folder URL. See the file appear. No build step. No generated index.html files. No VPS. No Apache server. No cron job. No manual directory-page maintenance. The function runs at the CloudFront edge on an origin-request event. When a visitor requests an approved folder-style URL such as: /beta/ /beta/some-subfolder/ /_docs/ /images/ the function calls Amazon S3 ListObjectsV2 using the requested path as the S3 prefix. It then renders a simple Apache-like HTML directory listing containing: Name, with multilingual-friendly folder/document icons Last modified Size Parent Directory Requests for actual files, such as: /beta/example.zip /_docs/readme.md /images/photo.png are passed through normally to CloudFront and Amazon S3. -------------------------------------------------------------------------------- WHY THIS IS USEFUL -------------------------------------------------------------------------------- This is useful anywhere you want the convenience of Apache directory browsing without running a VPS or web server. Common open source uses include: 1. Public beta builds Upload new beta builds every few minutes and let testers immediately see the newest files. 2. Nightly builds Publish automated builds into dated folders without regenerating an index page after every build. 3. Documentation drops Upload docs, examples, screenshots, text files, and changelogs directly to Amazon S3 and let users browse them. 4. Source code previews Combine this with a CloudFront Function that forces .php, .md, .txt, .json, .xml, .css, and .js files to display inline as UTF-8 text. 5. Public project archives Host old releases, dependency bundles, firmware images, ZIP files, or research artifacts in a browsable archive. 6. Educational publishing Teachers, students, and technical writers can upload folder trees and make them immediately visible without learning static-site generators. 7. VPS replacement If the only reason you kept an Apache VPS was directory browsing, this pattern can replace that VPS with Amazon S3 + CloudFront + Lambda@Edge. 8. Lightweight release servers Useful for indie developers, small teams, and open source maintainers who want a simple public file drop that still benefits from CloudFront caching. 9. Transparent public work-in-progress folders Let users, contributors, testers, and collaborators see what is changing without requiring a CMS or repository browser. 10. Multi-site browsing This example can map multiple URL prefixes to multiple Amazon S3 buckets, allowing one Lambda@Edge function to support more than one public site. -------------------------------------------------------------------------------- HOW IT WORKS -------------------------------------------------------------------------------- The important configuration is BROWSABLE_PREFIX_CONFIGS. Each entry maps a public folder prefix to an Amazon S3 bucket: { prefix: "beta/", bucket: "www.8k.art" } That means: https://8k.art/beta/ will list objects under: s3://www.8k.art/beta/ This function intentionally allows only configured prefixes. That prevents the entire bucket from becoming browsable accidentally. For each approved prefix, the function uses: ListObjectsV2 Prefix: requested folder path Delimiter: "/" The delimiter makes Amazon S3 return folder-like CommonPrefixes, which lets the function show subfolders separately from files. -------------------------------------------------------------------------------- DEPLOYMENT NOTES -------------------------------------------------------------------------------- This is intended for Lambda@Edge, not a CloudFront Function. CloudFront Functions are excellent for tiny header edits, redirects, and URL rewrites, but they cannot call Amazon S3. Directory browsing requires an Amazon S3 ListObjectsV2 call, so this belongs in Lambda@Edge. Typical deployment: 1. Create a Lambda function in us-east-1. 2. Use Node.js with this index.mjs code. 3. Give the Lambda execution role s3:ListBucket permission for only the browsable prefixes. 4. Publish a numbered Lambda version. 5. Attach that numbered version to the CloudFront behavior as: Event type: Origin request Include body: No 6. Wait for CloudFront deployment. 7. Visit an approved folder URL. Important Lambda@Edge notes: - The function must be in us-east-1. - CloudFront must use a published numbered version, not $LATEST. - The execution role trust policy must allow both: lambda.amazonaws.com edgelambda.amazonaws.com - After changing code, publish a new version and update the CloudFront Lambda@Edge association to the new version ARN. -------------------------------------------------------------------------------- IAM POLICY EXAMPLE -------------------------------------------------------------------------------- Limit list access to only the folders you intend to make browsable: { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowList8kArtBrowsableFolders", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::www.8k.art", "Condition": { "StringLike": { "s3:prefix": [ "beta/", "beta/*", "_docs/", "_docs/*" ] } } }, { "Sid": "AllowList8kPressImagesFolder", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::www.8k.press", "Condition": { "StringLike": { "s3:prefix": [ "images/", "images/*" ] } } } ] } -------------------------------------------------------------------------------- OPTIONAL COMPANION CLOUDFRONT FUNCTION -------------------------------------------------------------------------------- A separate CloudFront Function can be used on viewer-response to normalize headers, enable CORS, and make source files display inline in Chrome. For example, you may want: .php -> text/plain; charset=utf-8 .md -> text/markdown; charset=utf-8 .txt -> text/plain; charset=utf-8 .json -> application/json; charset=utf-8 .xml -> application/xml; charset=utf-8 That companion function is separate from this Lambda@Edge directory browser. -------------------------------------------------------------------------------- CACHE BEHAVIOR -------------------------------------------------------------------------------- This function returns: Cache-Control: public, max-age=30 That means directory listings can be cached briefly at CloudFront. New uploads will normally appear after the cache expires, or immediately after an invalidation. For very active beta folders, 15 to 30 seconds is a good balance. For slow archives, you can increase the max-age. -------------------------------------------------------------------------------- SEARCH ENGINE / GOOGLE INDEXING NOTES -------------------------------------------------------------------------------- This version includes small SEO-friendly improvements for public directory pages: - Slashless folder URLs such as /beta, /_docs, and /images are redirected to their canonical trailing-slash versions. - 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 -------------------------------------------------------------------------------- SECURITY NOTES -------------------------------------------------------------------------------- Only make prefixes browsable if the contents are intended to be public. This function does not grant public read access by itself. It only generates a listing for approved prefixes. Your CloudFront/S3 origin setup still controls whether file objects can actually be fetched. Recommended practices: - Restrict s3:ListBucket to only the prefixes you want listed. - Do not include private prefixes in BROWSABLE_PREFIX_CONFIGS. - Use separate buckets or prefixes for public and private content. - Keep the function attached only to the CloudFront behaviors that need it. -------------------------------------------------------------------------------- CUSTOMIZATION IDEAS -------------------------------------------------------------------------------- You can easily modify this code to add: - File icons - Sorting links - Client-side sortable directory tables - File type labels - Human-readable descriptions - README injection at the top of a folder - Custom branding - Dark mode - JSON directory output for APIs - Markdown rendering - Download buttons - Checksums - Version badges - Release notes - Directory-level access rules - Different cache durations per prefix -------------------------------------------------------------------------------- WHY THIS MATTERS -------------------------------------------------------------------------------- For open source developers, friction matters. When publishing beta builds, documentation, sample files, screenshots, helper scripts, or experimental releases, the maintenance cost of repeatedly generating index.html files can become absurd. A traditional Apache server solved that problem with directory browsing, but keeping a VPS alive only for autoindex is unnecessary when Amazon S3 and CloudFront can do the heavy lifting. This file demonstrates a tiny, practical bridge between the old web and the modern cloud: Apache-style browsing convenience Amazon S3 storage durability CloudFront global delivery Lambda@Edge dynamic folder listings It is simple, useful, and intentionally boring infrastructure — the best kind. -------------------------------------------------------------------------------- PUBLIC COPY -------------------------------------------------------------------------------- The public copy of this code is intended to live here: https://8k.art/beta/s3-cloudfront-indexer/8k-art-directory-browser.txt If this helped you, adapt it, improve it, fork the idea, write about it, and use it to publish your own open source work with less tedium. ================================================================================ */ import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3"; const REGION = "us-east-1"; const BROWSABLE_PREFIX_CONFIGS = [ { prefix: "beta/", bucket: "www.8k.art" }, { prefix: "_docs/", bucket: "www.8k.art" }, { prefix: "images/", bucket: "www.8k.press" } ]; const s3 = new S3Client({ region: REGION }); export const handler = async (event) => { const request = event.Records[0].cf.request; const uri = decodeURIComponent(request.uri || "/"); const host = getHost(request); // Redirect slashless browsable folder URLs to their canonical trailing-slash URLs. // Example: /beta -> /beta/ const slashRedirect = getSlashRedirect(uri, request.querystring || ""); if (slashRedirect) { return slashRedirect; } // Only handle folder-style URLs. // Examples: // /beta/ yes // /beta/_daily-builds/ yes // /_docs/ yes // /images/ yes // /images/file.png no if (!uri.endsWith("/")) { return request; } const prefix = uri.replace(/^\/+/, ""); const prefixConfig = getPrefixConfig(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); const body = renderIndex(uri, prefix, listing, host); return { status: "200", statusDescription: "OK", headers: { "content-type": [ { key: "Content-Type", value: "text/html; charset=utf-8" } ], "cache-control": [ { key: "Cache-Control", value: "public, max-age=30" } ], "access-control-allow-origin": [ { key: "Access-Control-Allow-Origin", value: "*" } ], "access-control-allow-methods": [ { key: "Access-Control-Allow-Methods", value: "GET, HEAD, OPTIONS" } ], "access-control-allow-headers": [ { key: "Access-Control-Allow-Headers", value: "*" } ], "x-myanything-source": [ { key: "X-Myanything-Source", value: "lambda-edge-directory-browser" } ] }, body }; } catch (err) { return { status: "500", statusDescription: "Internal Server Error", headers: { "content-type": [ { key: "Content-Type", value: "text/plain; charset=utf-8" } ], "cache-control": [ { key: "Cache-Control", value: "no-store" } ], "access-control-allow-origin": [ { key: "Access-Control-Allow-Origin", value: "*" } ] }, body: "Directory listing failed: " + formatError(err) }; } }; function getHost(request) { const hostHeader = request.headers && request.headers.host && request.headers.host[0]; if (!hostHeader || !hostHeader.value) { return ""; } return hostHeader.value.toLowerCase(); } function getSlashRedirect(uri, querystring) { const slashlessPath = uri.replace(/^\/+/, ""); const matchedConfig = BROWSABLE_PREFIX_CONFIGS.find((config) => { const configSlashless = config.prefix.replace(/\/$/, ""); return slashlessPath === configSlashless; }); if (!matchedConfig) { return null; } const location = uri + "/" + (querystring ? "?" + querystring : ""); return { status: "301", statusDescription: "Moved Permanently", headers: { "location": [ { key: "Location", value: location } ], "cache-control": [ { key: "Cache-Control", value: "public, max-age=3600" } ], "content-type": [ { key: "Content-Type", value: "text/plain; charset=utf-8" } ] }, body: "Moved permanently to " + location + "\n" }; } function getPrefixConfig(prefix) { return BROWSABLE_PREFIX_CONFIGS.find((config) => { return prefix === config.prefix || prefix.startsWith(config.prefix); }); } async function listAll(bucket, prefix) { const dirs = []; const files = []; let ContinuationToken = undefined; do { const result = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: "/", MaxKeys: 1000, ContinuationToken })); for (const p of result.CommonPrefixes || []) { if (p.Prefix) { dirs.push(p.Prefix); } } for (const o of result.Contents || []) { if (o.Key && o.Key !== prefix) { files.push(o); } } ContinuationToken = result.IsTruncated ? result.NextContinuationToken : undefined; } while (ContinuationToken); return { dirs, files }; } function renderIndex(uri, prefix, listing, host) { const parentRow = apacheRow({ href: "../", name: "Parent Directory", icon: "↰", modified: "", modifiedEpoch: 0, size: "-", sizeBytes: -1, type: "parent" }); const dirRows = listing.dirs .sort((a, b) => a.localeCompare(b)) .map((dirPrefix) => { const name = dirPrefix.slice(prefix.length).replace(/\/$/, ""); return apacheRow({ href: encodePathSegment(name) + "/", name: name + "/", icon: "📁", modified: "", modifiedEpoch: 0, size: "-", sizeBytes: -1, type: "dir" }); }) .join("\n"); const fileRows = listing.files .sort((a, b) => a.Key.localeCompare(b.Key)) .map((obj) => { const name = obj.Key.slice(prefix.length); // With Delimiter "/", this should already only be direct children, // but this keeps nested files from appearing twice. if (!name || name.includes("/")) { return ""; } const modifiedDate = obj.LastModified ? new Date(obj.LastModified) : null; const sizeBytes = obj.Size || 0; return apacheRow({ href: encodePathSegment(name), name, icon: "📄", modified: modifiedDate ? formatApacheDate(modifiedDate) : "", modifiedEpoch: modifiedDate ? modifiedDate.getTime() : 0, size: formatApacheSize(sizeBytes), sizeBytes, type: "file" }); }) .filter(Boolean) .join("\n"); const canonicalUrl = host ? `https://${host}${uri}` : 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 ` Index of ${escapeHtml(uri)}

Index of ${escapeHtml(uri)}

${escapeHtml(pageDescription)}

${parentRow} ${dirRows} ${fileRows}
`; } function apacheRow(item) { const rowClass = item.type === "parent" ? "parent-row" : ""; const dataType = item.type || "file"; const dataName = item.name || ""; const dataModified = Number.isFinite(item.modifiedEpoch) ? item.modifiedEpoch : 0; const dataSize = Number.isFinite(item.sizeBytes) ? item.sizeBytes : -1; const icon = item.icon || (dataType === "dir" ? "📁" : "📄"); return ` ${escapeHtml(item.name)} ${escapeHtml(item.modified)} ${escapeHtml(item.size)} `; } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function encodePathSegment(value) { return encodeURIComponent(value).replace(/%2F/g, "/"); } function formatApacheDate(value) { const d = new Date(value); const year = d.getUTCFullYear(); const month = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); const hour = String(d.getUTCHours()).padStart(2, "0"); const minute = String(d.getUTCMinutes()).padStart(2, "0"); return `${year}-${month}-${day} ${hour}:${minute}`; } function formatApacheSize(bytes) { if (bytes < 1024) { return String(bytes); } if (bytes < 1024 * 1024) { return Math.round(bytes / 1024) + "K"; } if (bytes < 1024 * 1024 * 1024) { return Math.round(bytes / 1024 / 1024) + "M"; } return Math.round(bytes / 1024 / 1024 / 1024) + "G"; } function formatError(err) { if (!err) { return "Unknown error"; } if (err.name && err.message) { return `${err.name}: ${err.message}`; } if (err.message) { return err.message; } return String(err); }