Last active
February 8, 2025 06:27
-
-
Save SollyBunny/3107e20f5c4c45532e37cc800aa984a1 to your computer and use it in GitHub Desktop.
A node.js http(s)/ws server which is all in one neat file (https://github.com/SollyBunny/webserver)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
// https://github.com/SollyBunny/ZISS | |
// ZISS: Ziss Is So Silly | |
/* | |
0. Foreword | |
Created by SollyBunny (find me on Discord) | |
You can do almost anything with this: share, fork, use (look at license) | |
1. Config File | |
1.1 Location | |
Default location for config file is `./conf.json` | |
The first argument overrides this | |
Arguments starting with + add to it | |
Both relative and absolute file paths work | |
Note: *1 | |
1.2 Format | |
JSON :) | |
1.3 Properties | |
"NAME": | |
Specifies the name of the server | |
Default: "NodeJS Web Server" | |
"DIRFILES": | |
The directory of the files to be served | |
Note: *1 | |
Default "files" | |
"DIRKEY": | |
The path to the key file for https | |
Note: Both DIRKEY and DIRCERT must be specified and valid for https to be enabled | |
Note: *1 | |
Default: undefined | |
"DIRCERT": | |
The path to the cert file for https | |
Note: Both DIRKEY and DIRCERT must be specified and valid for https to be enabled | |
Note: *1 | |
Default: undefined | |
"FORCEHTTPS": | |
Regardless of DIRKEY and DIRCERT, enable https with true, otherwise false | |
NOTE: This will most likely mess with websockets | |
"ERRFOLDER": | |
The file to use if the path is a folder | |
The folder will be passed in the query string | |
If none is specified, a "folder" message is sent in it's place | |
Note: *1 | |
Default: undefined | |
"ERRPERM": | |
The file to use if the user doesn't have permission to access a path | |
The path will be passed in the query string | |
If none is specified, a 403 message is sent in it's place | |
Note: *1 | |
Default: undefined | |
"PORT": | |
The port to use for server | |
If none is specified, 80 is used for http and 443 for https | |
Default: undefined | |
"DEV": | |
If set to true will remove all caching | |
Default: false | |
"USER": | |
A dictionary of user, password pairs | |
(See 2.1 Users) | |
Default: {} | |
"HEAD": | |
A dictionary of file extension, [ mime, (ttl), (cors) ] pairs | |
This shoudn't need to be changed unless you need new formats | |
Default: { <a big dictionary> } | |
"REPL": | |
If set to true will run the REPL (read eval print loop) on startup | |
Default: false | |
1.4 Example | |
```json | |
{ | |
"NAME" : "Solly's Web Server", | |
"DIRFILES" : "files", | |
"DIRFOLDER" : "files/explorer/index.html", | |
"DIRKEY" : "files/admin/cert/privkey.pem", | |
"DIRCERT" : "files/admin/cert/fullchain.pem", | |
"PORT" : 6567, | |
"USER" : { | |
"admin": "password123" | |
"solly": "bunny" | |
}, | |
} | |
``` | |
2. Permissions | |
2.1 Users | |
Users can be given special permissions to folders | |
This can be used to protect folders | |
The users credentials should be stored in cookies in u for user and p for password | |
(See 2.2 .perm) | |
2.2 .perm | |
.perm files can be placed in any directory 1 deep in DIRFILES | |
Inside is a comma delimited list of users who have exclusive access to the folder and it's contents | |
An example could be "admin,solly,bunny" | |
3. Scripts | |
3.1 Builtin Functions | |
Both fs and url modules are in the global scope | |
There is also conf for the current config | |
There are also util methods | |
util.typeof(file: String) => Enum | |
Returns what type of file file is | |
-1: doesn't exist (or error preventing it from being opened) | |
0: regular file | |
1: directory | |
2: script | |
3: wsscript | |
4: uscript (both script and wsscript) | |
util.checkperm(file: String, user: String, pass: String) => Boolean | |
Checks wether a user has access to a file | |
If you have access to the cookie object you can pass in | |
cookie.u as the user and | |
cookie.p as the pass | |
util.parsecookie(cookie) => Object<key: value> | |
Parses a cookie into an object | |
util.fetch(url: String) => Promise<String> | |
Fetch a url of the interwebs | |
util.require(file: String) => Object | |
Here be dragons: You aren't meant to be able to do this | |
Similar to NodeJS require but will always assume the source file is Javascript (thus not requiring .(m)js extension) | |
3.2 Web Scripts | |
Web Scripts are pages whose output are completely determined by a script run on server side | |
This opens up both possibilities to offer dynamic content, api endpoints and form endpoints, but they also open up possibilities to be exploited | |
Please make use to sanitize any input and use the function url.checkperm to see if a user has access to a file | |
Web scripts use the identifier `// ZISS SCRIPT` at the start to mark them | |
Here is the format of a web script | |
```js | |
// ZISS SCRIPT | |
module.exports.handle = (req, res) => { | |
res.writeHead(200); | |
res.end("Hello World"); | |
}; | |
``` | |
You can use the NodeJS documentation to figure out how to use this | |
There are also properties which are added to the req object | |
req.ip : the users IP | |
req.url : a url object to the original request | |
req.cookie : a parsed cookie object | |
3.3 Websocket Scripts (wsscripts) | |
Wsscripts provide a neat interface for the websockets | |
They the identifier `// ZISS WSSCRIPT` at the start to mark them | |
Here is the format of a websocket script | |
```js | |
// ZISS WSSCRIPT | |
module.exports.open = (ws) => { | |
} | |
module.exports.msg = (ws, msg) => { | |
} | |
module.exports.close = (ws) => { | |
} | |
``` | |
Note: all of the functions are optional | |
There are also properties which are added to the req object (you can add more, and they will be remembered) | |
ws.ip : the users IP | |
ws.url : a url object to the original upgrade request | |
ws.cookie : a parsed cookie object | |
3.4: Universal Scripts (uscripts) | |
Uscripts are a combination of both scripts and websocket scripts | |
If fetched by a browser they will run execute the handle method | |
If a websocket attempts to connect to it, it will run the websocket methods | |
United scripts use the identifier `// ZISS USCRIPT` at the start to mark them | |
Here is the format of a joint script | |
```js | |
// ZISS USCRIPT | |
module.exports.open = (ws) => { | |
}; | |
module.exports.msg = (ws, msg) => { | |
} | |
module.exports.close = (ws) => { | |
} | |
module.exports.handle = (req, res) => { | |
} | |
``` | |
*1: all relative file paths are always relative to the folder this file is in | |
for files in the DIRFILES folder, you still must specify the full relative path | |
*/ | |
// Log | |
global.log = (m) => { | |
log.raw(36, m); | |
}; | |
log.raw = (c, m) => { | |
console.log(`\x1b[${c}m[${log.time()}]\x1b[0m ${m}`); | |
} | |
log.warn = (m) => { | |
log.raw(33, m); | |
}; | |
log.error = (m) => { | |
log.raw(31, m); | |
}; | |
log.fake = (...m) => { | |
let out = ""; | |
const old = process.stdout.write; | |
process.stdout.write = (a, b, c) => { | |
out += a; | |
}; | |
console.log(...m); | |
process.stdout.write = old; | |
return out; | |
}; | |
log.time = () => { | |
const d = new Date(); | |
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDay() + 1).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}.${String(d.getSeconds()).padStart(2, "0")}` | |
}; | |
// args | |
const configdirs = [undefined]; | |
for (let i = 2; i < process.argv.length; ++i) { | |
const arg = process.argv[i]; | |
switch (arg[0]) { | |
case "-": | |
switch (arg.toLowerCase()) { | |
case "-h": | |
case "--help": | |
console.log(`Usage: ${__filename.slice(__filename.lastIndexOf("/") + 1)} [CONFIGDIR] [...+CONFGIDIRADD] [...KEY=VALUE] -h/--help -s/--setcap`); | |
process.exit(0); | |
case "-s": | |
case "--setcap": | |
require("child_process").execSync(`sudo $(which setcap) "cap_net_bind_service=+ep" $(which ${process.argv[0]})`); | |
process.exit(0); | |
default: | |
log.warn(`Config: Unknown flag ${process.argv[i]}`); | |
} | |
break; | |
case "+": | |
configdirs.push(arg.slice(1)); | |
break; | |
default: | |
if (arg.indexOf("=") !== -1) { | |
configdirs.push(arg); | |
break; | |
} | |
if (configdirs[0]) | |
log.warn(`Config: Config file ${configdirs[0]} overridden by ${arg}`); | |
configdirs[0] = arg; | |
} | |
} | |
configdirs[0] = configdirs[0] || "conf.json"; | |
// Globals | |
global.fs = require("fs"); | |
global.url = require("url"); | |
global.path = require("path"); | |
global.util = {}; | |
// Utils | |
util.typeof = file => { | |
/* | |
Get the "type" of a file: | |
-1: file doesn't exist (or error preventing it from being opened) | |
0: normal file | |
1: directory | |
2: script | |
3: wsscript | |
4: uscript (both script and wsscript) | |
*/ | |
let stat; | |
try { | |
stat = fs.statSync(file); | |
} catch (e) { | |
return -1; | |
} | |
if (stat.isDirectory()) return 1; | |
let fd; | |
try { | |
fd = fs.openSync(file, "r"); | |
} catch (e) { | |
return -1; | |
} | |
let buf = Buffer.alloc(16); | |
try { | |
fs.readSync(fd, buf, 0, 16, 0); | |
} catch (e) { | |
return -1; | |
} finally { | |
fs.closeSync(fd); | |
} | |
let str = buf.toString("utf-8"); | |
if (!str.startsWith("// ZISS ")) return 0; | |
str = str.slice(8); | |
if (str.startsWith("SCRIPT")) return 2; | |
if (str.startsWith("WSSCRIPT")) return 3; | |
if (str.startsWith("USCRIPT")) return 4; | |
return 0; | |
}; | |
util.checkperm = (file, user, pass) => { | |
parts = ""; | |
if (file.endsWith("/")) | |
file = file.slice(0, -1); | |
while (1) { | |
const path = `${file}/.perm`; | |
const i = file.lastIndexOf("/"); | |
if (i === -1) break; | |
file = file.slice(0, i); | |
try { | |
stat = fs.statSync(path); | |
} catch (e) { | |
if (e.code !== "ENOENT" && e.code != "ENOTDIR") throw e; | |
continue; | |
} | |
if (!stat.isFile()) continue; | |
if ( | |
!user || | |
!pass || | |
!conf.USER[user] || | |
conf.USER[user] !== pass || | |
fs.readFileSync(path).toString().trim().split(/[ ,]/g).indexOf(user) === -1 | |
) return false; | |
} | |
return true; | |
} | |
util.parsecookie = cookie => { | |
if (!cookie) return {}; | |
let tempcookie = cookie.split(";"); | |
cookie = {}; | |
let m; | |
for (let i = 0; i < tempcookie.length; ++i) { | |
if ((m = tempcookie[i].indexOf("=")) === -1) continue; | |
cookie[tempcookie[i].slice(0, m).trimLeft(" ")] = tempcookie[i].slice(m + 1); | |
} | |
return cookie; | |
} | |
util.requirecache = {}; | |
util.require = file => { | |
file = path.resolve(file); | |
// if (!conf.DEV) | |
if (util.requirecache[file]) return util.requirecache[file]; | |
let code; | |
code = fs.readFileSync(file).toString(); // this should error on fail | |
code = `(module,_import,__filename,__dirname)=>{${code}}`; | |
code = eval(code); | |
const fakeDirname = path.dirname(file); | |
const fakeModule = { exports: {} }; | |
const fakeImport = path => { | |
return import(`${fakeDirname}/${path}`); | |
}; | |
code(fakeModule, fakeImport, file, fakeDirname); | |
// if (!conf.DEV) | |
util.requirecache[file] = fakeModule.exports; | |
return fakeModule.exports; | |
}; | |
// Check working dir | |
if (__dirname !== process.cwd()) process.chdir(__dirname); | |
// Config | |
global.conf = { | |
"NAME" : "NodeJS Web Server", | |
"DIRFILES" : "files", | |
"DIRKEY" : undefined, | |
"DIRCERT" : undefined, | |
"FORCEHTTPS": false, | |
"ERRFOLDER" : undefined, | |
"ERRPERM" : undefined, | |
"ERR404" : undefined, | |
"PORT" : undefined, | |
"SCRIPTS" : [], | |
"WSSCRIPTS" : [], | |
"USER" : {}, | |
"HEAD" : { | |
"html" : ["text/html" , undefined], | |
"htm" : ["text/html" , undefined], | |
"css" : ["text/css" , undefined, "*"], | |
"mjs" : ["text/javascript" , undefined, "*"], | |
"js" : ["text/javascript" , undefined, "*"], | |
"woff2" : ["font/woff2" , 2147483647, "*"], | |
"ttf" : ["font/ttf" , 2147483647, "*"], | |
"otf" : ["font/otf" , 2147483647, "*"], | |
"ico" : ["image/x-image" , 2147483647, "*"], | |
"png" : ["image/png" , 2147483647, "*"], | |
"jpg" : ["image/jpeg" , 2147483647, "*"], | |
"jpeg" : ["image/jpeg" , 2147483647, "*"], | |
"mp4" : ["video/mp4" , 2147483647, "*"], | |
"mkv" : ["video/x-matroska", 2147483647, "*"], | |
"m4a" : ["audio/m4a" , 2147483647, "*"], | |
"svg" : ["image/svg+xml" , 2147483647, "*"], | |
"webp" : ["image/webp" , 2147483647, "*"], | |
"weba" : ["audio/weba" , 2147483647, "*"], | |
"webm" : ["video/webm" , 2147483647, "*"], | |
"wav" : ["audio/wav" , 2147483647, "*"], | |
"pdf" : ["application/pdf" , 2147483647, "*"], | |
}, | |
"REPL" : false, | |
"DEV" : false, | |
load: (file) => { | |
{ | |
const i = file.indexOf("="); | |
if (i !== -1) { | |
const key = file.slice(0, i).toUpperCase(); | |
const value = file.slice(i + 1); | |
if (key in conf) { | |
log(`Config: Set ${key} to ${value || "undefined"}`); | |
conf[key] = value ? JSON.parse(value) : undefined; | |
} else { | |
log.warn(`Config: ${key} is not a valid key`); | |
} | |
return; | |
} | |
} | |
let data; | |
try { // attempt to read file | |
data = fs.readFileSync(file); | |
} catch (e) { | |
log.error(`Config: Failed to config file ${file}: ${e.code}`); | |
return; | |
} | |
try { // attempt to load file | |
data = JSON.parse(data); | |
} catch (e) { | |
log.error(`Config: Failed to parse config file ${file}: ${e.message[0].toLowerCase()}${e.message.slice(1, e.message.indexOf(","))}`); | |
} | |
for (let i in data) { | |
key = i.toUpperCase(); | |
if (key in conf) | |
conf[key] = data[key]; | |
else if (!key.startsWith("_")) | |
log.warn(`Config: In ${file}, ${key} is not a valid key`); | |
} | |
log(`Config: Loaded ${file}`); | |
} | |
} | |
configdirs.forEach(conf.load); | |
function populatereq(req) { | |
req.ip = req.headers["x-forwarded-for"] || (req.socket || req.connection).remoteAddress; | |
req.url = url.parse(req.url, false); | |
req.cookie = util.parsecookie(req.headers.cookie); | |
// parse url | |
req.path = req.url.pathname; | |
if (req.path.length < 1) { // no pathname | |
req.path = "/"; | |
} else { | |
req.path = decodeURIComponent(req.path); // remove any html entities | |
req.path = req.path.replaceAll("/../", ""); // remove /.. in the start, middle | |
while (req.path.endsWith("/..")) // remove /.. at end | |
req.path = req.path.slice(0, -3); | |
while (req.path.startsWith("../")) // remove ../ at end | |
req.path = req.path.slice(3); | |
} | |
req.pathorig = req.path; | |
req.path = `${conf.DIRFILES}/${req.path}`; | |
return req; | |
} | |
// HTTPserver | |
function HTTPhandle(req, res) { | |
populatereq(req); | |
// main | |
let depth = 0; | |
const out = { code: 200, head: { "Content-Type": "text/html" } }; | |
function calcres() { | |
if ((depth++) > 5) { | |
out.code = 508; | |
out.data = "Loop detected"; | |
return; | |
} | |
if (!util.checkperm(req.path, req.cookie.u, req.cookie.p)) { | |
out.errperm = true; | |
if (conf.ERRPERM) { | |
req.path = `${conf.DIRFILES}/${conf.ERRPERM}`; | |
calcres(); | |
return; | |
} | |
out.code = 401; | |
out.data = "Unauthorized"; | |
return; | |
} | |
switch (util.typeof(req.path)) { | |
case -1: // file doesn't exist | |
out.err404 = true | |
if (conf.ERR404) { | |
req.path = `${conf.DIRFILES}/${conf.ERR404}`; | |
calcres(); | |
return; | |
} | |
out.code = 404; | |
out.data = "Not found"; | |
return; | |
case 0: // normal file | |
let ext = req.path.slice(req.path.lastIndexOf(".") + 1); | |
if (conf.HEAD[ext]) { | |
out.head["Content-Type"] = conf.HEAD[ext][0]; | |
if (conf.DEV) { | |
out.head["Cache-Control"] = "max-age=0"; | |
out.head["Access-Control-Allow-Origin"] = "*"; | |
} else { | |
if (conf.HEAD[ext][1]) // has cache | |
out.head["Cache-Control"] = `max-age=${conf.HEAD[ext][1]}`; | |
if (conf.HEAD[ext][2]) // has cors | |
out.head["Access-Control-Allow-Origin"] = `${conf.HEAD[ext][2]}`; | |
} | |
} | |
out.pipe = req.path; | |
return; | |
case 3: // wsscript | |
out.pipe = req.path; | |
out.head["Content-Type"] = "application/javascript"; | |
return; | |
case 1: // directory | |
out.errfolder = true; | |
if (!req.pathorig.endsWith("/")) { // make sure directories end with a slash | |
out.code = 307; | |
out.head = { | |
"Location": req.pathorig+ "/" | |
}; | |
return; | |
} | |
if (!fs.existsSync(`${req.path}/index.html`)) { // this dir doesn't have a index page | |
if (conf.ERRFOLDER) { | |
req.path = `${conf.DIRFILES}/${conf.ERRFOLDER}`; | |
calcres(); | |
return; | |
} else { | |
out.code = 200; | |
out.head["Content-Type"] = "text/html"; | |
out.data = `<h1>Directory: ${req.path}</h1><br>`; | |
if (req.path !== "/") | |
out.data += `<a href="..">..</a><br>`; | |
for (const file of fs.readdirSync(req.path)) { | |
out.data += `<a href="./${file}">${file}</a><br>`; | |
} | |
} | |
} else { | |
out.pipe = `${req.path}/index.html`; | |
} | |
return; | |
case 2: // script | |
case 4: // uscript | |
out.script = true; | |
out.done = true; | |
try { | |
util.require(req.path).handle(req, res); | |
} catch (e) { | |
log.error(e); | |
} | |
return; | |
} | |
} | |
calcres(); | |
const add = []; | |
if (out.script) add.push("script"); | |
if (out.err404) add.push("404"); | |
if (out.errperm) add.push("perm"); | |
if (out.errfolder) add.push("dir"); | |
log(`${req.ip}: "${req.pathorig}"${req.headers.cookie ? ` "${req.headers.cookie}"` : ""} ${add.join(" ")}`); | |
if (out.done) return; | |
if (out.pipe) { | |
const stat = fs.statSync(out.pipe); | |
const range = req.headers.range; | |
let start, end; | |
if (range) { | |
const parts = range.replace(/bytes=/, "").split("-"); | |
start = parseInt(parts[0], 10); | |
end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; | |
if (start < 0 || end < 0 || start >= end || start >= stat.size || end >= stat.size) { | |
out.head["Content-Range"] = `bytes */${stat.size}`; | |
res.writeHead(out.code, out.head); | |
res.end(); | |
return; | |
} | |
out.head["Content-Range"] = `bytes ${start}-${end}/${stat.size}`; | |
out.head["Accept-Ranges"] = "bytes"; | |
out.head["Content-Length"] = end - start + 1; | |
} else { | |
out.head["Content-Length"] = stat.size; | |
} | |
res.writeHead(out.code ?? 206, out.head); | |
const pipe = fs.createReadStream(out.pipe, { start, end }); | |
pipe.pipe(res); | |
pipe.on("error", e => { | |
res.end("error: " + e.message); | |
}); | |
} else if (out.data) { | |
out.head["Content-Length"] = out.data.length; | |
res.writeHead(out.code, out.head); | |
res.end(out.data); | |
} else { | |
res.writeHead(out.code, out.head); | |
res.end(); | |
} | |
} | |
let HTTPserver; | |
let HTTPserversecure = false; | |
if ((conf.DIRKEY && conf.DIRCERT) || conf.FORCEHTTPS) { | |
HTTPserver = require("https").createServer({ | |
key : conf.DIRKEY && fs.readFileSync(conf.DIRKEY ), | |
cert: conf.DIRCERT && fs.readFileSync(conf.DIRCERT), | |
}, HTTPhandle); | |
if (conf.PORT === undefined) conf.PORT = 443; | |
HTTPserversecure = true; | |
} else { | |
HTTPserver = require("http").createServer(HTTPhandle); | |
if (conf.PORT === undefined) conf.PORT = 80; | |
} | |
HTTPserver.on("error", e => { | |
switch (e.code) { | |
case "EADDRINUSE": | |
log.error(`HTTP: Failed to start as already running on port ${conf.PORT}`); | |
break; | |
case "EACCES": | |
log.error(`HTTP: Failed to start due to perms: try ./${__filename.slice(__filename.lastIndexOf("/") + 1)} --setcap`); | |
break; | |
default: // unknown | |
log.error(`HTTP: Failed to start:\n${e.stack}`); | |
} | |
process.exit(1); | |
}); | |
HTTPserver.listen(conf.PORT, () => { | |
log(`HTTP: ${HTTPserversecure ? "Secure server" : "Server"} started on port ${conf.PORT}`); | |
}); | |
// WSserver | |
function WShandle(req, socket, head) { | |
populatereq(req); | |
if (!util.checkperm(req.url.pathname, req.cookie.u, req.cookie.p)) { | |
socket.destroy(); | |
return; | |
} | |
switch (util.typeof(req.path)) { | |
case 3: // wsscript | |
case 4: // both script & wsscript | |
break; | |
default: | |
socket.destroy(); | |
return; | |
} | |
WSserver.handleUpgrade(req, socket, head, ws => { | |
ws.script = util.require(req.path); | |
Object.defineProperty(ws, "ip", { value: req.ip }); | |
Object.defineProperty(ws, "url", { value: req.url }); | |
Object.defineProperty(ws, "cookie", { value: req.cookie }); | |
if (ws.script.open) ws.script.open(ws); | |
WSserver.connections.push(ws); | |
if (ws.script.msg) ws.on("message", msg => { | |
ws.script.msg(ws, msg); | |
}); | |
ws.on("close", () => { | |
WSserver.connections = WSserver.connections.filter(i => i !== ws); | |
if (ws.script.close) ws.script.close(ws); | |
}); | |
}); | |
log(`${req.ip}: "${req.url.path}" "${req.headers.cookie || ""}" WS`); | |
} | |
let WSmodule = undefined; | |
try { | |
WSmodule = require("ws"); | |
} catch (e) { | |
log.warn("WS: Not installed, disabling"); | |
} | |
if (WSmodule) { | |
WSserver = new (WSmodule.WebSocketServer)({ | |
noServer: true, | |
autoAcceptConnections: false, | |
perMessageDeflate: true | |
}); | |
WSserver.connections = []; | |
HTTPserver.on("upgrade", WShandle); | |
log(`WS: Server Started`); | |
} | |
// Error Handle | |
process.on("uncaughtException", e => { | |
log.error(e.stack); | |
}); | |
// REPL | |
if (require.main === module && conf.REPL) | |
require("repl").start(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment