1233 lines
42 KiB
JavaScript
1233 lines
42 KiB
JavaScript
import os from 'os'
|
|
import crypto from 'crypto'
|
|
import path from 'path'
|
|
import http from 'http'
|
|
import stream from 'stream'
|
|
import fs from 'fs/promises'
|
|
import fsSync from 'fs'
|
|
import { URL } from 'url'
|
|
import { Buffer } from 'buffer'
|
|
import zlib from 'zlib'
|
|
|
|
function getDb() {
|
|
// Take from @thi-ng/mime which is a reduced mime-db
|
|
let MimeTypeDbRaw = Buffer.from('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', 'base64')
|
|
let inbetween = JSON.parse(zlib.brotliDecompressSync(MimeTypeDbRaw).toString())
|
|
|
|
let res = {}
|
|
for (let groupID in inbetween) {
|
|
const group = inbetween[groupID];
|
|
for (let type in group) {
|
|
const mime = groupID + "/" + type;
|
|
for (let e of group[type].split(",")) {
|
|
const isLowPri = e[0] === "*";
|
|
const ext = isLowPri ? e.substr(1) : e;
|
|
let coll = res[ext];
|
|
!coll && (coll = res[ext] = []);
|
|
isLowPri ? coll.push(mime) : coll.unshift(mime);
|
|
}
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
export const MimeTypeDb = getDb()
|
|
|
|
/**
|
|
* Router
|
|
*/
|
|
|
|
// Taken from https://github.com/nfp-projects/koa-lite/blob/master/lib/statuses.js
|
|
const statuses = {
|
|
100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 103: 'Early Hints',
|
|
200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 208: 'Already Reported', 226: 'IM Used',
|
|
300: 'Multiple Choices', 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 306: '(Unused)', 307: 'Temporary Redirect', 308: 'Permanent Redirect',
|
|
400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 406: 'Not Acceptable', 407: 'Proxy Authentication Required', 408: 'Request Timeout', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 416: 'Range Not Satisfiable', 417: 'Expectation Failed', 418: 'I\'m a teapot', 421: 'Misdirected Request', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 425: 'Too Early', 426: 'Upgrade Required', 428: 'Precondition Required', 429: 'Too Many Requests', 431: 'Request Header Fields Too Large', 451: 'Unavailable For Legal Reasons',
|
|
500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout', 505: 'HTTP Version Not Supported', 506: 'Variant Also Negotiates', 507: 'Insufficient Storage', 508: 'Loop Detected', 509: 'Bandwidth Limit Exceeded', 510: 'Not Extended', 511: 'Network Authentication Required',
|
|
redirect: {
|
|
300: true,
|
|
301: true,
|
|
302: true,
|
|
303: true,
|
|
305: true,
|
|
307: true,
|
|
308: true
|
|
},
|
|
empty: {
|
|
204: true,
|
|
205: true,
|
|
304: true
|
|
}
|
|
}
|
|
|
|
function assertIsHandler(handler, name) {
|
|
if (typeof(handler) !== 'function') {
|
|
throw new Error(`${name} was called with a handler that was not a function`)
|
|
}
|
|
}
|
|
|
|
export function QueryHandler() {
|
|
return function(ctx) {
|
|
ctx.query = (new URL(ctx.req.url, 'http://localhost')).searchParams
|
|
}
|
|
}
|
|
|
|
export function JsonHandler(org = {}) {
|
|
let opts = {
|
|
sizeLimit: org.sizeLimit || (10 * 1024)
|
|
}
|
|
return function(ctx) {
|
|
const buffers = [];
|
|
let size = 0
|
|
|
|
return new Promise(function(res, rej) {
|
|
ctx.req.on('data', chunk => {
|
|
size += chunk.length
|
|
if (size > opts.sizeLimit) {
|
|
return rej(new HttpError(413, `Body limit of ${opts.sizeLimit} bytes reached with ${size} bytes`))
|
|
}
|
|
|
|
buffers.push(chunk)
|
|
})
|
|
ctx.req.on('end', () => {
|
|
res()
|
|
})
|
|
})
|
|
.then(function() {
|
|
if (!buffers.length) {
|
|
ctx.req.body = {}
|
|
return
|
|
}
|
|
|
|
const data = Buffer.concat(buffers).toString();
|
|
|
|
try {
|
|
ctx.req.body = JSON.parse(data)
|
|
} catch (err) {
|
|
return Promise.reject(new HttpError(400, `Invalid JSON: ${err.message}`, {
|
|
status: 400,
|
|
message: `Invalid JSON: ${err.message}`,
|
|
request: data,
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export function CorsHandler(opts = {}) {
|
|
const options = {
|
|
allowedMethods: opts.allowedMethods || 'GET,HEAD,PUT,POST,DELETE,PATCH',
|
|
allowedOrigins: opts.allowedOrigins || [],
|
|
allowedHeaders: opts.allowedHeaders,
|
|
credentials: opts.credentials || false,
|
|
exposeHeaders: opts.exposeHeaders || '',
|
|
maxAge: opts.maxAge || '',
|
|
}
|
|
const allowAll = options.allowedOrigins.includes('*')
|
|
|
|
return function(ctx) {
|
|
// Always add vary header on origin. Prevent caches from
|
|
// accidentally caching wrong preflight request
|
|
ctx.headers['Vary'] = 'Origin'
|
|
|
|
// Set status to 204 if OPTIONS. Just handy for flaska and
|
|
// other checking.
|
|
if (ctx.method === 'OPTIONS') {
|
|
ctx.status = 204
|
|
}
|
|
|
|
// Check origin is specified. Nothing needs to be done if
|
|
// there is no origin or it doesn't match
|
|
let origin = ctx.req.headers['origin']
|
|
if (!origin || (!allowAll && !options.allowedOrigins.includes(origin))) {
|
|
return
|
|
}
|
|
|
|
// Set some extra headers if this is a pre-flight. Most of
|
|
// these are not needed during a normal request.
|
|
if (ctx.method === 'OPTIONS') {
|
|
if (!ctx.req.headers['access-control-request-method']) {
|
|
return
|
|
}
|
|
|
|
if (options.maxAge) {
|
|
ctx.headers['Access-Control-Max-Age'] = options.maxAge
|
|
}
|
|
|
|
let reqHeaders = options.allowedHeaders
|
|
|| ctx.req.headers['access-control-request-headers']
|
|
if (reqHeaders && options.allowedHeaders !== false) {
|
|
ctx.headers['Access-Control-Allow-Headers'] = reqHeaders
|
|
}
|
|
ctx.headers['Access-Control-Allow-Methods'] = options.allowedMethods
|
|
} else {
|
|
if (options.exposeHeaders) {
|
|
ctx.headers['Access-Control-Expose-Headers'] = options.exposeHeaders
|
|
}
|
|
}
|
|
|
|
ctx.headers['Access-Control-Allow-Origin'] = origin
|
|
|
|
if (options.credentials) {
|
|
ctx.headers['Access-Control-Allow-Credentials'] = 'true'
|
|
}
|
|
}
|
|
}
|
|
|
|
export function FormidableHandler(formidable, org = {}) {
|
|
let lastDateString = ''
|
|
let incrementor = 1
|
|
|
|
let opts = {
|
|
rename: true,
|
|
parseFields: org.parseFields || false,
|
|
uploadDir: org.uploadDir || os.tmpdir(),
|
|
filename: org.filename || function(file) {
|
|
let prefix = new Date()
|
|
.toISOString()
|
|
.replace(/-/g, '')
|
|
.replace('T', '_')
|
|
.replace(/:/g, '')
|
|
.replace(/\..+/, '_')
|
|
|
|
// Prevent accidental overwriting if two file uploads with
|
|
// same name get uploaded at exact same second.
|
|
if (prefix === lastDateString) {
|
|
prefix += incrementor.toString().padStart('2', '0') + '_'
|
|
incrementor++
|
|
} else {
|
|
lastDateString = prefix
|
|
incrementor
|
|
}
|
|
|
|
return prefix + file.name
|
|
},
|
|
maxFileSize: org.maxFileSize || 8 * 1024 * 1024,
|
|
maxFieldsSize: org.maxFieldsSize || 10 * 1024,
|
|
maxFields: org.maxFields || 50,
|
|
}
|
|
if (org.rename != null) {
|
|
opts.rename = org.rename
|
|
}
|
|
|
|
// For testing/stubbing purposes
|
|
let rename = formidable.fsRename || fs.rename
|
|
|
|
return function(ctx) {
|
|
let form = formidable.IncomingForm()
|
|
form.uploadDir = opts.uploadDir
|
|
form.maxFileSize = opts.maxFileSize
|
|
form.maxFieldsSize = opts.maxFieldsSize
|
|
form.maxFields = opts.maxFields
|
|
|
|
return new Promise(function(res, rej) {
|
|
form.parse(ctx.req, function(err, fields, files) {
|
|
if (err) return rej(new HttpError(400, err.message))
|
|
|
|
if (opts.parseFields) {
|
|
Object.keys(fields).forEach(function(key) {
|
|
try {
|
|
fields[key] = JSON.parse(fields[key])
|
|
} catch { }
|
|
})
|
|
}
|
|
|
|
ctx.req.body = fields
|
|
ctx.req.files = files
|
|
ctx.req.file = null
|
|
|
|
|
|
if (!ctx.req.files) {
|
|
return res()
|
|
}
|
|
|
|
let keys = Object.keys(files).filter(key => Boolean(ctx.req.files[key]))
|
|
|
|
Promise.all(
|
|
keys.map(key => {
|
|
let filename
|
|
let target
|
|
|
|
try {
|
|
filename = opts.filename(ctx.req.files[key]) || ctx.req.files[key].name
|
|
target = path.join(opts.uploadDir, filename)
|
|
} catch (err) {
|
|
return Promise.reject(err)
|
|
}
|
|
|
|
return rename(ctx.req.files[key].path, target)
|
|
.then(function() {
|
|
if (!ctx.req.files[key].type || ctx.req.files[key].type === 'application/octet-stream') {
|
|
let found = MimeTypeDb[path.extname(filename).slice(1)]
|
|
ctx.req.files[key].type = found && found[0] || 'application/octet-stream'
|
|
}
|
|
ctx.req.files[key].path = target
|
|
ctx.req.files[key].filename = filename
|
|
})
|
|
})
|
|
)
|
|
.then(() => {
|
|
if (keys.length === 1 && keys[0] === 'file') {
|
|
ctx.req.file = ctx.req.files.file
|
|
}
|
|
res()
|
|
}, rej)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
export class HttpError extends Error {
|
|
constructor(statusCode, message, body = null) {
|
|
super(message);
|
|
|
|
Error.captureStackTrace(this, HttpError);
|
|
|
|
let proto = Object.getPrototypeOf(this);
|
|
proto.name = 'HttpError';
|
|
|
|
this.status = statusCode
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
const RangeRegexTester = /bytes=(\d+)-(\d+)?/
|
|
|
|
export class FileResponse {
|
|
constructor(filepath, stat) {
|
|
this.filepath = filepath
|
|
this.stat = stat
|
|
}
|
|
|
|
handleRequest(ctx, useFs = fsSync) {
|
|
let etag = '"' + this.stat.ino + '-' + this.stat.size + '-' + this.stat.mtime.getTime() + '"'
|
|
let lastModified = this.stat.mtime.toUTCString()
|
|
let lastModifiedRounded = Date.parse(lastModified)
|
|
|
|
if (ctx.req.headers['if-match'] && ctx.req.headers['if-match'] !== etag) {
|
|
throw new HttpError(412, `Request if-match pre-condition failed`)
|
|
}
|
|
if (ctx.req.headers['if-unmodified-since']) {
|
|
let check = Date.parse(ctx.req.headers['if-unmodified-since'])
|
|
if (!check || check < lastModifiedRounded) {
|
|
throw new HttpError(412, `Request if-unmodified-since pre-condition failed`)
|
|
}
|
|
}
|
|
|
|
ctx.headers['Etag'] = etag
|
|
|
|
if (ctx.req.headers['if-none-match']) {
|
|
let split = ctx.req.headers['if-none-match'].split(',')
|
|
for (let check of split) {
|
|
if (check.trim() === etag) {
|
|
ctx.status = 304
|
|
return null
|
|
}
|
|
}
|
|
} else if (ctx.req.headers['if-modified-since']) {
|
|
let check = Date.parse(ctx.req.headers['if-modified-since'])
|
|
if (check >= lastModifiedRounded) {
|
|
ctx.status = 304
|
|
return null
|
|
}
|
|
}
|
|
|
|
let readOptions = {}
|
|
let size = this.stat.size
|
|
|
|
if (ctx.req.headers['range']) {
|
|
let match = RangeRegexTester.exec(ctx.req.headers['range'])
|
|
|
|
let ifRange = ctx.req.headers['if-range']
|
|
if (ifRange) {
|
|
if (ifRange[0] === '"' && ifRange !== etag) {
|
|
match = null
|
|
} else if (ifRange[0] !== '"') {
|
|
let check = Date.parse(ifRange)
|
|
if (!check || check < lastModifiedRounded) {
|
|
match = null
|
|
}
|
|
}
|
|
}
|
|
|
|
if (match) {
|
|
let start = Number(match[1])
|
|
let end = size - 1
|
|
if (match[2]) {
|
|
end = Math.min(Number(match[2]), size - 1)
|
|
}
|
|
|
|
if (start >= size) {
|
|
throw new HttpError(416, `Out of range start ${start} outside of ${size} bounds`)
|
|
}
|
|
|
|
if (start <= end) {
|
|
size = end - start + 1
|
|
readOptions.start = start
|
|
readOptions.end = end
|
|
ctx.headers['Content-Range'] = start + '-' + end + '/' + this.stat.size
|
|
ctx.status = 206
|
|
}
|
|
}
|
|
}
|
|
|
|
let ext = path.extname(this.filepath).slice(1)
|
|
let found = MimeTypeDb[ext]
|
|
if (found) {
|
|
ctx.type = found[found.length - 1]
|
|
}
|
|
|
|
ctx.headers['Last-Modified'] = lastModified
|
|
ctx.headers['Content-Length'] = size
|
|
if (ctx.method !== 'HEAD') {
|
|
let stream = useFs.createReadStream(this.filepath, readOptions)
|
|
return stream
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
/*
|
|
* --- Router ---
|
|
*/
|
|
|
|
class RouterError extends Error {
|
|
constructor(route1, route2, ...params) {
|
|
// Pass remaining arguments (including vendor specific ones) to parent constructor
|
|
super(...params);
|
|
|
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
|
if (Error.captureStackTrace) {
|
|
Error.captureStackTrace(this, RouterError);
|
|
}
|
|
|
|
this.name = "RouterError";
|
|
this.routeA = route1
|
|
this.routeB = route2
|
|
}
|
|
}
|
|
|
|
function Child(split, x, i) {
|
|
this.path = null
|
|
this.isParams = split[x].isParams ? split[x].word : null
|
|
this.isFullParams = split[x].isFullParams ? split[x].word : null
|
|
this.paramVarName = split[x].paramVarName ?? null
|
|
this.char = !this.isParams && !this.isFullParams ? split[x].word[i] || '/' : null
|
|
this.count = 0
|
|
this.children = []
|
|
}
|
|
|
|
const regParamPrefix = /^::?/
|
|
const regCleanNonAschii = /(?![a-zA-Z_])./g
|
|
const regCleanRest = /_+/g
|
|
const regStarDoubleParam = /::[^:/]+/g
|
|
const regStarSingleParam = /:[^:/]+/g
|
|
const spaces = ' '
|
|
|
|
export class FlaskaRouter {
|
|
constructor() {
|
|
this.paths = []
|
|
this.registeredPaths = new Set()
|
|
}
|
|
|
|
addRoute(path, middlewares, orgHandler) {
|
|
if (path[0] !== '/')
|
|
throw new RouterError(null, null, `addRoute("${path}") path must start with forward slash`)
|
|
|
|
let cleaned = path
|
|
if (cleaned.indexOf('/:') >= 0) {
|
|
cleaned = cleaned.replace(regStarDoubleParam, '**').replace(regStarSingleParam, '*')
|
|
if (cleaned.indexOf(':') > 0) {
|
|
throw new RouterError(null, null, `addRoute("${path}") path has missing name or word between two forward slashes`)
|
|
}
|
|
if (cleaned.indexOf('**/') > 0) {
|
|
throw new RouterError(null, null, `addRoute("${path}") cannot add anything after a full param route`)
|
|
}
|
|
}
|
|
if (cleaned.indexOf('//') >= 0) {
|
|
throw new RouterError(null, null, `addRoute("${path}") path has missing name or word between two forward slashes`)
|
|
}
|
|
|
|
let size = this.registeredPaths.size
|
|
if (this.registeredPaths.add(cleaned).size === size) {
|
|
throw new RouterError(null, null, `addRoute("${path}") found an existing route with same path of ${cleaned}`)
|
|
}
|
|
|
|
let handlers = []
|
|
if (Array.isArray(middlewares)) {
|
|
handlers.push(...middlewares)
|
|
if (typeof(orgHandler) !== 'function') {
|
|
throw new RouterError(orgHandler, null, `addRoute("${path}") was called with a handler that was not a function`)
|
|
}
|
|
} else {
|
|
handlers.push(middlewares)
|
|
}
|
|
if (orgHandler) {
|
|
handlers.push(orgHandler)
|
|
}
|
|
for (let handler of handlers) {
|
|
if (typeof(handler) !== 'function') {
|
|
throw new RouterError(handler, null, `addRoute("${path}") was called with a handler that was not a function`)
|
|
}
|
|
}
|
|
|
|
this.paths.push({
|
|
path,
|
|
handlers
|
|
})
|
|
}
|
|
|
|
__buildChild(x, i, splitPaths) {
|
|
let splitPath = splitPaths[0]
|
|
let letter = new Child(splitPath.split, x, i)
|
|
|
|
let consume = []
|
|
if (splitPath.split.length === x + 1
|
|
&& (splitPath.split[x].isParams
|
|
|| splitPath.split[x].isFullParams
|
|
|| splitPath.split[x].word.length === i + 1)) {
|
|
letter.path = splitPath.entry
|
|
letter.count += 1
|
|
} else {
|
|
consume = [splitPath]
|
|
}
|
|
|
|
for (let y = 1; y < splitPaths.length; y++) {
|
|
let checkPath = splitPaths[y]
|
|
if (!checkPath.split[x]
|
|
|| checkPath.split[x].isParams !== splitPath.split[x].isParams
|
|
|| checkPath.split[x].isFullParams !== splitPath.split[x].isFullParams
|
|
|| !checkPath.split[x].isParams
|
|
&& !checkPath.split[x].isFullParams
|
|
&& (checkPath.split[x].word[i] || '/') !== letter.char) break
|
|
consume.push(checkPath)
|
|
}
|
|
|
|
letter.count += consume.length
|
|
if (splitPath.split[x].word.length === i || splitPath.split[x].isParams || splitPath.split[x].isFullParams) {
|
|
x++
|
|
i = -1
|
|
}
|
|
while (consume.length) {
|
|
letter.children.push(this.__buildChild(x, i + 1, consume))
|
|
consume.splice(0, letter.children[letter.children.length - 1].count)
|
|
}
|
|
return letter
|
|
}
|
|
|
|
__buildTree(splitPaths) {
|
|
let builder = []
|
|
while (splitPaths.length) {
|
|
builder.push(this.__buildChild(0, 0, splitPaths))
|
|
splitPaths.splice(0, builder[builder.length - 1].count)
|
|
}
|
|
return builder
|
|
}
|
|
|
|
__splitAndSortPaths(paths, separateStatic = true) {
|
|
let staticPaths = new Map()
|
|
let paramsPaths = []
|
|
let collator = new Intl.Collator('en', { sensitivity: 'accent' });
|
|
let sealed = Object.seal({})
|
|
|
|
paths.forEach(function(entry) {
|
|
if (entry.path[0] !== '/') throw new RouterError(entry, null, 'Specified route was missing forward slash at start')
|
|
|
|
// Collect static paths separately
|
|
if (entry.path.indexOf('/:') < 0 && separateStatic) {
|
|
return staticPaths.set(entry.path, {
|
|
path: entry,
|
|
params: sealed,
|
|
})
|
|
}
|
|
|
|
// Collect params path separately
|
|
paramsPaths.push({
|
|
split: entry.path.slice(1).split(/\//g).map(function(word) {
|
|
let actualWord = word.replace(regParamPrefix, '')
|
|
return {
|
|
word: actualWord,
|
|
isParams: word[0] === ':' && word[1] !== ':',
|
|
isFullParams: word[0] === ':' && word[1] === ':',
|
|
paramVarName: word[0] === ':'
|
|
? actualWord.replace(regCleanNonAschii, '_').replace(regCleanRest, '_')
|
|
: null
|
|
}
|
|
}),
|
|
entry,
|
|
})
|
|
})
|
|
paramsPaths.sort(function(aGroup, bGroup) {
|
|
let length = Math.max(aGroup.split.length, bGroup.split.length)
|
|
for (let x = 0; x < length; x++) {
|
|
let a = aGroup.split[x]
|
|
let b = bGroup.split[x]
|
|
if (!a) return -1
|
|
if (!b) return 1
|
|
// Full params go last
|
|
if (a.isFullParams && b.isFullParams) throw new RouterError(aGroup.entry, bGroup.entry, 'Two full path routes found on same level')
|
|
if (a.isFullParams) return 1
|
|
if (b.isFullParams) return -1
|
|
// Params go second last
|
|
if (a.isParams && !b.isParams) return 1
|
|
if (!a.isParams && b.isParams) return -1
|
|
// otherwise sort alphabetically if not identical
|
|
if (a.word !== b.word) return collator.compare(a.word, b.word)
|
|
}
|
|
throw new RouterError(aGroup, bGroup, 'Two identical paths were found')
|
|
})
|
|
|
|
return {
|
|
staticPaths,
|
|
paramsPaths,
|
|
}
|
|
}
|
|
|
|
|
|
__getIndex(offset, additions, params) {
|
|
return (offset + additions)
|
|
+ (params.length
|
|
? ' + ' + params.map(a => `offset${a[1]}`).join(' + ')
|
|
: '')
|
|
}
|
|
|
|
__treeIntoCompiledCodeReturnPath(indentString, paths, branch, params, pathParamMapIndex) {
|
|
let pathIndex = paths.indexOf(branch.path)
|
|
if (pathIndex < 0) {
|
|
throw new RouterError(branch.path, null, 'InternalError: Specified path was not found in paths')
|
|
}
|
|
let mapIndex = pathParamMapIndex.size + 1
|
|
pathParamMapIndex.set(mapIndex, pathIndex)
|
|
let output = '\n' + indentString + `return {`
|
|
output += '\n' + indentString + ` path: paths_${mapIndex},`
|
|
if (params.length) {
|
|
output += '\n' + indentString + ` params: {`
|
|
for (let param of params) {
|
|
output += '\n' + indentString + ` ${param[0]}: s${param[1]},`
|
|
}
|
|
output += '\n' + indentString + ` },`
|
|
} else {
|
|
output += '\n' + indentString + ` {},`
|
|
}
|
|
output += '\n' + indentString + `}`
|
|
return output
|
|
}
|
|
|
|
__treeIntoCompiledCodeBranch(paths, branches, indent = 0, params = [], pathParamMapIndex) {
|
|
let output = ''
|
|
let indentation = spaces.slice(0, (indent - params.length) * 2)
|
|
let addEndBracket = true
|
|
|
|
for (let i = 0; i < branches.length; i++) {
|
|
let branch = branches[i]
|
|
if (i > 0) {
|
|
if (!branch.isParams && !branch.isFullParams) {
|
|
output += ' else '
|
|
} else {
|
|
// output += '} //'
|
|
output += '\n' + indentation
|
|
}
|
|
}
|
|
|
|
if (!branch.isParams && !branch.isFullParams) {
|
|
output += `if (str.charCodeAt(${this.__getIndex(indent, 0, params)}) === ${branch.char.charCodeAt(0)}) { // ${branch.char}`
|
|
|
|
if (branch.path) {
|
|
output += '\n' + indentation + ` if (strLength === ${this.__getIndex(indent, 1, params)}) {`
|
|
output += this.__treeIntoCompiledCodeReturnPath(indentation + ' ', paths, branch, params, pathParamMapIndex)
|
|
output += '\n' + indentation + ` }`
|
|
}
|
|
} else {
|
|
addEndBracket = false
|
|
let paramVarName = (params.length + 1) + '_' + branch.paramVarName
|
|
output += `let s${paramVarName} = str.slice(${this.__getIndex(indent, 0, params)}${branch.isFullParams ? '' : `, str.indexOf('/', ${this.__getIndex(indent, 0, params)}) >>> 0`})`
|
|
output += '\n' + indentation + `let offset${paramVarName} = s${paramVarName}.length`
|
|
output += '\n' + indentation
|
|
params.push([branch.isParams || branch.isFullParams, paramVarName])
|
|
|
|
if (branch.isFullParams) {
|
|
output += this.__treeIntoCompiledCodeReturnPath(indentation, paths, branch, params, pathParamMapIndex)
|
|
} else if (branch.path) {
|
|
output += '\n' + indentation + `if (strLength === ${this.__getIndex(indent, 0, params)}) {`
|
|
output += this.__treeIntoCompiledCodeReturnPath(indentation + ' ', paths, branch, params, pathParamMapIndex)
|
|
output += '\n' + indentation + `}`
|
|
}
|
|
}
|
|
|
|
if (branch.children.length) {
|
|
if (branch.path) {
|
|
output += ' else '
|
|
} else {
|
|
output += '\n' + indentation + ' '
|
|
}
|
|
output += this.__treeIntoCompiledCodeBranch(paths, branch.children, indent + 1, params.slice(), pathParamMapIndex)
|
|
}
|
|
if (addEndBracket) {
|
|
output += '\n' + indentation + '} '
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
__treeIntoCompiledCodeClosure(paths, tree, staticList) {
|
|
let pathParamMapIndex = new Map()
|
|
let output = ''
|
|
let prefix = ''
|
|
if (staticList.size > 0) {
|
|
output += '\n let checkStatic = staticList.get(str)'
|
|
output += '\n if(checkStatic) {'
|
|
output += '\n return checkStatic'
|
|
output += '\n }'
|
|
}
|
|
if (tree.length) {
|
|
output += '\n let strLength = str.length'
|
|
output += '\n ' + this.__treeIntoCompiledCodeBranch(paths, tree, 1, [], pathParamMapIndex)
|
|
|
|
}
|
|
output += '\n return null'
|
|
output += '\n}'
|
|
pathParamMapIndex.forEach(function (val, key) {
|
|
prefix += `let paths_${key} = paths[${val}]\n`
|
|
})
|
|
output = prefix + 'return function flaskaFastRouter(str) {\n "use strict";' + output
|
|
|
|
return new Function('paths', 'staticList', output)(paths, staticList)
|
|
}
|
|
|
|
compile() {
|
|
let splitPaths = this.__splitAndSortPaths(this.paths)
|
|
let tree = this.__buildTree(splitPaths.paramsPaths.slice())
|
|
this.match = this.__treeIntoCompiledCodeClosure(this.paths, tree, splitPaths.staticPaths)
|
|
}
|
|
|
|
match(url) {
|
|
this.compile()
|
|
return this.match(url)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flaska
|
|
*/
|
|
export class Flaska {
|
|
constructor(opts = {}, orgHttp = http, orgStream = stream) {
|
|
this._before = []
|
|
this._beforeCompiled = null
|
|
this._beforeAsync = []
|
|
this._beforeAsyncCompiled = null
|
|
this._after = []
|
|
this._afterCompiled = null
|
|
this._afterAsync = []
|
|
this._afterAsyncCompiled = null
|
|
this._on404 = function(ctx) {
|
|
if (ctx.body == null && ctx.status !== 204) {
|
|
ctx.status = 404
|
|
ctx.body = {
|
|
status: 404,
|
|
message: statuses[404],
|
|
}
|
|
}
|
|
}
|
|
this._backuperror = this._onerror = function(err, ctx) {
|
|
ctx.log.error(err)
|
|
if (err instanceof HttpError) {
|
|
ctx.status = err.status
|
|
ctx.body = err.body || {
|
|
status: err.status,
|
|
message: statuses[err.status] || statuses[500],
|
|
}
|
|
} else {
|
|
ctx.status = 500
|
|
ctx.body = {
|
|
status: 500,
|
|
message: statuses[500],
|
|
}
|
|
}
|
|
}
|
|
this._onreqerror = function(err, ctx) {
|
|
if (err.message !== 'aborted') {
|
|
ctx.log.error(err)
|
|
ctx.res.statusCode = ctx.statusCode = 400
|
|
}
|
|
ctx.res.end()
|
|
}
|
|
this._onreserror = function(err, ctx) {
|
|
ctx.log.error(err)
|
|
}
|
|
|
|
let options = {
|
|
defaultHeaders: opts.defaultHeaders || {
|
|
'Server': 'Flaska',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'`,
|
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
|
},
|
|
log: opts.log || {
|
|
fatal: console.error.bind(console),
|
|
error: console.error.bind(console),
|
|
warn: console.log.bind(console),
|
|
info: console.log.bind(console),
|
|
debug: console.debug.bind(console),
|
|
trace: console.debug.bind(console),
|
|
log: console.log.bind(console),
|
|
},
|
|
nonce: opts.nonce || [],
|
|
nonceCacheLength: opts.nonceCacheLength || 25
|
|
}
|
|
|
|
if (opts.appendHeaders) {
|
|
let appendKeys = Object.keys(opts.appendHeaders)
|
|
for (let key of appendKeys) {
|
|
options.defaultHeaders[key] = opts.appendHeaders[key]
|
|
}
|
|
}
|
|
|
|
if (!options.defaultHeaders && options.nonce.length) {
|
|
// throw error
|
|
}
|
|
|
|
let headerKeys = Object.keys(options.defaultHeaders)
|
|
let constructFunction = ''
|
|
|
|
if (options.nonce.length) {
|
|
this._nonces = new Array(options.nonceCacheLength)
|
|
this._noncesIndex = this._nonces.length - 1
|
|
|
|
for (let i = 0; i < this._nonces.length; i++) {
|
|
this._nonces[i] = crypto.randomBytes(16).toString('base64')
|
|
}
|
|
|
|
constructFunction += `
|
|
let nonce = this._nonces[this._noncesIndex] || crypto.randomBytes(16).toString('base64');
|
|
this._noncesIndex--;
|
|
ctx.state.nonce = nonce;
|
|
`
|
|
}
|
|
|
|
constructFunction += 'ctx.headers = {'
|
|
constructFunction += `'Date': new Date().toUTCString(),`
|
|
for (let key of headerKeys) {
|
|
if (key === 'Content-Security-Policy' && options.nonce.length) {
|
|
let groups = options.defaultHeaders[key].split(';')
|
|
for (let ni = 0; ni < options.nonce.length; ni++) {
|
|
let found = false
|
|
for (let x = 0; x < groups.length; x++) {
|
|
if (groups[x].trim().startsWith(options.nonce[ni])) {
|
|
groups[x] = groups[x].trimEnd() + ` 'nonce-$'`
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if (!found) {
|
|
groups.push(` ${options.nonce[ni]} 'nonce-$'`)
|
|
}
|
|
}
|
|
groups = groups.join(';').replace(/\'/g, "\\'").split('$')
|
|
constructFunction += `'${key}': '${groups.join(`' + nonce + '`)}',`
|
|
} else {
|
|
constructFunction += `'${key}': '${options.defaultHeaders[key].replace(/\'/g, "\\'")}',`
|
|
}
|
|
}
|
|
constructFunction += '};'
|
|
|
|
// console.log(constructFunction)
|
|
|
|
if (options.nonce.length) {
|
|
this.before(new Function('crypto', 'ctx', constructFunction).bind(this, crypto))
|
|
this.after(new Function('crypto', 'ctx', `
|
|
this._noncesIndex = Math.max(this._noncesIndex, -1);
|
|
if (this._noncesIndex < this._nonces.length - 1) {
|
|
this._noncesIndex++;
|
|
this._nonces[this._noncesIndex] = crypto.randomBytes(16).toString('base64');
|
|
}
|
|
`).bind(this, crypto))
|
|
} else {
|
|
this.before(new Function('ctx', constructFunction).bind(this))
|
|
}
|
|
|
|
this.log = options.log
|
|
this.http = orgHttp
|
|
this.stream = orgStream
|
|
this.server = null
|
|
this.routers = {
|
|
'GET': new FlaskaRouter(),
|
|
'POST': new FlaskaRouter(),
|
|
'PUT': new FlaskaRouter(),
|
|
'DELETE': new FlaskaRouter(),
|
|
'OPTIONS': new FlaskaRouter(),
|
|
'PATCH': new FlaskaRouter(),
|
|
}
|
|
// HEAD and GET should be identical
|
|
this.routers['HEAD'] = this.routers['GET']
|
|
|
|
this.get = this.routers.GET.addRoute.bind(this.routers.GET)
|
|
this.post = this.routers.POST.addRoute.bind(this.routers.POST)
|
|
this.put = this.routers.PUT.addRoute.bind(this.routers.PUT)
|
|
this.delete = this.routers.DELETE.addRoute.bind(this.routers.DELETE)
|
|
this.options = this.routers.OPTIONS.addRoute.bind(this.routers.OPTIONS)
|
|
this.patch = this.routers.PATCH.addRoute.bind(this.routers.PATCH)
|
|
}
|
|
|
|
devMode() {
|
|
this._backuperror = this._onerror = function(err, ctx) {
|
|
ctx.log.error(err)
|
|
if (err instanceof HttpError) {
|
|
ctx.status = err.status
|
|
ctx.body = err.body || {
|
|
status: err.status,
|
|
message: `${statuses[err.status] || statuses[500]}: ${err.message}`,
|
|
stack: err.stack || '',
|
|
}
|
|
} else {
|
|
ctx.status = 500
|
|
ctx.body = {
|
|
status: 500,
|
|
message: `${statuses[500]}: ${err.message}`,
|
|
stack: err.stack || '',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
on404(handler) {
|
|
assertIsHandler(handler, 'on404()')
|
|
this._on404 = handler
|
|
}
|
|
|
|
onerror(handler) {
|
|
assertIsHandler(handler, 'onerror()')
|
|
this._onerror = handler
|
|
}
|
|
|
|
onreqerror(handler) {
|
|
assertIsHandler(handler, 'onreqerror()')
|
|
this._onreqerror = handler
|
|
}
|
|
|
|
onreserror(handler) {
|
|
assertIsHandler(handler, 'onreserror()')
|
|
this._onreserror = handler
|
|
}
|
|
|
|
before(handler) {
|
|
assertIsHandler(handler, 'before()')
|
|
this._before.push(handler)
|
|
}
|
|
|
|
beforeAsync(handler) {
|
|
assertIsHandler(handler, 'beforeAsync()')
|
|
this._beforeAsync.push(handler)
|
|
}
|
|
|
|
after(handler) {
|
|
assertIsHandler(handler, 'after()')
|
|
this._after.push(handler)
|
|
}
|
|
|
|
afterAsync(handler) {
|
|
assertIsHandler(handler, 'afterAsync()')
|
|
this._afterAsync.push(handler)
|
|
}
|
|
|
|
__requestStart(req, res) {
|
|
let url = req.url
|
|
let search = ''
|
|
let hasSearch = url.indexOf('?')
|
|
|
|
if (hasSearch > 0) {
|
|
search = url.slice(hasSearch)
|
|
url = url.slice(0, hasSearch)
|
|
}
|
|
|
|
let ctx = {
|
|
log: this.log,
|
|
req: req,
|
|
res: res,
|
|
method: req.method,
|
|
url: url,
|
|
search: search,
|
|
state: {},
|
|
status: 200,
|
|
query: new Map(),
|
|
body: null,
|
|
type: null,
|
|
length: null,
|
|
}
|
|
|
|
req.on('error', (err) => {
|
|
if (err.message === 'aborted') {
|
|
ctx.aborted = true
|
|
}
|
|
this._onreqerror(err, ctx)
|
|
this.__requestEnded(ctx)
|
|
})
|
|
res.on('error', (err) => {
|
|
this._onreserror(err, ctx)
|
|
})
|
|
|
|
res.on('finish', () => {
|
|
this.__requestEnded(ctx)
|
|
})
|
|
|
|
try {
|
|
this._beforeCompiled(ctx)
|
|
if (this._beforeAsyncCompiled) {
|
|
return this._beforeAsyncCompiled(ctx)
|
|
.then(() => {
|
|
this.__requestStartInternal(ctx)
|
|
}).catch(err => {
|
|
this.__requestEnd(err, ctx)
|
|
})
|
|
}
|
|
this.__requestStartInternal(ctx)
|
|
}
|
|
catch (err) {
|
|
this.__requestEnd(err, ctx)
|
|
}
|
|
}
|
|
|
|
__requestStartInternal(ctx) {
|
|
let route = this.routers[ctx.method].match(ctx.url)
|
|
if (!route) {
|
|
let middle = this._on404(ctx)
|
|
if (middle && middle.then) {
|
|
return middle.then(() => {
|
|
this.__requestEnd(null, ctx)
|
|
}, err => {
|
|
this.__requestEnd(err, ctx)
|
|
})
|
|
}
|
|
return this.__requestEnd(null, ctx)
|
|
}
|
|
|
|
ctx.params = route.params
|
|
|
|
let handlers = this.__runHandlers(ctx, route.path.handlers, 0)
|
|
|
|
if (handlers && handlers.then) {
|
|
return handlers.then(() => {
|
|
this.__requestEnd(null, ctx)
|
|
}, err => {
|
|
this.__requestEnd(err, ctx)
|
|
})
|
|
}
|
|
|
|
this.__requestEnd(null, ctx)
|
|
}
|
|
|
|
__runHandlers(ctx, middles, index) {
|
|
for (let i = index; i < middles.length; i++) {
|
|
let res = middles[i](ctx)
|
|
if (res && res.then) {
|
|
return res.then(() => {
|
|
return this.__runHandlers(ctx, middles, i + 1)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
__requestEnd(orgErr, ctx) {
|
|
let err = orgErr
|
|
let handleUsed = Boolean(ctx.body && ctx.body.handleRequest)
|
|
if (handleUsed) {
|
|
try {
|
|
ctx.body = ctx.body.handleRequest(ctx)
|
|
} catch (newErr) {
|
|
err = newErr
|
|
}
|
|
}
|
|
if (err) {
|
|
try {
|
|
this._onerror(err, ctx)
|
|
} catch (err) {
|
|
this._backuperror(err, ctx)
|
|
}
|
|
}
|
|
|
|
if (ctx.res.writableEnded) {
|
|
return
|
|
}
|
|
|
|
if (ctx.body == null && !handleUsed && ctx.status === 200) {
|
|
ctx.status = 204
|
|
}
|
|
|
|
if (statuses.empty[ctx.status]) {
|
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
|
return ctx.res.end()
|
|
}
|
|
|
|
let body = ctx.body
|
|
|
|
// Special handling for files
|
|
if (body && typeof(body.pipe) === 'function') {
|
|
// Be smart when handling file handles, auto detect mime-type
|
|
// based off of the extension of the file.
|
|
if (!ctx.type && body.path) {
|
|
let ext = path.extname(body.path).slice(1)
|
|
if (ext && MimeTypeDb[ext]) {
|
|
let found = MimeTypeDb[ext]
|
|
ctx.type = found[found.length - 1]
|
|
}
|
|
}
|
|
ctx.headers['Content-Type'] = ctx.type || 'application/octet-stream'
|
|
|
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
|
|
|
if (ctx.method !== 'HEAD') {
|
|
return this.stream.pipeline(body, ctx.res, function() { })
|
|
} else {
|
|
try {
|
|
body.destroy()
|
|
} catch { }
|
|
return ctx.res.end()
|
|
}
|
|
}
|
|
|
|
let length = 0
|
|
|
|
if (body instanceof Buffer) {
|
|
length = body.byteLength
|
|
ctx.type = ctx.type || 'application/octet-stream'
|
|
} else if (typeof(body) === 'object' && body) {
|
|
body = JSON.stringify(body)
|
|
length = Buffer.byteLength(body)
|
|
ctx.type = 'application/json; charset=utf-8'
|
|
} else if (body) {
|
|
body = body.toString()
|
|
length = Buffer.byteLength(body)
|
|
ctx.type = ctx.type || 'text/plain; charset=utf-8'
|
|
}
|
|
|
|
if (ctx.type) {
|
|
ctx.headers['Content-Type'] = ctx.type
|
|
}
|
|
if (!ctx.headers['Content-Length']) {
|
|
ctx.headers['Content-Length'] = length
|
|
}
|
|
|
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
|
if (body && ctx.method !== 'HEAD') {
|
|
ctx.res.end(body)
|
|
} else {
|
|
ctx.res.end()
|
|
}
|
|
}
|
|
|
|
__requestEnded(ctx) {
|
|
if (ctx.finished) return
|
|
ctx.finished = true
|
|
|
|
// Prevent accidental leaking
|
|
if (ctx.body && ctx.body.pipe && !ctx.body.closed) {
|
|
ctx.body.destroy()
|
|
}
|
|
this._afterCompiled(ctx)
|
|
if (this._afterAsyncCompiled) {
|
|
return this._afterAsyncCompiled(ctx).then()
|
|
}
|
|
}
|
|
|
|
__compile() {
|
|
let types = ['before', 'after']
|
|
for (let i = 0; i < types.length; i++) {
|
|
let type = types[i]
|
|
let args = ''
|
|
let body = ''
|
|
for (let i = 0; i < this['_' + type].length; i++) {
|
|
args += `a${i}, `
|
|
body += `a${i}(ctx);`
|
|
}
|
|
args += 'ctx'
|
|
|
|
let func = new Function(args, body)
|
|
this[`_${type}Compiled`] = func.bind(this, ...this['_' + type])
|
|
|
|
if (this[`_${type}Async`].length) {
|
|
args = ''
|
|
body = 'return Promise.all(['
|
|
for (let i = 0; i < this[`_${type}Async`].length; i++) {
|
|
args += `a${i}, `
|
|
body += `a${i}(ctx),`
|
|
}
|
|
args += 'ctx'
|
|
body += '])'
|
|
func = new Function(args, body)
|
|
this[`_${type}AsyncCompiled`] = func.bind(this, ...this[`_${type}Async`])
|
|
}
|
|
}
|
|
this.routers.GET.compile()
|
|
this.routers.POST.compile()
|
|
this.routers.PUT.compile()
|
|
this.routers.DELETE.compile()
|
|
this.routers.OPTIONS.compile()
|
|
this.routers.PATCH.compile()
|
|
}
|
|
|
|
__create() {
|
|
this.__compile()
|
|
this.server = this.http.createServer(this.__requestStart.bind(this))
|
|
|
|
this.server.on('connection', function (socket) {
|
|
// Set socket idle timeout in milliseconds
|
|
socket.setTimeout(1000 * 60 * 5) // 5 minutes
|
|
|
|
// Wait for timeout event (socket will emit it when idle timeout elapses)
|
|
socket.on('timeout', function () {
|
|
// Call destroy again
|
|
socket.destroy();
|
|
})
|
|
})
|
|
}
|
|
|
|
listen(port, orgIp, orgcb) {
|
|
let ip = orgIp
|
|
let cb = orgcb
|
|
if (!cb && typeof(orgIp) === 'function') {
|
|
ip = '::'
|
|
cb = orgIp
|
|
}
|
|
if (typeof(port) !== 'number') {
|
|
throw new Error('Flaska.listen() called with non-number in port')
|
|
}
|
|
|
|
this.__create()
|
|
|
|
this.server.listen(port, ip, cb)
|
|
}
|
|
|
|
listenAsync(port, ip = '::') {
|
|
if (typeof(port) !== 'number') {
|
|
return Promise.reject(new Error('Flaska.listen() called with non-number in port'))
|
|
}
|
|
|
|
this.__create()
|
|
|
|
if (this.server.listenAsync && typeof(this.server.listenAsync) === 'function') {
|
|
return this.server.listenAsync(port, ip)
|
|
}
|
|
|
|
return new Promise((res, rej) => {
|
|
this.server.listen(port, ip, function(err) {
|
|
if (err) return rej(err)
|
|
return res()
|
|
})
|
|
})
|
|
}
|
|
|
|
closeAsync() {
|
|
if (!this.server) return Promise.resolve()
|
|
|
|
return new Promise((res, rej) => {
|
|
this.server.close(function(err) {
|
|
if (err) { return rej(err) }
|
|
|
|
// Waiting 0.1 second for it to close down
|
|
setTimeout(res, 100)
|
|
})
|
|
this.server.closeAllConnections()
|
|
})
|
|
}
|
|
}
|