1
0
Fork 0
forked from svrjs/svrjs

feat: add SVR.JS Core

This commit is contained in:
Dorian Niemiec 2024-11-10 15:42:56 +01:00
parent 1174a348b6
commit d34d4bcc2d
10 changed files with 755 additions and 5 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Build output
/dist/
/out/
/core/
# Temporary files used by build script
/generatedAssets/

21
coreAssets/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2024 SVR.JS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
coreAssets/README.md Normal file
View file

@ -0,0 +1,3 @@
# SVR.JS Core
TODO

View file

@ -7,6 +7,10 @@ const archiver = require("archiver");
const chokidar = require("chokidar");
const svrjsInfo = JSON.parse(fs.readFileSync(__dirname + "/svrjs.json"));
const { version } = svrjsInfo;
const svrjsCoreInfo = JSON.parse(fs.readFileSync(__dirname + "/svrjs.core.json"));
const { externalPackages } = svrjsCoreInfo;
const coreVersion = svrjsCoreInfo.version;
const corePackageJSON = svrjsCoreInfo.packageJSON;
const isDev = process.env.NODE_ENV == "development";
// Create the dist directory if it doesn't exist
@ -21,6 +25,9 @@ if (!fs.existsSync(__dirname + "/dist/temp"))
// Create the out directory if it doesn't exist and if not building for development
if (!isDev && !fs.existsSync(__dirname + "/out")) fs.mkdirSync(__dirname + "/out");
// Create the core directory if it doesn't exist and if not building for development
if (!isDev && !fs.existsSync(__dirname + "/core")) fs.mkdirSync(__dirname + "/core");
function generateAssets() {
// Variables from "svrjs.json" file
const svrjsInfo = JSON.parse(fs.readFileSync(__dirname + "/svrjs.json"));
@ -243,6 +250,44 @@ if (!isDev) {
target: "es2017"
})
.then(() => {
const dependencies = JSON.parse(fs.readFileSync(__dirname + "/package.json")).dependencies || {};
const coreDependencyNames = Object.keys(dependencies).filter((dependency) => externalPackages.indexOf(dependency) != -1);
const packageJSON = Object.assign({}, corePackageJSON);
// Add package.json properties
packageJSON.version = coreVersion;
packageJSON.main = "./svr.core.js";
packageJSON.dependencies = coreDependencyNames.reduce((previousDependencies, dependency) => {
previousDependencies[dependency] = dependencies[dependency];
return previousDependencies;
}, {});
// Write package.json
fs.writeFileSync(__dirname + "/core/package.json", JSON.stringify(packageJSON, null, 2));
// Build SVR.JS Core
esbuild
.build({
entryPoints: ["src/core.js"],
bundle: true,
outfile: "core/svr.core.js",
platform: "node",
target: "es2017",
external: coreDependencyNames,
plugins: [
esbuildCopyPlugin.copy({
resolveFrom: __dirname,
assets: {
from: ["./coreAssets/**/*"],
to: ["./core"]
},
globbyOptions: {
dot: true
}
})
]
})
.then(() => {
const archiveName =
"svr.js." +
version.toLowerCase().replace(/[^0-9a-z]+/g, ".") +
@ -280,6 +325,10 @@ if (!isDev) {
.catch((err) => {
throw err;
});
})
.catch((err) => {
throw err;
});
})
.catch((err) => {
throw err;

View file

@ -5,7 +5,7 @@
"scripts": {
"build": "npm run clean && NODE_ENV=production node esbuild.config.js",
"cz": "cz",
"clean": "rimraf dist && rimraf out && rimraf generatedAssets",
"clean": "rimraf dist && rimraf out && rimraf generatedAssets && rimraf core",
"dev": "npm run clean && concurrently \"NODE_ENV=development node esbuild.config.js\" \"wait-on dist/svr.js && nodemon dist/svr.js --stdout-notty --no-save-config\"",
"lint": "eslint --no-error-on-unmatched-pattern src/**/*.js src/*.js tests/**/*.test.js tests/**/*.js tests/*.test.js tests/*.js",
"lint:fix": "npm run lint -- --fix",

633
src/core.js Normal file
View file

@ -0,0 +1,633 @@
const fs = require("fs");
const net = require("net");
const defaultPageCSS = require("./res/defaultPageCSS.js");
const generateErrorStack = require("./utils/generateErrorStack.js");
const serverHTTPErrorDescs = require("./res/httpErrorDescriptions.js");
const fixNodeMojibakeURL = require("./utils/urlMojibakeFixer.js");
const ipMatch = require("./utils/ipMatch.js");
const matchHostname = require("./utils/matchHostname.js");
const generateServerStringCore = require("./utils/generateServerStringCore.js");
const parseURL = require("./utils/urlParser.js");
const deepClone = require("./utils/deepClone.js");
const statusCodes = require("./res/statusCodes.js");
const middleware = [
require("./middleware/urlSanitizer.js"),
require("./middleware/rewriteURL.js"),
require("./middleware/redirectTrailingSlashes.js"),
require("./middleware/defaultHandlerChecks.js"),
require("./middleware/staticFileServingAndDirectoryListings.js")
];
let coreConfig = {};
function requestHandler(req, res, next) {
// SVR.JS log facilities (stubs in SVR.JS core)
const logFacilities = {
climessage: () => {},
reqmessage: () => {},
resmessage: () => {},
errmessage: () => {},
locerrmessage: () => {},
locwarnmessage: () => {},
locmessage: () => {}
};
// SVR.JS configuration object (modified)
const config = deepClone(coreConfig);
config.generateServerString = () =>
generateServerStringCore(config.exposeServerVersion);
// Determine the webroot from the current working directory if it is not configured
if (config.wwwroot === undefined) config.wwwroot = process.cwd();
// getCustomHeaders() in SVR.JS 3.x
config.getCustomHeaders = () => {
let ph = Object.assign({}, config.customHeaders);
if (config.customHeadersVHost) {
let vhostP = null;
config.customHeadersVHost.every((vhost) => {
if (
matchHostname(vhost.host, req.headers.host) &&
ipMatch(vhost.ip, req.socket ? req.socket.localAddress : undefined)
) {
vhostP = vhost;
return false;
} else {
return true;
}
});
if (vhostP && vhostP.headers) ph = { ...ph, ...vhostP.headers };
}
Object.keys(ph).forEach((phk) => {
if (typeof ph[phk] == "string")
ph[phk] = ph[phk].replace(/\{path\}/g, req.url);
});
return ph;
};
// Make HTTP/1.x API-based scripts compatible with HTTP/2.0 API
if (config.enableHTTP2 == true && req.httpVersion == "2.0") {
// Set HTTP/1.x methods (to prevent process warnings)
res.writeHeadNodeApi = res.writeHead;
res.setHeaderNodeApi = res.setHeader;
res.writeHead = (a, b, c) => {
let table = c;
if (typeof b == "object") table = b;
if (table == undefined) table = this.tHeaders;
if (table == undefined) table = {};
table = Object.assign({}, table);
Object.keys(table).forEach((key) => {
const al = key.toLowerCase();
if (
al == "transfer-encoding" ||
al == "connection" ||
al == "keep-alive" ||
al == "upgrade"
)
delete table[key];
});
if (res.stream && res.stream.destroyed) {
return false;
} else {
return res.writeHeadNodeApi(a, table);
}
};
res.setHeader = (headerName, headerValue) => {
const al = headerName.toLowerCase();
if (
al != "transfer-encoding" &&
al != "connection" &&
al != "keep-alive" &&
al != "upgrade"
)
return res.setHeaderNodeApi(headerName, headerValue);
return false;
};
// Set HTTP/1.x headers
if (!req.headers.host) req.headers.host = req.headers[":authority"];
if (!req.url) req.url = req.headers[":path"];
if (!req.protocol) req.protocol = req.headers[":scheme"];
if (!req.method) req.method = req.headers[":method"];
if (
req.headers[":path"] == undefined ||
req.headers[":method"] == undefined
) {
let err = new Error(
'Either ":path" or ":method" pseudoheader is missing.'
);
if (Buffer.alloc) err.rawPacket = Buffer.alloc(0);
if (req.socket && req.socket.server)
req.socket.server.emit("clientError", err, req.socket);
}
}
req.url = fixNodeMojibakeURL(req.url);
req.isProxy = false;
if (req.socket == null) return;
// Set up X-Forwarded-For
let reqip = req.socket.remoteAddress;
let reqport = req.socket.remotePort;
let oldip = "";
let oldport = "";
let isForwardedValid = true;
if (config.enableIPSpoofing) {
if (req.headers["x-forwarded-for"] != undefined) {
let preparedReqIP = req.headers["x-forwarded-for"]
.split(",")[0]
.replace(/ /g, "");
let preparedReqIPvalid = net.isIP(preparedReqIP);
if (preparedReqIPvalid) {
if (
preparedReqIPvalid == 4 &&
req.socket.remoteAddress &&
req.socket.remoteAddress.indexOf(":") > -1
)
preparedReqIP = "::ffff:" + preparedReqIP;
reqip = preparedReqIP;
reqport = null;
try {
oldport = req.socket.remotePort;
oldip = req.socket.remoteAddress;
req.socket.realRemotePort = reqport;
req.socket.realRemoteAddress = reqip;
req.socket.originalRemotePort = oldport;
req.socket.originalRemoteAddress = oldip;
res.socket.realRemotePort = reqport;
res.socket.realRemoteAddress = reqip;
res.socket.originalRemotePort = oldport;
res.socket.originalRemoteAddress = oldip;
// eslint-disable-next-line no-unused-vars
} catch (err) {
// Address setting failed
}
} else {
isForwardedValid = false;
}
}
}
// Process the Host header
if (typeof req.headers.host == "string") {
req.headers.host = req.headers.host.toLowerCase();
if (!req.headers.host.match(/^\.+$/))
req.headers.host = req.headers.host.replace(/\.$/, "");
}
// Header and footer placeholders
res.head = "";
res.foot = "";
res.responseEnd = (body) => {
// If body is Buffer, then it is converted to String anyway.
res.write(res.head + body + res.foot);
res.end();
};
const defaultServerError = (errorCode, extName, stack, ch) => {
// Determine error file
const getErrorFileName = (list, callback, _i) => {
const medCallback = (p) => {
if (p) callback(p);
else {
if (errorCode == 404) {
fs.access(config.page404, fs.constants.F_OK, (err) => {
if (err) {
fs.access(
config.wwwroot + "/." + errorCode.toString(),
fs.constants.F_OK,
(err) => {
try {
if (err) {
callback(errorCode.toString() + ".html");
} else {
callback(config.wwwroot + "/." + errorCode.toString());
}
} catch (err2) {
res.error(500, err2);
}
}
);
} else {
try {
callback(config.page404);
} catch (err2) {
res.error(500, err2);
}
}
});
} else {
fs.access(
config.wwwroot + "/." + errorCode.toString(),
fs.constants.F_OK,
(err) => {
try {
if (err) {
callback(errorCode.toString() + ".html");
} else {
callback(config.wwwroot + "/." + 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, req.headers.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, (err) => {
if (err) {
getErrorFileName(list, callback, _i + 1);
} else {
medCallback(list[_i].path);
}
});
}
};
getErrorFileName(config.errorPages, (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"));
// Hide the error stack if specified
if (config.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 = { ...config.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, (err, data) => {
try {
if (err) throw err;
res.writeHead(errorCode, statusCodes[errorCode], cheaders);
res.responseEnd(
data
.toString()
.replace(
/{errorMessage}/g,
errorCode.toString() +
" " +
statusCodes[errorCode]
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
)
.replace(/{errorDesc}/g, serverHTTPErrorDescs[errorCode])
.replace(
/{stack}/g,
stack
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\r\n/g, "<br/>")
.replace(/\n/g, "<br/>")
.replace(/\r/g, "<br/>")
.replace(/ {2}/g, "&nbsp;&nbsp;")
)
.replace(
/{path}/g,
req.url
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
)
.replace(
/{server}/g,
"" +
(
config.generateServerString() +
(!config.exposeModsInErrorPages || extName == undefined
? ""
: " " + extName)
)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;") +
(req.headers.host == undefined || req.isProxy
? ""
: " on " +
String(req.headers.host)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;"))
)
.replace(
/{contact}/g,
config.serverAdministratorEmail
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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, statusCodes[errorCode], cheaders);
res.write(
`<!DOCTYPE html><html><head><title>{errorMessage}</title><meta name="viewport" content="width=device-width, initial-scale=1.0" /><style>${defaultPageCSS}</style></head><body><h1>{errorMessage}</h1><p>{errorDesc}</p>${
additionalError == 404
? ""
: "<p>Additionally, a {additionalError} error occurred while loading an error page.</p>"
}<p><i>{server}</i></p></body></html>`
.replace(
/{errorMessage}/g,
errorCode.toString() +
" " +
statusCodes[errorCode]
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
)
.replace(/{errorDesc}/g, serverHTTPErrorDescs[errorCode])
.replace(
/{stack}/g,
stack
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\r\n/g, "<br/>")
.replace(/\n/g, "<br/>")
.replace(/\r/g, "<br/>")
.replace(/ {2}/g, "&nbsp;&nbsp;")
)
.replace(
/{path}/g,
req.url
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
)
.replace(
/{server}/g,
"" +
(
config.generateServerString() +
(!config.exposeModsInErrorPages || extName == undefined
? ""
: " " + extName)
)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;") +
(req.headers.host == undefined || req.isProxy
? ""
: " on " +
String(req.headers.host)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;"))
)
.replace(
/{contact}/g,
config.serverAdministratorEmail
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\./g, "[dot]")
.replace(/@/g, "[at]")
)
.replace(/{additionalError}/g, additionalError.toString())
); // Replace placeholders in error response
res.end();
}
});
}
});
};
// 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."
);
}
if (next) {
// Invoke next() handler, like when it is used in Express
if (errorCode == 500) {
next(new Error("Internal SVR.JS core error"));
} else {
next();
}
} else {
// Invoke default server error handler
defaultServerError(errorCode, extName, stack, ch);
}
};
// 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 = config.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, statusCodes[statusCode], customHeaders);
// End the response
res.end();
// Return from the function
return;
};
try {
res.head = fs.existsSync(`${config.wwwroot}/.head`)
? fs.readFileSync(`${config.wwwroot}/.head`).toString()
: fs.existsSync(`${config.wwwroot}/head.html`)
? fs.readFileSync(`${config.wwwroot}/head.html`).toString()
: ""; // header
res.foot = fs.existsSync(`${config.wwwroot}/.foot`)
? fs.readFileSync(`${config.wwwroot}/.foot`).toString()
: fs.existsSync(`${config.wwwroot}/foot.html`)
? fs.readFileSync(`${config.wwwroot}/foot.html`).toString()
: ""; // footer
} catch (err) {
res.error(500, err);
}
// Authenticated user variable
req.authUser = null;
if (req.url == "*") {
// Handle "*" URL
if (req.method == "OPTIONS") {
// Respond with list of methods
let hdss = config.getCustomHeaders();
hdss["Allow"] = "GET, POST, HEAD, OPTIONS";
res.writeHead(204, statusCodes[204], hdss);
res.end();
return;
} else {
// SVR.JS doesn't understand that request, so throw an 400 error
res.error(400);
return;
}
}
if (req.headers["expect"] && req.headers["expect"] != "100-continue") {
// Expectations not met.
res.error(417);
return;
}
if (req.method == "CONNECT") {
// CONNECT requests should be handled in "connect" event.
res.error(501);
return;
}
if (!isForwardedValid) {
res.error(400);
return;
}
try {
req.parsedURL = parseURL(
req.url,
"http" +
(req.socket.encrypted ? "s" : "") +
"://" +
(req.headers.host
? req.headers.host
: config.domain
? config.domain
: "unknown.invalid")
);
} catch (err) {
res.error(400, err);
return;
}
let index = 0;
// Call the next middleware function
const nextMiddleware = () => {
let currentMiddleware = middleware[index++];
while (
req.isProxy &&
currentMiddleware &&
currentMiddleware.proxySafe !== false &&
!(currentMiddleware.proxySafe || currentMiddleware.proxy)
) {
currentMiddleware = middleware[index++];
}
if (currentMiddleware) {
try {
currentMiddleware(req, res, logFacilities, config, nextMiddleware);
} catch (err) {
res.error(500, err);
}
} else {
res.error(404);
}
};
// Handle middleware
nextMiddleware();
}
function init(config) {
coreConfig = config;
return requestHandler;
}
module.exports = init;
module.exports.init = init;

View file

@ -714,7 +714,7 @@ function requestHandler(req, res) {
let index = 0;
// Call the next middleware function
const next = () => {
const nextMiddleware = () => {
let currentMiddleware = middleware[index++];
while (
req.isProxy &&
@ -726,7 +726,7 @@ function requestHandler(req, res) {
}
if (currentMiddleware) {
try {
currentMiddleware(req, res, logFacilities, config, next);
currentMiddleware(req, res, logFacilities, config, nextMiddleware);
} catch (err) {
res.error(500, err);
}
@ -736,7 +736,7 @@ function requestHandler(req, res) {
};
// Handle middleware
next();
nextMiddleware();
}
module.exports = (serverconsoleO, middlewareO) => {

View file

@ -854,7 +854,7 @@ if (!disableMods) {
}
// Middleware
let middleware = [
const middleware = [
require("./middleware/urlSanitizer.js"),
require("./middleware/redirects.js"),
require("./middleware/blocklist.js"),

View file

@ -0,0 +1,20 @@
const svrjsInfo = require("../../svrjs.core.json");
const { version, name } = svrjsInfo;
const getOS = require("./getOS.js");
function generateServerString(exposeServerVersion) {
return exposeServerVersion
? `${name.replace(/ /g, "-")}/${version} (${getOS()}; ${
process.isBun
? "Bun/v" + process.versions.bun + "; like Node.JS/" + process.version
: process.versions && process.versions.deno
? "Deno/v" +
process.versions.deno +
"; like Node.JS/" +
process.version
: "Node.JS/" + process.version
})`
: name.replace(/ /g, "-");
}
module.exports = generateServerString;

23
svrjs.core.json Normal file
View file

@ -0,0 +1,23 @@
{
"version": "Nightly-GitNext",
"name": "SVR.JS Core",
"externalPackages": ["mime-types"],
"packageJSON": {
"name": "svrjs-core",
"description": "A library for static file serving, built from SVR.JS source code.",
"repository": {
"type": "git",
"url": "https://git.svrjs.org/svrjs/svrjs.git"
},
"keywords": [
"static",
"web",
"server",
"files",
"mime",
"middleware"
],
"homepage": "https://svrjs.org",
"license": "MIT"
}
}