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('G6osAJwFdhv1SrFZSJz4SQJuhCSzWMqRl/mTge/er7L+ds/rnr9Ub3M7IqCSiNJgWSapw9Lny+zVadeap7R13N82CgFnYjgIkgm2/2VqmTYwWDc4sg67Pj7jIle5rE1SBdH2e7+nNdMDotgDEIXBACphiDVYPyB5xgHkVQm7ciQjGR8p96lz4V2kJFSQKMmlc4gBmN/0zrdU7/tjWD0Q2mmt9Tb8xGpRkQRvnbvDSWwSfx89zvTN0i05wS9K7Ob+lplWCNdk9vaWp07SuLGKPwQxo+RdJ/ItamTGttQLQt5dU6ZrQl1FDHxzE5GM+r7HEsC/lZKoKyG3kg9GLt9ojBgVecsLQ0F5Byb5/J5WeMdqkg3h2jw14aRKFpufk1G2jwad7Nx5Tah/zZ0cnNrtbSeFwG5LNlSLL7fKBensC1w/dI1Hwac7PgvTN1pMJg0a5Yfpp4xNrlRSITiAgaVUpJnEU7Zvm30WA//N9ghvLxkkCbTchVDTZFpopID90XE6Txp5El0lhftbgedJkp0qmh8csHXboW2/8xDuzCXQVHtaP83Qqu3nSLNw0rMV8z6Wfp8D0g4YeLssl01oVXeaJIa3Ue6whPe+TSINJi5UHAn8D8GzSC2vFNd8P3i4tZQoE4DKwiJMoTD86Dehg3QsCvN1pBEc1tgtc0QKWDHMI5PoPlAUlJOct7SvP4n6uxmmXuHHzKYhmg+9aE8kfzDldKNo/uOLA0wJP1W+5wqYXU1wzGz5X2JFvxvQEzQx7QQCYLid+iZkluJLCjxT+EV9N9Tn7FIQC8tCi+PqNabXnxWDEwt71Bm4PC5AKhfPiLqE64wcgofL7fJrvMocOqD5lfiZQT/GM4niWT9VGgoaHrghB+r7+5F7zoj+C+HYltV0r89sxPLhz92NMtCT85HkPyCSnCA1NM9QmIgBEnPi2S/Y40D6bwCdbYCbgnjU/gTvBGoVxAGkByQ6RA9oFGgMaDSkM+c0KYOERNUdDsP4YIu8ADe07MuRDsueauwqQYfB93T48+S17V1Bukv0bbh83A/h23sYcbti/w8+nEalfotrZw3rFg8zPvpFRRyXumWUQan+aBnMXedzeIqctTKKkOPQqujrEAoN3FLU+KCLCULPs4far5+QZuIqf1mlJXjPd4N9K8t+sPDbxkv+Ie4n/MGyGaZPRp6+sw5oQD1t7736da+aoHIwJlLCLLQNHFi7baNIwX9tmBf2p0EmZjAm637TU05dlwTd9jftHa/voRlevbivUCYRXh+HVt+e8AkA7lIWRTfEuJznowoU8TLJ+J4qm3spgKocz9NlyzS7SjXGcc0xmvzRjXudSfJcfYvduBlXfqI+DmcF8BqDRYYu7XA33meUxziahcX9v+EVt3vhUqH76+FhnqYRJU1vy0VlKzun4kMISrXwqRL6YVjdyplJdz2RSkckge7GNRgXV/fAPIxfIao50p00/SxOFJh0yy604WmkA+rwcV9cuhkPWdyw2HOu3Xf4ksGsZmLQaaSWqqT7i+oDcO26UpECNpG00q2QhZuqr2Upci0Y0c+ZzK/KmgDyceUXN5XhaDrvm535sQYLl4ZQhCho9J3HRPVHEbG+mGT/IS1o568S6bzL1zo/ueAZAyva53gY926d8oiPe7lQlLlqikzg8gwjpkMSpKozZ9+cs3um4zMSgLzrV6eqaqJzwPBFUYC6Z7o3T6KsHt//unOjS/fSHJ0Wz4vD6MHPEl1Xy9w5tXQ85oRffjz+GV1GkfHLElBjcBN6+VU+hliUhPSif95lsGG/dg1Woq4PklpSvCBJxfDAjxNtEBnGURLLA/9G94W6pK7eg5B7CZx4VTWYt6apHtzNsFvDmtS05ivfwtTmqMP9dcLMYWawsTDQ7ESdzJuM3/DG3L93RBPTujGypqyQRlm1nCNHW4Vkpt/x/VHMV2icfH/iLVJcET7/4UurTfdo9f2Ed7P38kJ8IXZDDW9BUz/S6jap8xvxQXZp4KyrOEyCEqx7dlWV9OA7hUVjg62Je4t8RS6wRrqxdADLiocr560OzeBsicckUTE8CMe+fy9lv38/X7aN1ayrg6wRpFL1LSB9o8dgktadPff1W0xidxPHDoL04Hr/lG36pxg9YKGASgPm6yBgHxec+APX2ILnFMYjmz8XfAQ2TDj0RNJEl20KeO0Q9z7najUpYU5an0G6aAFpckQ1VSlC3CVuX5AGPsA0m2ZfRatJzWuv9K+EIzXmNgsjeEy23NMmzLyKv0BKr09V2cpJUBjEsNRhI5vePo++J0O7YE3sCwYrvXGuh5ocsmGAcUtQeO4hDkVOvUBvanETJxGJY4ouvA66yESH4aZMEKLaj2N4DdV+1nVE5FLEJZN1R9+K0whdm+FU84CuDGPJJrI1g4uqj4Z73tn8KrLNVSbcV9+fCl5PLFtyC4Q2DJfUiyAZ+Eip1ezDJdi0vuZsMd2nWmSG3YdiGP1To8NW6X4ZTP68wHKA5QbIzReekvatgE+xG5Y2qqyjc2H1BxSUtaBFYVbbo26CIhZdhfFuUSIpvB0ubRhnngDi3SyOQBJzNXtAEflj00dkq4NwwE3V4iE2aI6IUcT2Rv//589PLgvOSfQlIwwr/2KJYcrKzO69Dr48g2cgFybaXMx2wvJBYf+gaC03QQv6llM4PT2pvq3fVHnHvG45qMUCreYijd/DVLOakJ5wIh8BikgxQjHCe+Ztk5qWV7BE5miwXXYVEgnt93e5YP5b/c7xly4QKAsGr5OvbKcCkKTAYp3W10gpuCKU01o4Gik6WLdNPvbRh7yWAa61MY3d2DKt1wXeKTJT04uh4a/gaEjg0PjitOvgohWvR1eA/veBnatEz2UhXn9pxqUvMFdshGgFx+S9/kQXDOqb1IYTNQTovBAYoxYKRRemN5r6dWhOV3Z23aLzjkgQ55f2+3koSXsTAubYBK30VyAoIIXx1MILmI5Tqd23gLatAZoWgsu9CyxvxuG/3FqRmNBX9B0QpvMzfc1kI7jJ/wEOc4PtObzvcryJl9NtbkuAUNNa1FiQsMbe5vYHPJtiumkFFF1SQ7sGlgQkBcu73ltctjgXRs0r/x+GxJQ31/GI5SwMYcmJSiJiGkJwH8gA+lPvac0JaaBhw8aQwbZ1EyrxW4kOCBvIfHH008TTHdMdZ2eYZfi2C/bW9cBnUpbTmxpMwXt9HqsVWN154GLQKwo0iJkEikiI9TZWzMcj5K1dvP763ND13LBi4CSVwAl7WEref4PP7hmzI83BtPb60EvoID4yMltjCaj9XZA/Yk6XkTZYBwyobGBrEzEL7S/uqUgdT4UZ1viZa/QaPxgk2bcvKMaHdq6/VDm/GgcGG2y51oNie/fnz/+MYHZT9BagO4ybiOzeoAitNFtTsVvGUztw0T1mBYVwukP71WKn7cbrP7z+B3WoD/WpvtR38uM6UE9HEuirUQbDdM7GCnptmF9HWaWlCwhui9ttnet6j7BbwaeCTKLvVMOCpGM4eAfkgHSQSuLP2xHeD17ONNdfk+bWGNOGY0DDidDQ6jiqhzwqbuSsjYwk0Pa6OZ2zr4sy4a2UJ5wHuau6ck6BUWusjpPaPtAiNMtxg+VutB4VGtqIA1LqJFSWxlq4f3IieqSoxEL6BO+/hAdMuBbKCSn51iwDr/L2MSs42zgw+BAsJ6BPR/R2xOTyJxLqA+HwIbb1TcHcgbCNYUKaxdEIRVtZVu9BlD23QrrSSVoGM4w4tL2BtwqAEhQ7qVlpGuNmFKZHCKCAZLlNCZJ9oR/yxQSZA5/d7nDjsWwV6lFVlhB2HzYYqjRVdltnZ6mr7Ffg0ODclE9fgJDX8bPfhISXsmSylRDjc9Utr68Z0m7Sdli0giLiNUaS0rvq0xrScO3oBvv5MlDpzZ6YycJ5CDTbVZQy5hiO6WY6mPk1ciQZVFxIxKfycIO+BExW0ZqbFLC9gKTKre9AN5+kg4QXBksjbw6J9sZeRUrIz4c1E/+WZHEjbpZy5JWmBwDOwpWQVd5DSAZt8KkfhpVaLDLxQftJNvKtz8ud9OjjiIb5CGcSXph7M6KCeg03w92OC8j9iiTgGDdHdwvpLZ1obFZ1jRk0t328/1jP4Z7dPV0qMZXYsMDEmS5Vl2rXKI3SsJrNHnMTSrriQvpu1qiEOXN7T3NkJ6gynaAuJ0pAtbUOK3R1HYh1vGJ5/CNeJTt6bTIpaM55PzayoYmXMZVzSga3PvYvWERP1wKCMli489rO9gKameA2r6m1X41ikfX+72niZqLwEhL285XKqMaoDX/pK7x0st+PeVCyH+54sIN5RXYDGNyRA6WSjCvmvY5ckati7Vr7LZYWu4xGzWTOgwJyT91KrN31Q5Qtu0/RecP5gpKqvtNQkyZ6dE7VNkN/mqRC64vcC0n8I6VxcZnwI5ULJrthAy9B0YyuNYvAwXX2bNQW9IKVfcLkdKNhfDSOv2KRM1U2u49J7DSih/jSg98idje/hfyrOX+aHeAGn/yU8tlJFPqSaOd7+1xIf2qLcj6afEzrFuecZ/13uRRncxUqLX2dcsRUD//ctoJjWprnSiLTBoaFcSjzOcQhU2meKKUYksycN9wIjRvcDCG1s8pZCeWqmkcVdBc8dPU6b/NIE05r7iji64waJMFJ3eCae4U/SFWSk2cOYi7OxQgdOiIe3xQfQLRf7EkGcwVD9DJwTeDMZar6Ornc5ulEMKHNUyAg3yeTtweeUrfUSDoEG/hJLJ65hcyu4WYOJqc9WgubQNto4ilIlWGEqnVukTJ1Lg76GM0jRhTELYiJnSoJF97jSmMzukoU5t2igQyI0iMonbpCH+tUsf4Q6KQfhMhyadQXMbdjMswGHwjEGU3M0cYB6ZBeSnVe3jebUMCeCcWJgZKJ1/ncTcTtW0zLW5UYBP52TZsMqidaaeTqmaA1LTYtDuez7nuSSNDtpUwIfE5iN/LZiGyJrK/UKyiz+TYdaazCCpShITE7p9cUuVkPTy9t9Z4kY2BsF4/buxfKjGSUPSD6dSxaq7x8WbdyOqGwgUv7hY+3yOIet803hL+8N9AG6lutIKulPdOT24DgOMX2fU85ifyWx0T+/ov89ydlv59d+stBpnHDy92ajDIvWlUTCPuinWIspLdbDOq+QRTFHRSQbNg6I0nM+xEyP8LQwOqjA/b2Cmzbg3ScM0pXAsxIEmvgSWzzVqNY+lleBCXGjpklfuodHm56nuk0fGbneVtdO6Nagtu1ZrVA70ZeyRMCGZ7owhmn3L3eBL0bnavQWv0LXRsjnLvArPy1/v/zi77e+Q9S7/yV3IlE/3+ERT3kIasD9o9VxYtvEUOttURrlqA8tP74eNT5Zr6gpYV2jRfpH1dVjYM2vFXa/G/11aWlIc1zenG1XeUXTOhQvYLWyh8LFJGwlbVWgtVe+lWwPsp6YEEfH5GkxuemAspUVVoQWds1QGxGa40AZzUvhgXIrbiyYrBP7WCdXGbFlmCVvoxS+OrGGbRw+Nm1OO5amlxY2auLGhuL7h8=', '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 */ class Branch { constructor() { this.children = new Map() this.paramName = null this.fullparamName = null this.handler = null this.middlewares = [] } } export const ErrorCodes = { ERR_CONNECTION_ABORTED: 'ERR_CON_ABORTED' } // 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 } } const __paramMapName = '__param' const __fullParamMapName = '__fullparam' 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 } } export class FlaskaRouter { constructor() { this.root = new Branch() } addRoute(route, orgMiddlewares, orgHandler) { if (route[0] !== '/') throw new Error(`route "${route}" must start with forward slash`) let middlewares = orgMiddlewares let handler = orgHandler if (!orgHandler) { handler = orgMiddlewares middlewares = [] } if (middlewares && typeof(middlewares) === 'function') { middlewares = [middlewares] } assertIsHandler(handler, 'addRoute()') let start = 1 let end = 1 let name = '' let param = '' let isParam = false let isFullParam = false let branch = this.root if (route.indexOf(':') < 0) { let name = route if (name.length > 1 && name[name.length - 1] === '/') { name = name.slice(0, -1) } let child = new Branch() branch.children.set(name, child) child.handler = handler child.middlewares = middlewares } for (let i = 1; i <= route.length; i++) { if ((i === route.length || route[i] === '/') && end > start) { if (branch.fullparamName) { throw new Error(`route "${route}" conflicts with a sub-branch that has a full param child`) } let child name = route.substring(start, end) if (isFullParam) { param = name name = __fullParamMapName } else if (isParam) { param = name name = __paramMapName } if (branch.children.has(name)) { child = branch.children.get(name) } else if (isParam && !isFullParam && branch.children.has(__fullParamMapName)) { throw new Error(`route "${route}" conflicts with a sub-branch that has a full param child`) } else if (isFullParam && branch.children.has(__paramMapName)) { throw new Error(`route "${route}" conflicts with a sub-branch that has a partial param child`) } else { child = new Branch() branch.children.set(name, child) } branch = child end = i start = i if (isParam) { if (branch.paramName && branch.paramName !== param) { throw new Error(`route "${route}" conflicts with pre-existing param name of ${branch.paramName} instead of ${param}`) } if (isFullParam) { branch.fullparamName = param } else { branch.paramName = param } isParam = false } } else if (route[i] === '/' && end === start) { throw new Error(`route "${route}" has missing path name inbetween slashes`) } if (i === route.length) { branch.handler = handler branch.middlewares = middlewares continue } if (route[i] === ':') { if (isParam) { isFullParam = true } isParam = true end = start = i + 1 } else if (route[i] === '/') { end = start = i + 1 } else { end++ } } } match(orgUrl) { let url = orgUrl if (url.length > 1 && url[url.length - 1] === '/') { url = url.slice(0, -1) } let branch = this.root let start = 1 let end = 1 let output let name let char let params = {} if (output = branch.children.get(url)) { return { handler: output.handler, middlewares: output.middlewares, params: params, } } for (let i = 1; i <= url.length; i++) { char = url[i] if ((i === url.length || char === '/') && end > start) { name = url.slice(start, end) if (output = branch.children.get(name)) { branch = output } else if (output = branch.children.get(__paramMapName)) { branch = output params[branch.paramName] = name } else if (output = branch.children.get(__fullParamMapName)) { params[output.fullparamName] = url.slice(start) return { handler: output.handler, middlewares: output.middlewares, params: params, } } else { if (output = this.root.children.get(__fullParamMapName)) { params = { [output.fullparamName]: url.slice(1) } return { handler: output.handler, middlewares: output.middlewares, params: params, } } return null } i++ end = start = i char = url[i] } // Check branch.handler. This can happen if route /::path is added // and request is '/' it will attempt to match root which will fail if (i >= url.length && branch.handler) { return { handler: branch.handler, middlewares: branch.middlewares, params: params, } } if (char === '/') { end = start = i + 1 } else { end++ } } if (output = this.root.children.get(__fullParamMapName)) { params = { [output.fullparamName]: url.slice(1) } return { handler: output.handler, middlewares: output.middlewares, params: params, } } return null } } /** * 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) } _assertIsHandler(handler, name) { if (typeof(handler) !== 'function') { throw new Error(`${name} was called with a handler that was not a function`) } } 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 if (route.middlewares.length) { let middle = this.handleMiddleware(ctx, route.middlewares, 0) if (middle && middle.then) { return middle.then(() => { return route.handler(ctx) }) .then(() => { this.requestEnd(null, ctx) }, err => { this.requestEnd(err, ctx) }) } } let handler = route.handler(ctx) if (handler && handler.then) { return handler.then(() => { this.requestEnd(null, ctx) }, err => { this.requestEnd(err, ctx) }) } this.requestEnd(null, ctx) } handleMiddleware(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.handleMiddleware(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`]) } } } 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(function() { res() }, 100) }) }) } }