commit 287fe1d63e8afd4407f74907abc379681fe79ba8 Author: Alex Thomassen Date: Sun Oct 6 21:59:28 2024 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef3ae86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +* +!_autoindex +!_autoindex/* +_autoindex/_autoindex-config.js + +!README.md +!.gitignore \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b6127e --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# AutoIndex Plus for NGINX + +## Introduction + +AutoIndex Plus is an alternative to the default autoindex module of NGINX. It's intended to be a solution for those who want a different-looking type of directory listing, without having to deal with external software or XSLT stylesheets to achieve this. + +## Requirements + +- NGINX with the [`autoindex`](https://nginx.org/en/docs/http/ngx_http_autoindex_module.html) and [`addition`](https://nginx.org/en/docs/http/ngx_http_addition_module.html) modules included. + - From my experience, these are both included in the NGINX packages you get from `apt` (Debian and Ubuntu) and `yum` (Fedora, Alma etc.). Your mileage may vary. + +## Limitations + +- All of the logic for parsing and rendering the directory listing happens using frontend JavaScript. Browsers without JavaScript enabled will not be able to see the directory listing. + - For my personal use case, this is a perfectly acceptable limitation. I don't expect people to be browsing with JavaScript disabled. +- The directory where the autoindex files are located is expected to be under `/_autoindex` of the document root. + - I don't think there's any good way to get around this, besides either a rewrite rule in NGINX or by editing the HTML/JS files. + +## Theming + +> [!IMPORTANT] +> At the moment, the only way to change the theme is by editing the HTML files directly. +> This means that every time you update AutoIndex Plus, you will need to reapply your changes. + +AutoIndex Plus uses [Pico CSS](https://picocss.com/) for styling. If you wish to use a different theme, you can change the `` tag in the [`_header.html`](./_autoindex/_header.html) file. + +The [Pumpkin theme](https://picocss.com/docs/version-picker/pumpkin) is used by default. You can use the [Pico CSS version picker](https://picocss.com/docs/version-picker) to find a theme that you like, then replace the existing `` tag with the one in the "Usage from CDN" section. + +> [!TIP] +> If you know what you're doing, it's recommended to grab the corresponding theme from [jsDelivr](https://www.jsdelivr.com/package/npm/@picocss/pico?tab=files&path=css) and use [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) to ensure the file hasn't been tampered with when loading into your page. +> Find the corresponding `pico..min.css` file, click on the "Copy to clipboard" icon and select "Copy HTML + SRI". + +## Installation + +1. Clone this repository to your server. +2. Copy the `_autoindex` directory to the root of your website. +3. Add the following to your NGINX configuration file: + +```nginx +# Note that `/media` should be replaced with the directory you want to enable AutoIndex Plus on. +location ~ /media(.*)/$ { + autoindex on; + autoindex_format json; + addition_types application/json; + + add_before_body /_autoindex/_header.html; + add_after_body /_autoindex/_footer.html; + + add_header Content-Type "text/html; charset=utf-8"; +} +``` + +4. Reload NGINX + - For example: `sudo nginx -t && sudo nginx -s reload` diff --git a/_autoindex/_autoindex-config.sample.js b/_autoindex/_autoindex-config.sample.js new file mode 100644 index 0000000..c1f6081 --- /dev/null +++ b/_autoindex/_autoindex-config.sample.js @@ -0,0 +1,61 @@ +const AUTOINDEX_CONFIG = { + siteTitle: 'AutoIndex Plus', + + /** + * File extensions. The "last" file extension in the array is used as the icon. + * E.g. `.tar.gz` would result in `gz` being used. + */ + fileTypes: { + // Individual file types + pdf: ['pdf'], + xlsx: ['xlsx', 'xls'], + docx: ['docx', 'doc'], + pptx: ['pptx', 'ppt'], + csv: ['csv'], + code: ['json', 'xml', 'yaml', 'yml', 'ini', 'cfg', 'conf', 'html', 'htm'], + + // Grouped file types + archive: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'], + audio: ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'wma', 'aac', 'aiff', 'ape', 'alac'], + document: ['odt', 'ods', 'odp', 'txt', 'rtf'], + image: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tif', 'tiff', 'avif', 'heic', 'jxl'], + text: ['log', 'md', 'markdown', 'txt'], + video: ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv', 'mpg', 'mpeg', 'm4v'], + }, + + /** + * Font Awesome icons. `fa-solid` is the default style. + * @link https://fontawesome.com/search?o=r&s=solid + */ + fileIcons: { + pdf: 'fa-file-pdf', + xlsx: 'fa-file-excel', + docx: 'fa-file-word', + pptx: 'fa-file-powerpoint', + csv: 'fa-file-csv', + code: 'fa-file-code', + + archive: 'fa-file-zipper', + audio: 'fa-file-audio', + document: 'fa-file-lines', + image: 'fa-image', + // Same as document for now + text: 'fa-file-lines', + video: 'fa-video', + }, + + /** + * Locale used for date and time formatting. + * By default, the browser locale of the visitor is used. + * + * However you can use this option to force a specific one, such as `en-US` or `nb-NO`. + * Unless you have very specific reasons to not do so, I recommend leaving this to the default of `null`. + */ + dateTimeLocale: null, + + /** + * Show credits in the footer to the GitHub repository. + * Disabled by default, but enabling it would be one way to support the project :) + */ + showCredits: false, +}; diff --git a/_autoindex/_autoindex-functions.js b/_autoindex/_autoindex-functions.js new file mode 100644 index 0000000..8f541c1 --- /dev/null +++ b/_autoindex/_autoindex-functions.js @@ -0,0 +1,389 @@ +/** + * Format bytes into a human readable format + * + * @param {Number} bytes + * @param {Number} decimals + * @returns {String} + */ +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +/** + * Get the current URL path, so it can be displayed to the user. + * + * @returns {String} + */ +function parseIndexPath() +{ + const url = new URL(window.location.href); + const path = decodeURIComponent(url.pathname); + return path; +} + +/** + * Resolve a file's file extension to a Font Awesome icon + * + * @param {Object} config Config object + * @param {Object} file File object, as returned by the autoindex JSON + * @returns {String} Font Awesome icon class + */ +function resolveFileIcon(config, file) +{ + const defaultIcon = 'fa-file'; + if (!file.type) { + return defaultIcon; + } + + if (file.type === 'directory') { + return 'fa-folder-open'; + } + + const filename = file.name; + if (!filename.includes('.')) { + return defaultIcon; + } + + const fileIcons = config.fileIcons; + const ext = filename.split('.').pop().toLowerCase(); + + for (const type in config.fileTypes) { + if (config.fileTypes[type].includes(ext)) { + return fileIcons[type]; + } + } + + return defaultIcon; +} + +const AUTOINDEX_CONFIG_DEFAULTS = { + siteTitle: 'NGINX Autoindex', + fileTypes: { + // Individual file types + pdf: ['pdf'], + xlsx: ['xlsx', 'xls'], + docx: ['docx', 'doc'], + pptx: ['pptx', 'ppt'], + csv: ['csv'], + + // I'm sure I could continue forever with this + code: ['json', 'xml', 'yaml', 'yml', 'ini', 'cfg', 'conf', 'html', 'htm', 'sql'], + + // Grouped file types + archive: ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'], + audio: ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'wma', 'aac', 'aiff', 'ape', 'alac'], + document: ['odt', 'ods', 'odp', 'txt', 'rtf'], + image: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tif', 'tiff', 'avif', 'heic', 'jxl'], + text: ['log', 'md', 'markdown', 'txt'], + video: ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv', 'mpg', 'mpeg', 'm4v'], + }, + fileIcons: { + pdf: 'fa-file-pdf', + xlsx: 'fa-file-excel', + docx: 'fa-file-word', + pptx: 'fa-file-powerpoint', + csv: 'fa-file-csv', + code: 'fa-file-code', + + archive: 'fa-file-zipper', + audio: 'fa-file-audio', + document: 'fa-file-lines', + image: 'fa-image', + // Same as document for now + text: 'fa-file-lines', + video: 'fa-video', + }, + dateTimeLocale: null, + showCredits: false, +}; + +/** + * Helper function to merge the config objects + * Used recursively to merge nested objects + * + * @param {Object} defaultConfig + * @param {Object} userConfig + * @returns {Object} + */ +function mergeConfigs(defaultConfig, userConfig) +{ + const merged = {}; + for (const key in defaultConfig) { + const defaults = defaultConfig[key]; + const user = userConfig[key]; + + if (defaults === null || typeof defaults === 'undefined') { + merged[key] = user; + continue; + } + + if (Array.isArray(defaults)) { + merged[key] = [...defaults, ...user]; + // Remove duplicates + merged[key] = [...new Set(merged[key])]; + continue; + } + + if (typeof defaults === 'object') { + merged[key] = mergeConfigs(defaults, user); + continue; + } + + // Usually a string or number + merged[key] = user; + } + + return merged; +} + +const sortOrder = ['none', 'asc', 'desc']; + +const sortDirections = { + name: 'none', + size: 'none', + modified: 'none', +}; + +const sortIcons = { + none: 'fa-sort', + asc: 'fa-sort-up', + desc: 'fa-sort-down', +}; + +const sortAttribute = 'data-sort'; +let originalRows = []; + +/** + * Reset the sort directions for all other columns + * + * @param {String} currentSort + */ +function resetOtherSortDirections(currentSort) +{ + for (const key in sortDirections) { + if (key === currentSort) { + continue; + } + + sortDirections[key] = 'none'; + const header = document.querySelector(`#${key}-header`); + const icon = header.querySelector('i.sort-icon'); + + if (icon) { + icon.classList.remove('fa-sort-up', 'fa-sort-down'); + icon.classList.add('fa-sort'); + } + } +} + +/** + * Sort the table by the clicked header + * + * @param {Event} event + * @returns {void} + */ +function sortTable(event) +{ + let target = event.target; + console.log('Target', target); + + // Re-assign to the table header if it's one of the icons + if (target.tagName === 'I') { + target = target.parentElement; + } + + const sortDirectionKeys = Object.keys(sortDirections); + const originalId = target.id; + const id = originalId.replace('-header', ''); + + if (!sortDirectionKeys.includes(id)) { + return; + } + + const sortIndex = target.cellIndex; + const table = document.querySelector('#file-listing'); + let rows = Array.from(table.rows); + + // First time sorting + if (!originalRows.length) { + originalRows = [...rows]; + } + + const previousSort = sortDirections[id]; + const sortDirection = sortOrder[(sortOrder.indexOf(previousSort) + 1) % sortOrder.length]; + const isNumericSort = id === 'size' || id === 'modified'; + + if (sortDirection === 'none') { + rows = [... originalRows]; + } + else { + rows.sort((a, b) => { + let aSort = a.cells[sortIndex].getAttribute(sortAttribute); + let bSort = b.cells[sortIndex].getAttribute(sortAttribute); + + // Case insensitive sorting + if (!isNumericSort) { + aSort = aSort.toLowerCase(); + bSort = bSort.toLowerCase(); + } + + let first = aSort; + let second = bSort; + if (sortDirection === 'desc') { + first = bSort; + second = aSort; + } + + if (isNumericSort) { + return first - second; + } + + return first.localeCompare(second); + }); + } + + resetOtherSortDirections(id); + + const sortIcon = target.querySelector('i.sort-icon'); + if (sortIcon) { + sortIcon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down'); + sortIcon.classList.add(sortIcons[sortDirection]); + } + + // Update the sort direction + sortDirections[id] = sortDirection; + + table.innerHTML = ''; + for (const row of rows) { + table.appendChild(row); + } +} + +/** + * Setup the sorting event listeners + */ +function setupSorting() +{ + const headers = document.querySelectorAll('th'); + for (const header of headers) { + header.addEventListener('click', sortTable); + } +} + +/** + * Setup the table with the autoindex data + * + * @param {Object} autoindexData + * @param {Object} config + */ +function setupTable(autoindexData, config) +{ + const parentTable = document.querySelector('#main-table'); + parentTable.classList.remove('hidden'); + + const table = document.querySelector('#file-listing'); + + const locale = config.dateTimeLocale || navigator.language; + const localeOptions = { + dateStyle: 'full', + timeStyle: 'short', + }; + + for (const file of autoindexData) { + const row = document.createElement('tr'); + const name = document.createElement('td'); + const link = document.createElement('a'); + const filename = file.name; + + name.setAttribute(sortAttribute, filename); + + link.href = filename; + link.textContent = filename; + + /** + * Icon in the first table cell + */ + const icon = document.createElement('i'); + icon.className = 'fa-solid fa-fw'; + icon.classList.add(resolveFileIcon(config, file)); + icon.setAttribute('style', 'margin-right: 0.25rem;'); + + link.prepend(icon); + name.appendChild(link); + + // File size + const size = document.createElement('td'); + size.setAttribute(sortAttribute, file.size || 0); + size.textContent = ''; + const fileSize = file.size; + if (fileSize) { + size.textContent = formatBytes(file.size); + } + + // Last modified + const modified = document.createElement('td'); + const modifiedDate = new Date(file.mtime); + modified.setAttribute(sortAttribute, modifiedDate.getTime()); + modified.textContent = Intl.DateTimeFormat(locale, localeOptions).format(modifiedDate); + + // Append the cells to the row + row.appendChild(name); + row.appendChild(size); + row.appendChild(modified); + + // Append the row to the table + table.appendChild(row); + } +} + +function showCredits() +{ + const credits = document.querySelector('#author-credits'); + credits.classList.remove('hidden'); +} + +/** + * Initialize the autoindex page + */ +function autoIndexInit() +{ + // I mean... if this JS loaded, then JS is enabled. + const noscriptAlert = document.querySelector('#noscript-alert'); + if (noscriptAlert) { + noscriptAlert.remove(); + } + + const autoindexListing = document.querySelector('#autoindex-listing'); + let autoindexData = JSON.parse(autoindexListing.textContent); + // autoindexListing.remove(); + + let config = AUTOINDEX_CONFIG_DEFAULTS; + if (AUTOINDEX_CONFIG) { + config = mergeConfigs(AUTOINDEX_CONFIG_DEFAULTS, AUTOINDEX_CONFIG); + console.log('Resulting config', config); + } + + const title = document.querySelector('title'); + const heading = document.querySelector('#page-title'); + const path = document.querySelector('#index-path'); + const siteTitle = config.siteTitle || 'NGINX Autoindex'; + + heading.textContent = siteTitle; + path.textContent = parseIndexPath(); + title.textContent = `${siteTitle} - ${parseIndexPath()}`; + + setupTable(autoindexData, config); + setupSorting(); + + if (config.showCredits) { + showCredits(); + } +} diff --git a/_autoindex/_footer.html b/_autoindex/_footer.html new file mode 100644 index 0000000..5c865e7 --- /dev/null +++ b/_autoindex/_footer.html @@ -0,0 +1,3 @@ + + + diff --git a/_autoindex/_header.html b/_autoindex/_header.html new file mode 100644 index 0000000..fb97e72 --- /dev/null +++ b/_autoindex/_header.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + +
+
+

+

+
+
+
+ + + + + + + + + + +
+ + + + + + + +