// 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.

${parentRow} ${dirRows} ${fileRows}
${directoryDebugHtml}\n `; } 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 ` ${escapeHtml(item.name)} ${escapeHtml(item.modified)} ${escapeHtml(item.size)} `; } 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 `
${escapeHtml(lines.join("\n"))}
`; } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } 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); }