diff --git a/esbuild.config.js b/esbuild.config.js index b0e0f7a..1b82481 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -119,7 +119,7 @@ esbuild.build({ bundle: true, outfile: "dist/svr.js", // Output file platform: "node", - target: "es2020", + target: "es2017", plugins: [ esbuildCopyPlugin.copy({ resolveFrom: __dirname, diff --git a/eslint.config.mjs b/eslint.config.mjs index fd22806..26fd9c1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,5 +26,5 @@ export default [ } }, pluginJs.configs.recommended, - eslintPluginPrettierRecommended, + eslintPluginPrettierRecommended ]; diff --git a/src/index.js b/src/index.js index 763b09a..20f7d69 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,244 @@ -var http = require("http"); -http.createServer((req, res) => { - res.end("Hello World!"); -}).listen(3000); +const http = require("http"); +const fs = require("fs"); +const cluster = require("./utils/clusterBunShim.js"); // Cluster module with shim for Bun +const sanitizeURL = require("./utils/urlSanitizer.js"); +const generateErrorStack = require("./utils/generateErrorStack.js"); +const serverHTTPErrorDescs = require("./res/httpErrorDescriptions.js"); +const getOS = require("./utils/getOS.js"); +const svrjsInfo = require("../svrjs.json"); +const version = svrjsInfo.version; +//const parseURL = require("./utils/urlParser.js"); +//const fixNodeMojibakeURL = require("./utils/urlMojibakeFixer.js"); + +// Create log, mods and temp directories, if they don't exist. +if (!fs.existsSync(__dirname + "/log")) fs.mkdirSync(__dirname + "/log"); +if (!fs.existsSync(__dirname + "/mods")) fs.mkdirSync(__dirname + "/mods"); +if (!fs.existsSync(__dirname + "/temp")) fs.mkdirSync(__dirname + "/temp"); + +const serverconsoleConstructor = require("./utils/serverconsole.js"); + +let config = {}; + +// TODO: configuration from config.json +let page404 = "404.html" +let errorPages = []; +let stackHidden = true; +let exposeServerVersion = false; +let exposeModsInErrorPages = false; +let enableLogging = true; +let serverAdmin = "webmaster@svrjs.org"; +function getCustomHeaders() { + return {}; +} + +const serverconsole = serverconsoleConstructor(enableLogging); + +function requestHandler(req, res) { + // TODO: variables from SVR.JS 3.x + let isProxy = false; + + let reqIdInt = Math.floor(Math.random() * 16777216); + if (reqIdInt == 16777216) reqIdInt = 0; + const reqId = "0".repeat(6 - reqIdInt.toString(16).length) + reqIdInt.toString(16); + + // SVR.JS log facilities + const logFacilities = { + climessage: (msg) => serverconsole.climessage(msg, reqId), + reqmessage: (msg) => serverconsole.reqmessage(msg, reqId), + resmessage: (msg) => serverconsole.resmessage(msg, reqId), + errmessage: (msg) => serverconsole.errmessage(msg, reqId), + locerrmessage: (msg) => serverconsole.locerrmessage(msg, reqId), + locwarnmessage: (msg) => serverconsole.locwarnmessage(msg, reqId), + locmessage: (msg) => serverconsole.locmessage(msg, reqId) + }; + + // Server error calling method + res.error = (errorCode, extName, stack, ch) => { + if (typeof errorCode !== "number") { + throw new TypeError("HTTP error code parameter needs to be an integer."); + } + + // Handle optional parameters + if (extName && typeof extName === "object") { + ch = stack; + stack = extName; + extName = undefined; + } else if (typeof extName !== "string" && extName !== null && extName !== undefined) { + throw new TypeError("Extension name parameter needs to be a string."); + } + + if (stack && typeof stack === "object" && Object.prototype.toString.call(stack) !== "[object Error]") { + ch = stack; + stack = undefined; + } else if (typeof stack !== "object" && typeof stack !== "string" && stack) { + throw new TypeError("Error stack parameter needs to be either a string or an instance of Error object."); + } + + // Determine error file + function getErrorFileName(list, callback, _i) { + + function medCallback(p) { + if (p) callback(p); + else { + if (errorCode == 404) { + fs.access(page404, fs.constants.F_OK, function (err) { + if (err) { + fs.access("." + errorCode.toString(), fs.constants.F_OK, function (err) { + try { + if (err) { + callback(errorCode.toString() + ".html"); + } else { + callback("." + errorCode.toString()); + } + } catch (err2) { + res.error(500, err2); + } + }); + } else { + try { + callback(page404); + } catch (err2) { + res.error(500, err2); + } + } + }); + } else { + fs.access("." + errorCode.toString(), fs.constants.F_OK, function (err) { + try { + if (err) { + callback(errorCode.toString() + ".html"); + } else { + callback("." + errorCode.toString()); + } + } catch (err2) { + res.error(500, err2); + } + }); + } + } + } + + if (!_i) _i = 0; + if (_i >= list.length) { + medCallback(false); + return; + } + + if (list[_i].scode != errorCode || !(matchHostname(list[_i].host) && ipMatch(list[_i].ip, req.socket ? req.socket.localAddress : undefined))) { + getErrorFileName(list, callback, _i + 1); + return; + } else { + fs.access(list[_i].path, fs.constants.F_OK, function (err) { + if (err) { + getErrorFileName(list, callback, _i + 1); + } else { + medCallback(list[_i].path); + } + }); + } + } + + getErrorFileName(errorPages, function (errorFile) { + // Generate error stack if not provided + if (Object.prototype.toString.call(stack) === "[object Error]") stack = generateErrorStack(stack); + if (stack === undefined) stack = generateErrorStack(new Error("Unknown error")); + + if (errorCode == 500 || errorCode == 502) { + //TODO: server logging facilities + logFacilities.errmessage("There was an error while processing the request!"); + logFacilities.errmessage("Stack:"); + logFacilities.errmessage(stack); + } + + // Hide the error stack if specified + if (stackHidden) stack = "[error stack hidden]"; + + // Validate the error code and handle unknown codes + if (serverHTTPErrorDescs[errorCode] === undefined) { + res.error(501, extName, stack); + } else { + // Process custom headers if provided + let cheaders = { ...getCustomHeaders(), ...ch }; + + cheaders["Content-Type"] = "text/html; charset=utf-8"; + + // Set default Allow header for 405 error if not provided + if (errorCode == 405 && !cheaders["Allow"]) cheaders["Allow"] = "GET, POST, HEAD, OPTIONS"; + + // Read the error file and replace placeholders with error information + fs.readFile(errorFile, function (err, data) { + try { + if (err) throw err; + res.writeHead(errorCode, http.STATUS_CODES[errorCode], cheaders); + responseEnd(data.toString().replace(/{errorMessage}/g, errorCode.toString() + " " + http.STATUS_CODES[errorCode].replace(/&/g, "&").replace(//g, ">")).replace(/{errorDesc}/g, serverHTTPErrorDescs[errorCode]).replace(/{stack}/g, stack.replace(/&/g, "&").replace(//g, ">").replace(/\r\n/g, "
").replace(/\n/g, "
").replace(/\r/g, "
").replace(/ {2}/g, "  ")).replace(/{path}/g, req.url.replace(/&/g, "&").replace(//g, ">")).replace(/{server}/g, "" + ((exposeServerVersion ? "SVR.JS/" + version + " (" + getOS() + "; " + (process.isBun ? ("Bun/v" + process.versions.bun + "; like Node.JS/" + process.version) : ("Node.JS/" + process.version)) + ")" : "SVR.JS") + ((!exposeModsInErrorPages || extName == undefined) ? "" : " " + extName)).replace(/&/g, "&").replace(//g, ">") + ((req.headers.host == undefined || isProxy) ? "" : " on " + String(req.headers.host).replace(/&/g, "&").replace(//g, ">"))).replace(/{contact}/g, serverAdmin.replace(/&/g, "&").replace(//g, ">").replace(/\./g, "[dot]").replace(/@/g, "[at]"))); // Replace placeholders in error response + } catch (err) { + let additionalError = 500; + // Handle additional error cases + if (err.code == "ENOENT") { + additionalError = 404; + } else if (err.code == "ENOTDIR") { + additionalError = 404; // Assume that file doesn't exist + } else if (err.code == "EACCES") { + additionalError = 403; + } else if (err.code == "ENAMETOOLONG") { + additionalError = 414; + } else if (err.code == "EMFILE") { + additionalError = 503; + } else if (err.code == "ELOOP") { + additionalError = 508; + } + + res.writeHead(errorCode, http.STATUS_CODES[errorCode], cheaders); + res.write(("{errorMessage}

{errorMessage}

{errorDesc}

" + ((additionalError == 404) ? "" : "

Additionally, a {additionalError} error occurred while loading an error page.

") + "

{server}

").replace(/{errorMessage}/g, errorCode.toString() + " " + http.STATUS_CODES[errorCode].replace(/&/g, "&").replace(//g, ">")).replace(/{errorDesc}/g, serverHTTPErrorDescs[errorCode]).replace(/{stack}/g, stack.replace(/&/g, "&").replace(//g, ">").replace(/\r\n/g, "
").replace(/\n/g, "
").replace(/\r/g, "
").replace(/ {2}/g, "  ")).replace(/{path}/g, req.url.replace(/&/g, "&").replace(//g, ">")).replace(/{server}/g, "" + ((exposeServerVersion ? "SVR.JS/" + version + " (" + getOS() + "; " + (process.isBun ? ("Bun/v" + process.versions.bun + "; like Node.JS/" + process.version) : ("Node.JS/" + process.version)) + ")" : "SVR.JS") + ((!exposeModsInErrorPages || extName == undefined) ? "" : " " + extName)).replace(/&/g, "&").replace(//g, ">") + ((req.headers.host == undefined || isProxy) ? "" : " on " + String(req.headers.host).replace(/&/g, "&").replace(//g, ">"))).replace(/{contact}/g, serverAdmin.replace(/&/g, "&").replace(//g, ">").replace(/\./g, "[dot]").replace(/@/g, "[at]")).replace(/{additionalError}/g, additionalError.toString())); // Replace placeholders in error response + res.end(); + } + }); + } + }); + } + + // Function to perform HTTP redirection to a specified destination URL + res.redirect = (destination, isTemporary, keepMethod, customHeaders) => { + // If keepMethod is a object, then save it to customHeaders + if (typeof keepMethod == "object") customHeaders = keepMethod; + + // If isTemporary is a object, then save it to customHeaders + if (typeof isTemporary == "object") customHeaders = isTemporary; + + // If customHeaders are not provided, get the default custom headers + if (customHeaders === undefined) customHeaders = getCustomHeaders(); + + // Set the "Location" header to the destination URL + customHeaders["Location"] = destination; + + // Determine the status code for redirection based on the isTemporary and keepMethod flags + const statusCode = keepMethod ? (isTemporary ? 307 : 308) : (isTemporary ? 302 : 301); + + // Write the response header with the appropriate status code and message + res.writeHead(statusCode, http.STATUS_CODES[statusCode], customHeaders); + + // Log the redirection message + logFacilities("Client redirected to " + destination); + + // End the response + res.end(); + + // Return from the function + return; + } + + try { + req.parsedURL = new URL(req.url, "http" + (req.socket.encrypted ? "s" : "") + "://" + (req.headers.host ? req.headers.host : (domain ? domain : "unknown.invalid"))); + } catch (err) { + res.error(400, err); + return; + } + + //logFacilities.resmessage("svrjs"); + + // Call res.error (equivalent to callServerError in SVR.JS 3.x) + res.error(200); +} + +// Create HTTP server +http.createServer(requestHandler).listen(3000); diff --git a/src/res/httpErrorDescriptions.js b/src/res/httpErrorDescriptions.js new file mode 100644 index 0000000..7625909 --- /dev/null +++ b/src/res/httpErrorDescriptions.js @@ -0,0 +1,52 @@ +// HTTP error descriptions +var serverHTTPErrorDescs = { + 200: "The request succeeded! :)", + 201: "A new resource has been created.", + 202: "The request has been accepted for processing, but the processing has not been completed.", + 400: "The request you made is invalid.", + 401: "You need to authenticate yourself in order to access the requested file.", + 402: "You need to pay in order to access the requested file.", + 403: "You don't have access to the requested file.", + 404: "The requested file doesn't exist. If you have typed the URL manually, then please check the spelling.", + 405: "Method used to access the requested file isn't allowed.", + 406: "The request is capable of generating only unacceptable content.", + 407: "You need to authenticate yourself in order to use the proxy.", + 408: "You have timed out.", + 409: "The request you sent conflicts with the current state of the server.", + 410: "The requested file is permanently deleted.", + 411: "Content-Length property is required.", + 412: "The server doesn't meet the preconditions you put in the request.", + 413: "The request you sent is too large.", + 414: "The URL you sent is too long.", + 415: "The media type of request you sent isn't supported by the server.", + 416: "The requested content range (Content-Range header) you sent is unsatisfiable.", + 417: "The expectation specified in the Expect property couldn't be satisfied.", + 418: "The server (teapot) can't brew any coffee! ;)", + 421: "The request you made isn't intended for this server.", + 422: "The server couldn't process content sent by you.", + 423: "The requested file is locked.", + 424: "The request depends on another failed request.", + 425: "The server is unwilling to risk processing a request that might be replayed.", + 426: "You need to upgrade the protocols you use to request a file.", + 428: "The request you sent needs to be conditional, but it isn't.", + 429: "You sent too many requests to the server.", + 431: "The request you sent contains headers that are too large.", + 451: "The requested file isn't accessible for legal reasons.", + 497: "You sent a non-TLS request to the HTTPS server.", + 500: "The server had an unexpected error. Below, the error stack is shown:

{stack}

You may need to contact the server administrator at {contact}.", + 501: "The request requires the use of a function, which isn't currently implemented by the server.", + 502: "The server had an error while it was acting as a gateway.

You may need to contact the server administrator at {contact}.", + 503: "The service provided by the server is currently unavailable, possibly due to maintenance downtime or capacity problems. Please try again later.

You may need to contact the server administrator at {contact}.", + 504: "The server couldn't get a response in time while it was acting as a gateway.

You may need to contact the server administrator at {contact}.", + 505: "The server doesn't support the HTTP version used in the request.", + 506: "The Variant header is configured to be engaged in content negotiation.

You may need to contact the server administrator at {contact}.", + 507: "The server ran out of disk space necessary to complete the request.", + 508: "The server detected an infinite loop while processing the request.", + 509: "The server has its bandwidth limit exceeded.

You may need to contact the server administrator at {contact}.", + 510: "The server requires an extended HTTP request. The request you made isn't an extended HTTP request.", + 511: "You need to authenticate yourself in order to get network access.", + 598: "The server couldn't get a response in time while it was acting as a proxy.", + 599: "The server couldn't connect in time while it was acting as a proxy.", +}; + +module.exports = serverHTTPErrorDescs; diff --git a/src/utils/clusterBunShim.js b/src/utils/clusterBunShim.js new file mode 100644 index 0000000..1c6e34e --- /dev/null +++ b/src/utils/clusterBunShim.js @@ -0,0 +1,230 @@ +const net = require("net"); +const os = require("os"); +const path = require("path"); + +let cluster = {}; +try { + // Import cluster module + cluster = require("cluster"); +} catch (err) { + // Clustering is not supported! +} + +// Cluster & IPC shim for Bun + +cluster.bunShim = function () { + cluster.isMaster = !process.env.NODE_UNIQUE_ID; + cluster.isPrimary = cluster.isMaster; + cluster.isWorker = !cluster.isMaster; + cluster.__shimmed__ = true; + + if (cluster.isWorker) { + // Shim the cluster.worker object for worker processes + cluster.worker = { + id: parseInt(process.env.NODE_UNIQUE_ID), + process: process, + isDead: function () { + return false; + }, + send: function (message, b, c, d) { + process.send(message, b, c, d); + }, + }; + + if (!process.send) { + // Shim the process.send function for worker processes + + // Create a fake IPC server to receive messages + let fakeIPCServer = net.createServer(function (socket) { + let receivedData = ""; + + socket.on("data", function (data) { + receivedData += data.toString(); + }); + + socket.on("end", function () { + process.emit("message", receivedData); + }); + }); + fakeIPCServer.listen( + os.platform() === "win32" + ? path.join( + "\\\\?\\pipe", + __dirname, + "temp/.W" + process.pid + ".ipc", + ) + : __dirname + "/temp/.W" + process.pid + ".ipc", + ); + + process.send = function (message) { + // Create a fake IPC connection to send messages + let fakeIPCConnection = net.createConnection( + os.platform() === "win32" + ? path.join( + "\\\\?\\pipe", + __dirname, + "temp/.P" + process.pid + ".ipc", + ) + : __dirname + "/temp/.P" + process.pid + ".ipc", + function () { + fakeIPCConnection.end(message); + }, + ); + }; + + process.removeFakeIPC = function () { + // Close IPC server + process.send = function () {}; + fakeIPCServer.close(); + }; + } + } + + // Custom implementation for cluster.fork() + cluster._workersCounter = 1; + cluster.workers = {}; + cluster.fork = function (env) { + const child_process = require("child_process"); + let newEnvironment = JSON.parse(JSON.stringify(env ? env : process.env)); + newEnvironment.NODE_UNIQUE_ID = cluster._workersCounter; + let newArguments = JSON.parse(JSON.stringify(process.argv)); + let command = newArguments.shift(); + let newWorker = child_process.spawn(command, newArguments, { + env: newEnvironment, + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + + newWorker.process = newWorker; + newWorker.isDead = function () { + return newWorker.exitCode !== null || newWorker.killed; + }; + newWorker.id = newEnvironment.NODE_UNIQUE_ID; + + function checkSendImplementation(worker) { + let sendImplemented = true; + + if ( + !( + process.versions && + process.versions.bun && + process.versions.bun[0] != "0" + ) + ) { + if (!worker.send) { + sendImplemented = false; + } + + let oldLog = console.log; + console.log = function (a, b, c, d, e, f) { + if ( + a == "ChildProcess.prototype.send() - Sorry! Not implemented yet" + ) { + throw new Error("NOT IMPLEMENTED"); + } else { + oldLog(a, b, c, d, e, f); + } + }; + + try { + worker.send(undefined); + } catch (err) { + if (err.message === "NOT IMPLEMENTED") { + sendImplemented = false; + } + console.log(err); + } + + console.log = oldLog; + } + + return sendImplemented; + } + + if (!checkSendImplementation(newWorker)) { + // Create a fake IPC server for worker process to receive messages + let fakeWorkerIPCServer = net.createServer(function (socket) { + let receivedData = ""; + + socket.on("data", function (data) { + receivedData += data.toString(); + }); + + socket.on("end", function () { + newWorker.emit("message", receivedData); + }); + }); + fakeWorkerIPCServer.listen( + os.platform() === "win32" + ? path.join( + "\\\\?\\pipe", + __dirname, + "temp/.P" + newWorker.process.pid + ".ipc", + ) + : __dirname + "/temp/.P" + newWorker.process.pid + ".ipc", + ); + + // Cleanup when worker process exits + newWorker.on("exit", function () { + fakeWorkerIPCServer.close(); + delete cluster.workers[newWorker.id]; + }); + + newWorker.send = function ( + message, + fakeParam2, + fakeParam3, + fakeParam4, + tries, + ) { + if (!tries) tries = 0; + + try { + // Create a fake IPC connection to send messages to worker process + let fakeWorkerIPCConnection = net.createConnection( + os.platform() === "win32" + ? path.join( + "\\\\?\\pipe", + __dirname, + "temp/.W" + newWorker.process.pid + ".ipc", + ) + : __dirname + "/temp/.W" + newWorker.process.pid + ".ipc", + function () { + fakeWorkerIPCConnection.end(message); + }, + ); + } catch (err) { + if (tries > 50) throw err; + newWorker.send( + message, + fakeParam2, + fakeParam3, + fakeParam4, + tries + 1, + ); + } + }; + } else { + newWorker.on("exit", function () { + delete cluster.workers[newWorker.id]; + }); + } + + cluster.workers[newWorker.id] = newWorker; + cluster._workersCounter++; + return newWorker; + }; +}; + +if ( + process.isBun && + (cluster.isMaster === undefined || + (cluster.isMaster && process.env.NODE_UNIQUE_ID)) +) { + cluster.bunShim(); +} + +// Shim cluster.isPrimary field +if (cluster.isPrimary === undefined && cluster.isMaster !== undefined) + cluster.isPrimary = cluster.isMaster; + +module.exports = cluster; diff --git a/src/utils/generateErrorStack.js b/src/utils/generateErrorStack.js new file mode 100644 index 0000000..d1f23ff --- /dev/null +++ b/src/utils/generateErrorStack.js @@ -0,0 +1,50 @@ +// Generate V8-style error stack from Error object. +function generateErrorStack(errorObject) { + // Split the error stack by newlines. + var errorStack = errorObject.stack ? errorObject.stack.split("\n") : []; + + // If the error stack starts with the error name, return the original stack (it is V8-style then). + if ( + errorStack.some(function (errorStackLine) { + return errorStackLine.indexOf(errorObject.name) == 0; + }) + ) { + return errorObject.stack; + } + + // Create a new error stack with the error name and code (if available). + var newErrorStack = [ + errorObject.name + + (errorObject.code ? ": " + errorObject.code : "") + + (errorObject.message == "" ? "" : ": " + errorObject.message), + ]; + + // Process each line of the original error stack. + errorStack.forEach(function (errorStackLine) { + if (errorStackLine != "") { + // Split the line into function and location parts (if available). + var errorFrame = errorStackLine.split("@"); + var location = ""; + if (errorFrame.length > 1 && errorFrame[0] == "global code") + errorFrame.shift(); + if (errorFrame.length > 1) location = errorFrame.pop(); + var func = errorFrame.join("@"); + + // Build the new error stack entry with function and location information. + newErrorStack.push( + " at " + + (func == "" + ? !location || location == "" + ? "" + : location + : func + + (!location || location == "" ? "" : " (" + location + ")")), + ); + } + }); + + // Join the new error stack entries with newlines and return the final stack. + return newErrorStack.join("\n"); +} + +module.exports = generateErrorStack; diff --git a/src/utils/getOS.js b/src/utils/getOS.js new file mode 100644 index 0000000..7f181eb --- /dev/null +++ b/src/utils/getOS.js @@ -0,0 +1,31 @@ +const os = require("os"); + +function getOS() { + var osType = os.type(); + var platform = os.platform(); + if (platform == "android") { + return "Android"; + } else if (osType == "Windows_NT" || osType == "WindowsNT") { + var arch = os.arch(); + if (arch == "ia32") { + return "Win32"; + } else if (arch == "x64") { + return "Win64"; + } else { + return "Win" + arch.toUpperCase(); + } + } else if (osType.indexOf("CYGWIN") == 0) { + return "Cygwin"; + } else if (osType.indexOf("MINGW") == 0) { + return "MinGW"; + } else if (osType.indexOf("MSYS") == 0) { + return "MSYS"; + } else if (osType.indexOf("UWIN") == 0) { + return "UWIN"; + } else if (osType == "GNU") { + return "GNU Hurd"; + } else { + return osType; + } +} +module.exports = getOS; diff --git a/src/utils/helper.js b/src/utils/helper.js deleted file mode 100644 index 775887f..0000000 --- a/src/utils/helper.js +++ /dev/null @@ -1,8 +0,0 @@ -// src/utils/helper.js -function add(a, b) { - return a + b; -} - -module.exports = { - add, -}; diff --git a/src/utils/serverconsole.js b/src/utils/serverconsole.js new file mode 100644 index 0000000..e7f588d --- /dev/null +++ b/src/utils/serverconsole.js @@ -0,0 +1,277 @@ +const fs = require("fs"); + +let enableLoggingIntoFile = false; +let logFile = undefined; +let logSync = false; +let cluster = require("./clusterBunShim.js"); +let reallyExiting = false; +let timestamp = new Date().getTime(); + +// Logging function +function LOG(s) { + try { + if (enableLoggingIntoFile) { + if (logSync) { + fs.appendFileSync( + __dirname + + "/log/" + + (cluster.isPrimary + ? "master" + : cluster.isPrimary === undefined + ? "singlethread" + : "worker") + + "-" + + timestamp + + ".log", + "[" + new Date().toISOString() + "] " + s + "\r\n", + ); + } else { + if (!logFile) { + logFile = fs.createWriteStream( + __dirname + + "/log/" + + (cluster.isPrimary + ? "master" + : cluster.isPrimary === undefined + ? "singlethread" + : "worker") + + "-" + + timestamp + + ".log", + { + flags: "a", + autoClose: false, + }, + ); + logFile.on("error", function (err) { + if ( + !s.match( + /^SERVER WARNING MESSAGE(?: \[Request Id: [0-9a-f]{6}\])?: There was a problem while saving logs! Logs will not be kept in log file\. Reason: /, + ) && + !reallyExiting + ) + serverconsole.locwarnmessage( + "There was a problem while saving logs! Logs will not be kept in log file. Reason: " + + err.message, + ); + }); + } + if (logFile.writable) { + logFile.write("[" + new Date().toISOString() + "] " + s + "\r\n"); + } else { + throw new Error("Log file stream is closed."); + } + } + } + } catch (err) { + if ( + !s.match( + /^SERVER WARNING MESSAGE(?: \[Request Id: [0-9a-f]{6}\])?: There was a problem while saving logs! Logs will not be kept in log file\. Reason: /, + ) && + !reallyExiting + ) + serverconsole.locwarnmessage( + "There was a problem while saving logs! Logs will not be kept in log file. Reason: " + + err.message, + ); + } +} + +// Server console function +var serverconsole = { + climessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.climessage(nmsg); + }); + return; + } + console.log( + "\x1b[1mSERVER CLI MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + LOG( + "SERVER CLI MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + reqmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.reqmessage(nmsg); + }); + return; + } + console.log( + "\x1b[34m\x1b[1mSERVER REQUEST MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg + + "\x1b[37m\x1b[0m", + ); + LOG( + "SERVER REQUEST MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + resmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.resmessage(nmsg); + }); + return; + } + console.log( + "\x1b[32m\x1b[1mSERVER RESPONSE MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg + + "\x1b[37m\x1b[0m", + ); + LOG( + "SERVER RESPONSE MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + errmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.errmessage(nmsg); + }); + return; + } + console.log( + "\x1b[31m\x1b[1mSERVER RESPONSE ERROR MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg + + "\x1b[37m\x1b[0m", + ); + LOG( + "SERVER RESPONSE ERROR MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + locerrmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.locerrmessage(nmsg); + }); + return; + } + console.log( + "\x1b[41m\x1b[1mSERVER ERROR MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg + + "\x1b[40m\x1b[0m", + ); + LOG( + "SERVER ERROR MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + locwarnmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.locwarnmessage(nmsg); + }); + return; + } + console.log( + "\x1b[43m\x1b[1mSERVER WARNING MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg + + "\x1b[40m\x1b[0m", + ); + LOG( + "SERVER WARNING MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + locmessage: (msg, reqId) => { + if (msg.indexOf("\n") != -1) { + msg.split("\n").forEach((nmsg) => { + serverconsole.locmessage(nmsg); + }); + return; + } + console.log( + "\x1b[1mSERVER MESSAGE\x1b[22m" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + LOG( + "SERVER MESSAGE" + + (reqId ? " [Request Id: " + reqId + "]" : "") + + ": " + + msg, + ); + return; + }, + setProcessExiting: (state) => { + reallyExiting = state; + }, +}; + +// Wrap around process.exit, so that log contents can be flushed. +process.unsafeExit = process.exit; +process.exit = function (code) { + if (logFile && logFile.writable && !logFile.pending) { + try { + logFile.close(function () { + logFile = undefined; + logSync = true; + process.unsafeExit(code); + }); + if (process.isBun) { + setInterval(function () { + if (!logFile.writable) { + logFile = undefined; + logSync = true; + process.unsafeExit(code); + } + }, 50); // Interval + } + setTimeout(function () { + logFile = undefined; + logSync = true; + process.unsafeExit(code); + }, 10000); // timeout + } catch (err) { + logFile = undefined; + logSync = true; + process.unsafeExit(code); + } + } else { + logSync = true; + process.unsafeExit(code); + } +}; + +module.exports = (enableLoggingIntoFileParam) => { + enableLoggingIntoFile = enableLoggingIntoFileParam; + return serverconsole; +}; diff --git a/src/utils/urlMojibakeFixer.js b/src/utils/urlMojibakeFixer.js new file mode 100644 index 0000000..92824f5 --- /dev/null +++ b/src/utils/urlMojibakeFixer.js @@ -0,0 +1,21 @@ +// Node.JS mojibake URL fixing function +function fixNodeMojibakeURL(string) { + var encoded = ""; + + //Encode URLs + Buffer.from(string, "latin1").forEach(function (value) { + if (value > 127) { + encoded += + "%" + (value < 16 ? "0" : "") + value.toString(16).toUpperCase(); + } else { + encoded += String.fromCodePoint(value); + } + }); + + //Upper case the URL encodings + return encoded.replace(/%[0-9a-f-A-F]{2}/g, function (match) { + return match.toUpperCase(); + }); +} + +module.exports = fixNodeMojibakeURL; diff --git a/src/utils/urlParser.js b/src/utils/urlParser.js new file mode 100644 index 0000000..6779707 --- /dev/null +++ b/src/utils/urlParser.js @@ -0,0 +1,90 @@ +const url = require("url"); + +// SVR.JS URL parser function +function parseURL(uri, prepend) { + // Replace newline characters with its respective URL encodings + uri = uri.replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + + // If URL begins with a slash, prepend a string if available + if (prepend && uri[0] == "/") uri = prepend.replace(/\/+$/, "") + uri; + + // Determine if URL has slashes + let hasSlashes = uri.indexOf("/") != -1; + + // Parse the URL using regular expression + let parsedURI = uri.match( + /^(?:([^:]+:)(\/\/)?)?(?:([^@\/?#\*]+)@)?([^:\/?#\*]+|\[[^\*]\/]\])?(?::([0-9]+))?(\*|\/[^?#]*)?(\?[^#]*)?(#[\S\s]*)?/, + ); + // Match 1: protocol + // Match 2: slashes after protocol + // Match 3: authentication credentials + // Match 4: host name + // Match 5: port + // Match 6: path name + // Match 7: query string + // Match 8: hash + + // If regular expression didn't match the entire URL, throw an error + if (parsedURI[0].length != uri.length) throw new Error("Invalid URL: " + uri); + + // If match 1 is not empty, set the slash variable based on state of match 2 + if (parsedURI[1]) hasSlashes = parsedURI[2] == "//"; + + // If match 6 is empty and URL has slashes, set it to a slash. + if (hasSlashes && !parsedURI[6]) parsedURI[6] = "/"; + + // If match 4 contains Unicode characters, convert it to Punycode. If the result is an empty string, throw an error + if (parsedURI[4] && !parsedURI[4].match(/^[a-zA-Z0-9\.\-]+$/)) { + parsedURI[4] = url.domainToASCII(parsedURI[4]); + if (!parsedURI[4]) throw new Error("Invalid URL: " + uri); + } + + // Create a new URL object + let uobject = new url.Url(); + + // Populate a URL object + if (hasSlashes) uobject.slashes = true; + if (parsedURI[1]) uobject.protocol = parsedURI[1]; + if (parsedURI[3]) uobject.auth = parsedURI[3]; + if (parsedURI[4]) { + uobject.host = parsedURI[4] + (parsedURI[5] ? ":" + parsedURI[5] : ""); + if (parsedURI[4][0] == "[") + uobject.hostname = parsedURI[4].substring(1, parsedURI[4].length - 1); + else uobject.hostname = parsedURI[4]; + } + if (parsedURI[5]) uobject.port = parsedURI[5]; + if (parsedURI[6]) uobject.pathname = parsedURI[6]; + if (parsedURI[7]) { + uobject.search = parsedURI[7]; + // Parse query strings + let qobject = Object.create(null); + const parsedQuery = parsedURI[7] + .substring(1) + .match(/([^&=]*)(?:=([^&]*))?/g); + parsedQuery.forEach(function (qp) { + if (qp.length > 0) { + let parsedQP = qp.match(/([^&=]*)(?:=([^&]*))?/); + if (parsedQP) { + qobject[parsedQP[1]] = parsedQP[2] ? parsedQP[2] : ""; + } + } + }); + uobject.query = qobject; + } else { + uobject.query = Object.create(null); + } + if (parsedURI[8]) uobject.hash = parsedURI[8]; + if (uobject.pathname) + uobject.path = uobject.pathname + (uobject.search ? uobject.search : ""); + uobject.href = + (uobject.protocol ? uobject.protocol + (uobject.slashes ? "//" : "") : "") + + (uobject.auth ? uobject.auth + "@" : "") + + (uobject.hostname ? uobject.hostname : "") + + (uobject.port ? ":" + uobject.port : "") + + (uobject.path ? uobject.path : "") + + (uobject.hash ? uobject.hash : ""); + + return uobject; +} + +module.exports = parseURL; diff --git a/src/utils/urlSanitizer.js b/src/utils/urlSanitizer.js index 3ea6979..23a8bde 100644 --- a/src/utils/urlSanitizer.js +++ b/src/utils/urlSanitizer.js @@ -1,3 +1,4 @@ +// SVR.JS path sanitizer function function sanitizeURL(resource, allowDoubleSlashes) { if (resource == "*" || resource == "") return resource; // Remove null characters @@ -7,17 +8,17 @@ function sanitizeURL(resource, allowDoubleSlashes) { throw new URIError("URI malformed"); // Decode URL-encoded characters while preserving certain characters resource = resource.replace(/%([0-9a-f]{2})/gi, (match, hex) => { - var decodedChar = String.fromCharCode(parseInt(hex, 16)); + const decodedChar = String.fromCharCode(parseInt(hex, 16)); return /(?!["<>^`{|}?#%])[!-~]/.test(decodedChar) ? decodedChar : "%" + hex; }); // Encode certain characters resource = resource.replace(/[<>^`{|}]]/g, (character) => { - var charCode = character.charCodeAt(0); + const charCode = character.charCodeAt(0); return ( "%" + (charCode < 16 ? "0" : "") + charCode.toString(16).toUpperCase() ); }); - var sanitizedResource = resource; + let sanitizedResource = resource; // Ensure the resource starts with a slash if (resource[0] != "/") sanitizedResource = "/" + sanitizedResource; // Convert backslashes to slashes and handle duplicate slashes diff --git a/tests/utils/generateErrorStack.test.js b/tests/utils/generateErrorStack.test.js new file mode 100644 index 0000000..25bd0da --- /dev/null +++ b/tests/utils/generateErrorStack.test.js @@ -0,0 +1,44 @@ +const generateErrorStack = require("../../src/utils/generateErrorStack"); + +describe("generateErrorStack", () => { + test("should return the original stack if it is V8-style", () => { + const error = new Error("Test error"); + error.stack = `Error: Test error + at Object. (/path/to/file.js:10:15) + at Module._compile (internal/modules/cjs/loader.js:1063:30) + at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)`; + + const result = generateErrorStack(error); + expect(result).toBe(error.stack); + }); + + test("should generate a new stack if the original stack is SpiderMonkey-style", () => { + const error = new Error("Test error"); + error.stack = `baz@filename.js:10:15 +bar@filename.js:6:3 +foo@filename.js:2:3 +@filename.js:13:1`; + + const result = generateErrorStack(error); + expect(result).toContain("Error: Test error"); + expect(result).toContain(" at baz (filename.js:10:15)"); + expect(result).toContain(" at bar (filename.js:6:3)"); + expect(result).toContain(" at foo (filename.js:2:3)"); + expect(result).toContain(" at filename.js:13:1"); + }); + + test("should generate a new stack if the original stack is JavaScriptCore-style", () => { + const error = new Error("Test error"); + error.stack = `baz@filename.js:10:15 +bar@filename.js:6:3 +foo@filename.js:2:3 +global code@filename.js:13:1`; + + const result = generateErrorStack(error); + expect(result).toContain("Error: Test error"); + expect(result).toContain(" at baz (filename.js:10:15)"); + expect(result).toContain(" at bar (filename.js:6:3)"); + expect(result).toContain(" at foo (filename.js:2:3)"); + expect(result).toContain(" at filename.js:13:1"); + }); +}); diff --git a/tests/utils/helper.test.js b/tests/utils/helper.test.js deleted file mode 100644 index b5fe8c7..0000000 --- a/tests/utils/helper.test.js +++ /dev/null @@ -1,5 +0,0 @@ -const { add } = require("../../src/utils/helper"); - -test("adds 1 + 2 to equal 3", () => { - expect(add(1, 2)).toBe(3); -}); diff --git a/tests/utils/urlMojibakeFixer.test.js b/tests/utils/urlMojibakeFixer.test.js new file mode 100644 index 0000000..50373cc --- /dev/null +++ b/tests/utils/urlMojibakeFixer.test.js @@ -0,0 +1,42 @@ +const fixNodeMojibakeURL = require("../../src/utils/urlMojibakeFixer.js"); + +describe("URL mojibake fixer", () => { + test("should return the same string for ASCII characters", () => { + expect(fixNodeMojibakeURL("hello world")).toBe("hello world"); + }); + + test("should encode characters with values greater than 127", () => { + expect(fixNodeMojibakeURL("é")).toBe("%E9"); + expect(fixNodeMojibakeURL("ñ")).toBe("%F1"); + }); + + test("should uppercase the URL encodings", () => { + expect(fixNodeMojibakeURL("a%e9b")).toBe("a%E9b"); + }); + + test("should handle mixed ASCII and non-ASCII characters", () => { + expect(fixNodeMojibakeURL("hello é world ñ")).toBe("hello %E9 world %F1"); + }); + + test("should handle empty string", () => { + expect(fixNodeMojibakeURL("")).toBe(""); + }); + + test("should handle strings with special characters", () => { + expect(fixNodeMojibakeURL("!@#$%^&*()")).toBe("!@#$%^&*()"); + }); + + test("should handle strings with spaces", () => { + expect(fixNodeMojibakeURL("hello world")).toBe("hello world"); + }); + + test("should handle strings with numbers", () => { + expect(fixNodeMojibakeURL("12345")).toBe("12345"); + }); + + test("should handle strings with mixed characters", () => { + expect(fixNodeMojibakeURL("hello123 é world ñ!@#$%^&*()")).toBe( + "hello123 %E9 world %F1!@#$%^&*()", + ); + }); +}); diff --git a/tests/utils/urlParser.test.js b/tests/utils/urlParser.test.js new file mode 100644 index 0000000..f5953aa --- /dev/null +++ b/tests/utils/urlParser.test.js @@ -0,0 +1,80 @@ +const parseURL = require("../../src/utils/urlParser.js"); + +describe("URL parser", () => { + test("should parse a simple URL", () => { + const parsedUrl = parseURL("http://example.com"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.pathname).toBe("/"); + expect(parsedUrl.path).toBe("/"); + expect(parsedUrl.href).toBe("http://example.com/"); + }); + + test("should parse a URL with a path", () => { + const parsedUrl = parseURL("http://example.com/path/to/resource"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.pathname).toBe("/path/to/resource"); + expect(parsedUrl.path).toBe("/path/to/resource"); + expect(parsedUrl.href).toBe("http://example.com/path/to/resource"); + }); + + test("should parse a URL with a query string", () => { + const parsedUrl = parseURL("http://example.com/path?query=string"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.pathname).toBe("/path"); + expect(parsedUrl.search).toBe("?query=string"); + expect(parsedUrl.query.query).toBe("string"); + expect(parsedUrl.path).toBe("/path?query=string"); + expect(parsedUrl.href).toBe("http://example.com/path?query=string"); + }); + + test("should parse a URL with a port", () => { + const parsedUrl = parseURL("http://example.com:8080"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.port).toBe("8080"); + expect(parsedUrl.pathname).toBe("/"); + expect(parsedUrl.path).toBe("/"); + expect(parsedUrl.href).toBe("http://example.com:8080/"); + }); + + test("should parse a URL with a username and password", () => { + const parsedUrl = parseURL("http://user:pass@example.com"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.auth).toBe("user:pass"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.pathname).toBe("/"); + expect(parsedUrl.path).toBe("/"); + expect(parsedUrl.href).toBe("http://user:pass@example.com/"); + }); + + test("should parse a URL with a fragment", () => { + const parsedUrl = parseURL("http://example.com/path#fragment"); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.pathname).toBe("/path"); + expect(parsedUrl.hash).toBe("#fragment"); + expect(parsedUrl.path).toBe("/path"); + expect(parsedUrl.href).toBe("http://example.com/path#fragment"); + }); + + test("should parse a URL with all components", () => { + const parsedUrl = parseURL( + "http://user:pass@example.com:8080/path/to/resource?query=string#fragment", + ); + expect(parsedUrl.protocol).toBe("http:"); + expect(parsedUrl.auth).toBe("user:pass"); + expect(parsedUrl.hostname).toBe("example.com"); + expect(parsedUrl.port).toBe("8080"); + expect(parsedUrl.pathname).toBe("/path/to/resource"); + expect(parsedUrl.search).toBe("?query=string"); + expect(parsedUrl.query.query).toBe("string"); + expect(parsedUrl.hash).toBe("#fragment"); + expect(parsedUrl.path).toBe("/path/to/resource?query=string"); + expect(parsedUrl.href).toBe( + "http://user:pass@example.com:8080/path/to/resource?query=string#fragment", + ); + }); +});