diff --git a/package-lock.json b/package-lock.json index fef9421..68d5d3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "svrjs-build", "version": "0.0.0", + "dependencies": { + "mime-types": "^2.1.35" + }, "devDependencies": { "@eslint/js": "^9.9.0", "archiver": "^7.0.1", @@ -4710,6 +4713,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", diff --git a/package.json b/package.json index d1516c5..51e78f9 100644 --- a/package.json +++ b/package.json @@ -23,5 +23,8 @@ "globals": "^15.9.0", "jest": "^29.7.0", "prettier": "^3.3.3" + }, + "dependencies": { + "mime-types": "^2.1.35" } } diff --git a/src/index.js b/src/index.js index e101548..f506259 100644 --- a/src/index.js +++ b/src/index.js @@ -211,7 +211,8 @@ let middleware = [ // TODO: SVR.JS mods go here // TODO: default handler require("./middleware/defaultHandlerChecks.js"), - require("./middleware/status.js") + require("./middleware/status.js"), + require("./middleware/staticFileServingAndDirectoryListings.js") ]; function addMiddleware(mw) { diff --git a/src/middleware/staticFileServingAndDirectoryListings.js b/src/middleware/staticFileServingAndDirectoryListings.js new file mode 100644 index 0000000..b7da8bd --- /dev/null +++ b/src/middleware/staticFileServingAndDirectoryListings.js @@ -0,0 +1,992 @@ +const http = require("http"); +const fs = require("fs"); +const os = require("os"); +const zlib = require("zlib"); +const mime = require("mime-types"); +const matchHostname = require("../utils/matchHostname.js"); +const ipMatch = require("../utils/ipMatch.js"); +const createRegex = require("../utils/createRegex.js"); +const sha256 = require("../utils/sha256.js"); +const sizify = require("../utils/sizify.js"); + +// ETag-related +let ETagDB = {}; + +const generateETag = (filePath, stat) => { + if (!ETagDB[filePath + "-" + stat.size + "-" + stat.mtime]) + ETagDB[filePath + "-" + stat.size + "-" + stat.mtime] = sha256( + filePath + "-" + stat.size + "-" + stat.mtime, + ); + return ETagDB[filePath + "-" + stat.size + "-" + stat.mtime]; +}; + +module.exports = (req, res, logFacilities, config, next) => { + const checkPathLevel = (path) => { + // Split the path into an array of components based on "/" + const pathComponents = path.split("/"); + + // Initialize counters for level up (..) and level down (.) + let levelUpCount = 0; + let levelDownCount = 0; + + // Loop through the path components + for (var i = 0; i < pathComponents.length; i++) { + // If the component is "..", decrement the levelUpCount + if (".." === pathComponents[i]) { + levelUpCount--; + } + // If the component is not "." or an empty string, increment the levelDownCount + else if ("." !== pathComponents[i] && "" !== pathComponents[i]) { + levelDownCount++; + } + } + + // Calculate the overall level by subtracting levelUpCount from levelDownCount + const overallLevel = levelDownCount - levelUpCount; + + // Return the overall level + return overallLevel; + }; + + const checkForEnabledDirectoryListing = (hostname, localAddress) => { + const main = + config.enableDirectoryListing || + config.enableDirectoryListing === undefined; + if (!config.enableDirectoryListingVHost) return main; + let vhostP = null; + config.enableDirectoryListingVHost.every(function (vhost) { + if ( + matchHostname(vhost.host, hostname) && + ipMatch(vhost.ip, localAddress) + ) { + vhostP = vhost; + return false; + } else { + return true; + } + }); + if (!vhostP || vhostP.enabled === undefined) return main; + else return vhostP.enabled; + }; + + let href = req.parsedURL.pathname; + let origHref = req.originalParsedURL.pathname; + let ext = href.match(/[^\/]\.([^.]+)$/); + if (!ext) ext = ""; + let dHref = ""; + try { + dHref = decodeURIComponent(href); + } catch (err) { + res.error(400); + return; + } + let readFrom = "." + dHref; + let dirImagesMissing = false; + fs.stat(readFrom, (err, stats) => { + if (err) { + if (err.code == "ENOENT") { + if ( + process.dirname != process.cwd() && + dHref.match(/^\/\.dirimages\/(?:(?!\.png$).)+\.png$/) + ) { + dirImagesMissing = true; + readFrom = process.dirname + dHref; + } else { + res.error(404); + logFacilities.errmessage("Resource not found."); + return; + } + } else if (err.code == "ENOTDIR") { + res.error(404); // Assume that file doesn't exist. + logFacilities.errmessage("Resource not found."); + return; + } else if (err.code == "EACCES") { + res.error(403); + logFacilities.errmessage("Access denied."); + return; + } else if (err.code == "ENAMETOOLONG") { + res.error(414); + return; + } else if (err.code == "EMFILE") { + res.error(503); + return; + } else if (err.code == "ELOOP") { + res.error(508); // The symbolic link loop is detected during file system operations. + logFacilities.errmessage("Symbolic link loop detected."); + return; + } else { + res.error(500, err); + return; + } + } + + const properDirectoryListingAndStaticFileServe = () => { + if (stats.isFile()) { + let acceptEncoding = req.headers["accept-encoding"]; + if (!acceptEncoding) acceptEncoding = ""; + + let filelen = stats.size; + + // ETag code + let fileETag = undefined; + if (config.enableETag == undefined || config.enableETag) { + fileETag = generateETag(href, stats); + // Check if the client's request matches the ETag value (If-None-Match) + const clientETag = req.headers["if-none-match"]; + if (clientETag === fileETag) { + res.writeHead(304, http.STATUS_CODES[304], { + ETag: clientETag, + }); + res.end(); + return; + } + + // Check if the client's request doesn't match the ETag value (If-Match) + const ifMatchETag = req.headers["if-match"]; + if (ifMatchETag && ifMatchETag !== "*" && ifMatchETag !== fileETag) { + res.error(412, { + ETag: clientETag, + }); + return; + } + } + + // Handle partial content request + if (req.headers["range"]) { + try { + let rhd = config.getCustomHeaders(); + rhd["Accept-Ranges"] = "bytes"; + rhd["Content-Range"] = "bytes */" + filelen; + const regexmatch = req.headers["range"].match( + /bytes=([0-9]*)-([0-9]*)/, + ); + if (!regexmatch) { + res.error(416, rhd); + } else { + // Process the partial content request + const beginOrig = regexmatch[1]; + const endOrig = regexmatch[2]; + const maxEnd = + filelen - + 1 + + (ext == "html" ? res.head.length + res.foot.length : 0); + let begin = 0; + let end = maxEnd; + if (beginOrig == "" && endOrig == "") { + res.error(416, rhd); + return; + } else if (beginOrig == "") { + begin = end - parseInt(endOrig) + 1; + } else { + begin = parseInt(beginOrig); + if (endOrig != "") end = parseInt(endOrig); + } + if (begin > end || begin < 0 || begin > maxEnd) { + res.error(416, rhd); + return; + } + if (end > maxEnd) end = maxEnd; + rhd["Content-Range"] = + "bytes " + begin + "-" + end + "/" + filelen; + rhd["Content-Length"] = end - begin + 1; + delete rhd["Content-Type"]; + const mtype = mime.contentType(ext); + if (mtype && ext != "") rhd["Content-Type"] = mtype; + if (fileETag) rhd["ETag"] = fileETag; + + if (req.method != "HEAD") { + if ( + ext == "html" && + begin < res.head.length && + end - begin < res.head.length + ) { + res.writeHead(206, http.STATUS_CODES[206], rhd); + res.end(res.head.substring(begin, end + 1)); + return; + } else if ( + ext == "html" && + begin >= res.head.length + filelen + ) { + res.writeHead(206, http.STATUS_CODES[206], rhd); + res.end( + res.foot.substring( + begin - res.head.length - filelen, + end - res.head.length - filelen + 1, + ), + ); + return; + } + let readStream = fs.createReadStream(readFrom, { + start: + ext == "html" + ? Math.max(0, begin - res.head.length) + : begin, + end: + ext == "html" + ? Math.min(filelen, end - res.head.length) + : end, + }); + readStream + .on("error", (err) => { + if (err.code == "ENOENT") { + res.error(404); + logFacilities.errmessage("Resource not found."); + } else if (err.code == "ENOTDIR") { + res.error(404); // Assume that file doesn't exist. + logFacilities.errmessage("Resource not found."); + } else if (err.code == "EACCES") { + res.error(403); + logFacilities.errmessage("Access denied."); + } else if (err.code == "ENAMETOOLONG") { + res.error(414); + } else if (err.code == "EMFILE") { + res.error(503); + } else if (err.code == "ELOOP") { + res.error(508); // The symbolic link loop is detected during file system operations. + logFacilities.errmessage("Symbolic link loop detected."); + } else { + res.error(500, err); + } + }) + .on("open", () => { + try { + if (ext == "html") { + const afterWriteCallback = () => { + if ( + res.foot.length > 0 && + end > res.head.length + filelen + ) { + readStream.on("end", () => { + res.end( + res.foot.substring( + 0, + end - res.head.length - filelen + 1, + ), + ); + }); + } + readStream.pipe(res, { + end: !( + res.foot.length > 0 && + end > res.head.length + filelen + ), + }); + }; + res.writeHead(206, http.STATUS_CODES[206], rhd); + if (res.head.length == 0 || begin > res.head.length) { + afterWriteCallback(); + } else if ( + !res.write( + res.head.substring(begin, res.head.length - begin), + ) + ) { + res.on("drain", afterWriteCallback); + } else { + process.nextTick(afterWriteCallback); + } + } else { + res.writeHead(206, http.STATUS_CODES[206], rhd); + readStream.pipe(res); + } + logFacilities.resmessage( + "Client successfully received content.", + ); + } catch (err) { + res.error(500, err); + } + }); + } else { + res.writeHead(206, http.STATUS_CODES[206], rhd); + res.end(); + } + } + } catch (err) { + res.error(500, err); + } + } else { + // Helper function to check if compression is allowed for the file + const canCompress = (path, list) => { + let canCompress = true; + for (var i = 0; i < list.length; i++) { + if (createRegex(list[i], true).test(path)) { + canCompress = false; + break; + } + } + return canCompress; + }; + + let useBrotli = + ext != "br" && + filelen > 256 && + zlib.createBrotliCompress && + acceptEncoding.match(/\bbr\b/); + let useDeflate = + ext != "zip" && + filelen > 256 && + acceptEncoding.match(/\bdeflate\b/); + let useGzip = + ext != "gz" && filelen > 256 && acceptEncoding.match(/\bgzip\b/); + + let isCompressable = true; + try { + // Check for files not to compressed and compression enabling setting. Also check for browser quirks and adjust compression accordingly + if ( + (!useBrotli && !useDeflate && !useGzip) || + config.enableCompression !== true || + !canCompress(href, config.dontCompress) + ) { + isCompressable = false; // Compression is disabled + } else if ( + ext != "html" && + ext != "htm" && + ext != "xhtml" && + ext != "xht" && + ext != "shtml" + ) { + if ( + /^Mozilla\/4\.[0-9]+(( *\[[^)]*\] *| *)\([^)\]]*\))? *$/.test( + req.headers["user-agent"], + ) && + !/https?:\/\/|[bB][oO][tT]|[sS][pP][iI][dD][eE][rR]|[sS][uU][rR][vV][eE][yY]|MSIE/.test( + req.headers["user-agent"], + ) + ) { + isCompressable = false; // Netscape 4.x doesn't handle compressed data properly outside of HTML documents. + } else if (/^w3m\/[^ ]*$/.test(req.headers["user-agent"])) { + isCompressable = false; // w3m doesn't handle compressed data properly outside of HTML documents. + } + } else { + if ( + /^Mozilla\/4\.0[6-8](( *\[[^)]*\] *| *)\([^)\]]*\))? *$/.test( + req.headers["user-agent"], + ) && + !/https?:\/\/|[bB][oO][tT]|[sS][pP][iI][dD][eE][rR]|[sS][uU][rR][vV][eE][yY]|MSIE/.test( + req.headers["user-agent"], + ) + ) { + isCompressable = false; // Netscape 4.06-4.08 doesn't handle compressed data properly. + } + } + } catch (err) { + res.error(500, err); + return; + } + + // Bun 1.1 has definition for zlib.createBrotliCompress, but throws an error while invoking the function. + if (process.isBun && useBrotli && isCompressable) { + try { + zlib.createBrotliCompress(); + } catch (err) { + useBrotli = false; + } + } + + try { + let hdhds = {}; + if (useBrotli && isCompressable) { + hdhds["Content-Encoding"] = "br"; + } else if (useDeflate && isCompressable) { + hdhds["Content-Encoding"] = "deflate"; + } else if (useGzip && isCompressable) { + hdhds["Content-Encoding"] = "gzip"; + } else { + if (ext == "html") { + hdhds["Content-Length"] = + res.head.length + filelen + res.foot.length; + } else { + hdhds["Content-Length"] = filelen; + } + } + hdhds["Accept-Ranges"] = "bytes"; + delete hdhds["Content-Type"]; + const mtype = mime.contentType(ext); + if (mtype && ext != "") hdhds["Content-Type"] = mtype; + if (fileETag) hdhds["ETag"] = fileETag; + + if (req.method != "HEAD") { + let readStream = fs.createReadStream(readFrom); + readStream + .on("error", (err) => { + if (err.code == "ENOENT") { + res.error(404); + logFacilities.errmessage("Resource not found."); + } else if (err.code == "ENOTDIR") { + res.error(404); // Assume that file doesn't exist. + logFacilities.errmessage("Resource not found."); + } else if (err.code == "EACCES") { + res.error(403); + logFacilities.errmessage("Access denied."); + } else if (err.code == "ENAMETOOLONG") { + res.error(414); + } else if (err.code == "EMFILE") { + res.error(503); + } else if (err.code == "ELOOP") { + res.error(508); // The symbolic link loop is detected during file system operations. + logFacilities.errmessage("Symbolic link loop detected."); + } else { + res.error(500, err); + } + }) + .on("open", () => { + try { + var resStream = {}; + if (useBrotli && isCompressable) { + resStream = zlib.createBrotliCompress(); + resStream.pipe(res); + } else if (useDeflate && isCompressable) { + resStream = zlib.createDeflateRaw(); + resStream.pipe(res); + } else if (useGzip && isCompressable) { + resStream = zlib.createGzip(); + resStream.pipe(res); + } else { + resStream = res; + } + if (ext == "html") { + const afterWriteCallback = () => { + if (res.foot.length > 0) { + readStream.on("end", () => { + resStream.end(res.foot); + }); + } + readStream.pipe(resStream, { + end: res.foot.length == 0, + }); + }; + res.writeHead(200, http.STATUS_CODES[200], hdhds); + if (res.head.length == 0) { + afterWriteCallback(); + } else if (!resStream.write(res.head)) { + resStream.on("drain", afterWriteCallback); + } else { + process.nextTick(afterWriteCallback); + } + } else { + res.writeHead(200, http.STATUS_CODES[200], hdhds); + readStream.pipe(resStream); + } + logFacilities.resmessage( + "Client successfully received content.", + ); + } catch (err) { + res.error(500, err); + } + }); + } else { + res.writeHead(200, http.STATUS_CODES[200], hdhds); + res.end(); + logFacilities.resmessage("Client successfully received content."); + } + } catch (err) { + res.error(500, err); + } + } + } else if (stats.isDirectory()) { + // Check if directory listing is enabled in the configuration + if ( + checkForEnabledDirectoryListing( + req.headers.host, + req.socket ? req.socket.localAddress : undefined, + ) + ) { + let customDirListingHeader = ""; + let customDirListingFooter = ""; + + const getCustomDirListingHeader = (callback) => { + fs.readFile( + ("." + dHref + "/.dirhead").replace(/\/+/g, "/"), + (err, data) => { + if (err) { + if (err.code == "ENOENT" || err.code == "EISDIR") { + if (os.platform != "win32" || href != "/") { + fs.readFile( + ("." + dHref + "/HEAD.html").replace(/\/+/g, "/"), + (err, data) => { + if (err) { + if (err.code == "ENOENT" || err.code == "EISDIR") { + callback(); + } else { + res.error(500, err); + } + } else { + customDirListingHeader = data.toString(); + callback(); + } + }, + ); + } else { + callback(); + } + } else { + res.error(500, err); + } + } else { + customDirListingHeader = data.toString(); + callback(); + } + }, + ); + }; + + const getCustomDirListingFooter = (callback) => { + fs.readFile( + ("." + dHref + "/.dirfoot").replace(/\/+/g, "/"), + (err, data) => { + if (err) { + if (err.code == "ENOENT" || err.code == "EISDIR") { + if (os.platform != "win32" || href != "/") { + fs.readFile( + ("." + dHref + "/FOOT.html").replace(/\/+/g, "/"), + (err, data) => { + if (err) { + if (err.code == "ENOENT" || err.code == "EISDIR") { + callback(); + } else { + res.error(500, err); + } + } else { + customDirListingFooter = data.toString(); + callback(); + } + }, + ); + } else { + callback(); + } + } else { + res.error(500, err); + } + } else { + customDirListingFooter = data.toString(); + callback(); + } + }, + ); + }; + + // Read custom header and footer content (if available) + getCustomDirListingHeader(() => { + getCustomDirListingFooter(() => { + // Check if custom header has HTML tag + const headerHasHTMLTag = customDirListingHeader + .replace(/|$)/g, "") + .match( + /])*(?:>|$)/i, + ); + + // Generate HTML head and footer based on configuration and custom content + let htmlHead = + (!config.enableDirectoryListingWithDefaultHead || res.head == "" + ? !headerHasHTMLTag + ? "
Filename | Size | Date | |
---|---|---|---|
Return |
" + + config + .generateServerString() + .replace(/&/g, "&") + .replace(//g, ">") + + (req.headers.host == undefined + ? "" + : " on " + + String(req.headers.host) + .replace(/&/g, "&") + .replace(//g, ">")) + + "
" + + customDirListingFooter + + (!config.enableDirectoryListingWithDefaultHead || res.foot == "" + ? "" + : res.foot); + + if ( + fs.existsSync( + "." + + decodeURIComponent(href) + + "/.maindesc".replace(/\/+/g, "/"), + ) + ) { + htmlFoot = + "