@radatek/microserver 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/microserver.d.ts +34 -22
- package/dist/microserver.js +254 -201
- package/microserver.ts +3803 -0
- package/package.json +9 -4
package/microserver.ts
ADDED
|
@@ -0,0 +1,3803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MicroServer
|
|
3
|
+
* @version 2.2.0
|
|
4
|
+
* @package @radatek/microserver
|
|
5
|
+
* @copyright Darius Kisonas 2022
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import http from 'http'
|
|
10
|
+
import https from 'https'
|
|
11
|
+
import net from 'net'
|
|
12
|
+
import tls from 'tls'
|
|
13
|
+
import querystring from 'querystring'
|
|
14
|
+
import { Readable } from 'stream'
|
|
15
|
+
import fs from 'fs'
|
|
16
|
+
import path, { basename, extname } from 'path'
|
|
17
|
+
import crypto from 'crypto'
|
|
18
|
+
import zlib from 'zlib'
|
|
19
|
+
import { EventEmitter } from 'events'
|
|
20
|
+
|
|
21
|
+
const defaultToken = 'wx)>:ZUqVc+E,u0EmkPz%ZW@TFDY^3vm'
|
|
22
|
+
const defaultExpire = 24 * 60 * 60
|
|
23
|
+
const defaultMaxBodySize = 5 * 1024 * 1024
|
|
24
|
+
const defaultMethods = 'HEAD,GET,POST,PUT,PATCH,DELETE'
|
|
25
|
+
|
|
26
|
+
function NOOP (...args: any[]) { }
|
|
27
|
+
|
|
28
|
+
function isFunction (fn: any): boolean {
|
|
29
|
+
return typeof fn === 'function' && !fn.prototype?.constructor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class Warning extends Error {
|
|
33
|
+
constructor (text: string) {
|
|
34
|
+
super(text)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commonCodes: {[key: number]: string} = { 404: 'Not found', 403: 'Access denied', 422: 'Invalid data'}
|
|
39
|
+
const commonTexts: {[key: string]: number} = {'Not found': 404, 'Access denied': 403, 'Permission denied': 422, 'Invalid data': 422, InvalidData: 422, AccessDenied: 403, NotFound: 404, Failed: 422, OK: 200 }
|
|
40
|
+
export class ResponseError extends Error {
|
|
41
|
+
static getStatusCode(text: string | number | undefined): number { return typeof text === 'number' ? text : text && commonTexts[text] || 500 }
|
|
42
|
+
static getStatusText(text: string | number | undefined): string { return typeof text === 'number' ? commonCodes[text] : text?.toString() || 'Error' }
|
|
43
|
+
statusCode: number
|
|
44
|
+
constructor (text: string | number | undefined, statusCode?: number) {
|
|
45
|
+
super(ResponseError.getStatusText(text || statusCode || 500))
|
|
46
|
+
this.statusCode = ResponseError.getStatusCode(statusCode || text) || 500
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AccessDenied extends ResponseError {
|
|
51
|
+
constructor (text?: string) { super(text, 403) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class InvalidData extends ResponseError {
|
|
55
|
+
constructor (text?: string, type?: string) {
|
|
56
|
+
super(type ? text ? `Invalid ${type}: ${text}` : `Invalid ${type}` : text, 422)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class NotFound extends ResponseError {
|
|
61
|
+
constructor (text?: string) { super(text, 404) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WebSocketError extends Error {
|
|
65
|
+
statusCode: number
|
|
66
|
+
constructor (text?: string, code?: number) {
|
|
67
|
+
super(text)
|
|
68
|
+
this.statusCode = code || 1002
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Routes = () => {[key: string]: Array<any>} | {[key: string]: Array<any>}
|
|
73
|
+
|
|
74
|
+
export abstract class Plugin {
|
|
75
|
+
name?: string
|
|
76
|
+
priority?: number
|
|
77
|
+
handler?(req: ServerRequest, res: ServerResponse, next: Function): Promise<string | object | void> | string | object | void
|
|
78
|
+
routes?(): Promise<Routes> | Routes
|
|
79
|
+
initialise?(): Promise<void> | void
|
|
80
|
+
constructor(router: Router, ...args: any) { }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PluginClass {
|
|
84
|
+
new(router: Router, ...args: any): Plugin
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface UploadFiles {
|
|
88
|
+
list: any[]
|
|
89
|
+
uploadDir: string
|
|
90
|
+
resolve: (res?: any) => void
|
|
91
|
+
done?: boolean
|
|
92
|
+
boundary: string
|
|
93
|
+
chunk?: Buffer
|
|
94
|
+
last?: any
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Extended http.IncomingMessage */
|
|
98
|
+
export class ServerRequest extends http.IncomingMessage {
|
|
99
|
+
/** Request protocol: http or https */
|
|
100
|
+
public protocol!: string
|
|
101
|
+
/** Request client IP */
|
|
102
|
+
public ip?: string
|
|
103
|
+
/** Request from local network */
|
|
104
|
+
public localip?: boolean
|
|
105
|
+
/** Request is secure (https) */
|
|
106
|
+
public secure?: boolean
|
|
107
|
+
/** Request whole path */
|
|
108
|
+
public path: string = '/'
|
|
109
|
+
/** Request pathname */
|
|
110
|
+
public pathname: string = '/'
|
|
111
|
+
/** Base url */
|
|
112
|
+
public baseUrl: string = '/'
|
|
113
|
+
/** Original url */
|
|
114
|
+
public originalUrl?: string
|
|
115
|
+
/** Query parameters */
|
|
116
|
+
public query!: { [key: string]: string }
|
|
117
|
+
/** Router named parameters */
|
|
118
|
+
public params!: { [key: string]: string }
|
|
119
|
+
/** Router named parameters list */
|
|
120
|
+
public paramsList!: string[]
|
|
121
|
+
/** Router */
|
|
122
|
+
public router!: Router
|
|
123
|
+
/** Authentication object */
|
|
124
|
+
public auth?: Auth
|
|
125
|
+
/** Authenticated user info */
|
|
126
|
+
public user?: UserInfo
|
|
127
|
+
/** Model used for request */
|
|
128
|
+
public model?: Model
|
|
129
|
+
/** Authentication token id */
|
|
130
|
+
public tokenId?: string
|
|
131
|
+
|
|
132
|
+
private _body?: { [key: string]: any }
|
|
133
|
+
/** Request raw body */
|
|
134
|
+
public rawBody!: Buffer[]
|
|
135
|
+
/** Request raw body size */
|
|
136
|
+
public rawBodySize!: number
|
|
137
|
+
|
|
138
|
+
private constructor (router: Router) {
|
|
139
|
+
super(new net.Socket())
|
|
140
|
+
this._init(router)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private _init (router: Router) {
|
|
144
|
+
Object.assign(this, {
|
|
145
|
+
router,
|
|
146
|
+
auth: router.auth,
|
|
147
|
+
protocol: 'encrypted' in this.socket && this.socket.encrypted ? 'https' : 'http',
|
|
148
|
+
query: {},
|
|
149
|
+
params: {},
|
|
150
|
+
paramsList: [],
|
|
151
|
+
path: '/',
|
|
152
|
+
pathname: '/',
|
|
153
|
+
baseUrl: '/',
|
|
154
|
+
rawBody: [],
|
|
155
|
+
rawBodySize: 0
|
|
156
|
+
})
|
|
157
|
+
this.updateUrl(this.url || '/')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Update request url */
|
|
161
|
+
updateUrl (url: string) {
|
|
162
|
+
this.url = url
|
|
163
|
+
if (!this.originalUrl)
|
|
164
|
+
this.originalUrl = url
|
|
165
|
+
|
|
166
|
+
const parsedUrl = new URL(url || '/', 'body:/'), pathname = parsedUrl.pathname
|
|
167
|
+
this.pathname = pathname
|
|
168
|
+
this.path = pathname.slice(pathname.lastIndexOf('/'))
|
|
169
|
+
this.baseUrl = pathname.slice(0, pathname.length - this.path.length)
|
|
170
|
+
this.query = {}
|
|
171
|
+
parsedUrl.searchParams.forEach((v, k) => this.query[k] = v)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Rewrite request url */
|
|
175
|
+
rewrite (url: string): void {
|
|
176
|
+
throw new Error('Internal error')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Request body: JSON or POST parameters */
|
|
180
|
+
get body () {
|
|
181
|
+
if (!this._body) {
|
|
182
|
+
if (this.method === 'GET')
|
|
183
|
+
this._body = {}
|
|
184
|
+
else {
|
|
185
|
+
const contentType = this.headers['content-type'] || '',
|
|
186
|
+
charset = contentType.match(/charset=(\S+)/)
|
|
187
|
+
let bodyString = Buffer.concat(this.rawBody).toString((charset ? charset[1] : 'utf8') as BufferEncoding)
|
|
188
|
+
this._body = {}
|
|
189
|
+
if (bodyString.startsWith('{') || bodyString.startsWith('[')) {
|
|
190
|
+
try {
|
|
191
|
+
this._body = JSON.parse(bodyString)
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error('Invalid request format')
|
|
194
|
+
}
|
|
195
|
+
} else if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
|
196
|
+
this._body = querystring.parse(bodyString)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return this._body
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Alias to body */
|
|
204
|
+
get post () {
|
|
205
|
+
return this.body
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private _websocket?: WebSocket
|
|
209
|
+
/** Get websocket */
|
|
210
|
+
get websocket(): WebSocket {
|
|
211
|
+
if (!this._websocket) {
|
|
212
|
+
if (!this.headers.upgrade)
|
|
213
|
+
throw new Error('Invalid WebSocket request')
|
|
214
|
+
this._websocket = new WebSocket(this, {
|
|
215
|
+
permessageDeflate: this.router.server.config.websocketCompress,
|
|
216
|
+
maxPayload: this.router.server.config.websocketMaxPayload || 1024 * 1024,
|
|
217
|
+
maxWindowBits: this.router.server.config.websocketMaxWindowBits || 10
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
if (!this._websocket.ready)
|
|
221
|
+
throw new Error('Invalid WebSocket request')
|
|
222
|
+
return this._websocket
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private _files: UploadFiles | undefined
|
|
226
|
+
/** get files list in request */
|
|
227
|
+
async files (): Promise<any[] | undefined> {
|
|
228
|
+
this.resume()
|
|
229
|
+
delete this.headers.connection
|
|
230
|
+
const files = this._files
|
|
231
|
+
if (files) {
|
|
232
|
+
if (files.resolve !== NOOP)
|
|
233
|
+
throw new Error('Invalid request files usage')
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
files.resolve = err => {
|
|
236
|
+
files.done = true
|
|
237
|
+
files.resolve = NOOP
|
|
238
|
+
if (err) reject(err)
|
|
239
|
+
else resolve(files.list)
|
|
240
|
+
}
|
|
241
|
+
if (files.done)
|
|
242
|
+
files.resolve()
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Decode request body */
|
|
248
|
+
bodyDecode(res: ServerResponse, options: any, next: () => void) {
|
|
249
|
+
const contentType = (this.headers['content-type'] || '').split(';')
|
|
250
|
+
const maxSize = options.maxBodySize || defaultMaxBodySize
|
|
251
|
+
|
|
252
|
+
if (contentType.includes('multipart/form-data')) {
|
|
253
|
+
const chunkParse = (chunk: Buffer) => {
|
|
254
|
+
const files: UploadFiles | undefined = this._files
|
|
255
|
+
if (!files || files.done)
|
|
256
|
+
return
|
|
257
|
+
chunk = files.chunk = files.chunk ? Buffer.concat([files.chunk, chunk]) : chunk
|
|
258
|
+
const p: number = chunk.indexOf(files.boundary) || -1
|
|
259
|
+
if (p >= 0 && chunk.length - p >= 2) {
|
|
260
|
+
if (files.last) {
|
|
261
|
+
if (p > 0)
|
|
262
|
+
files.last.write(chunk.subarray(0, p))
|
|
263
|
+
files.last.srtream.close()
|
|
264
|
+
delete files.last.srtream
|
|
265
|
+
files.last = undefined
|
|
266
|
+
}
|
|
267
|
+
let pe = p + files.boundary.length
|
|
268
|
+
if (chunk[pe] === 13 && chunk[pe + 1] === 10) {
|
|
269
|
+
chunk = files.chunk = chunk.subarray(p)
|
|
270
|
+
// next header
|
|
271
|
+
pe = chunk.indexOf('\r\n\r\n')
|
|
272
|
+
if (pe > 0) { // whole header
|
|
273
|
+
const header = chunk.toString('utf8', files.boundary.length + 2, pe)
|
|
274
|
+
chunk = chunk.subarray(pe + 4)
|
|
275
|
+
const fileInfo = header.match(/content-disposition: ([^\r\n]+)/i)
|
|
276
|
+
const contentType = header.match(/content-type: ([^\r\n;]+)/i)
|
|
277
|
+
let fieldName: string = '', fileName: string = ''
|
|
278
|
+
if (fileInfo)
|
|
279
|
+
fileInfo[1].replace(/(\w+)="?([^";]+)"?/, (_: string, n: string, v: string) => {
|
|
280
|
+
if (n === 'name')
|
|
281
|
+
fieldName = v
|
|
282
|
+
if (n === 'filename')
|
|
283
|
+
fileName = v
|
|
284
|
+
return _
|
|
285
|
+
})
|
|
286
|
+
if (fileName) {
|
|
287
|
+
let file: string
|
|
288
|
+
do {
|
|
289
|
+
file = path.resolve(path.join(files.uploadDir, crypto.randomBytes(16).toString('hex') + '.tmp'))
|
|
290
|
+
} while (fs.existsSync(file))
|
|
291
|
+
files.last = {
|
|
292
|
+
name: fieldName,
|
|
293
|
+
fileName: fileName,
|
|
294
|
+
contentType: contentType && contentType[1],
|
|
295
|
+
file: file,
|
|
296
|
+
stream: fs.createWriteStream(file)
|
|
297
|
+
}
|
|
298
|
+
files.list.push(files.last)
|
|
299
|
+
} else if (fieldName) {
|
|
300
|
+
files.last = {
|
|
301
|
+
name: fieldName,
|
|
302
|
+
stream: {
|
|
303
|
+
write: (chunk: Buffer) => {
|
|
304
|
+
if (!this._body)
|
|
305
|
+
this._body = {}
|
|
306
|
+
this._body[fieldName] = (this._body[fieldName] || '') + chunk.toString()
|
|
307
|
+
},
|
|
308
|
+
close () { }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
files.chunk = undefined
|
|
315
|
+
files.done = true
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
if (chunk.length > 8096) {
|
|
319
|
+
if (files.last)
|
|
320
|
+
files.last.stream.write(chunk.subarray(0, files.boundary.length - 1))
|
|
321
|
+
chunk = files.chunk = chunk.subarray(files.boundary.length - 1)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.pause()
|
|
327
|
+
//res.setHeader('Connection', 'close') // TODO: check if this is needed
|
|
328
|
+
this._body = {}
|
|
329
|
+
const files = this._files = {
|
|
330
|
+
list: [],
|
|
331
|
+
uploadDir: path.resolve(options.uploadDir || 'upload'),
|
|
332
|
+
resolve: NOOP,
|
|
333
|
+
boundary: ''
|
|
334
|
+
}
|
|
335
|
+
if (!contentType.find(l => {
|
|
336
|
+
const p = l.indexOf('boundary=')
|
|
337
|
+
if (p >= 0) {
|
|
338
|
+
files.boundary = '\r\n--' + l.slice(p + 9).trim()
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
}))
|
|
342
|
+
return res.error(400)
|
|
343
|
+
next()
|
|
344
|
+
this.once('error', () => files.resolve(new ResponseError('Request error')))
|
|
345
|
+
.on('data', chunk => chunkParse(chunk))
|
|
346
|
+
.once('end', () => files.resolve(new Error('Request error')))
|
|
347
|
+
|
|
348
|
+
res.on('finish', () => this._removeTempFiles())
|
|
349
|
+
res.on('error', () => this._removeTempFiles())
|
|
350
|
+
res.on('close', () => this._removeTempFiles())
|
|
351
|
+
} else {
|
|
352
|
+
this.once('error', err => console.error(err))
|
|
353
|
+
.on('data', chunk => {
|
|
354
|
+
this.rawBodySize += chunk.length
|
|
355
|
+
if (this.rawBodySize >= maxSize) {
|
|
356
|
+
this.pause()
|
|
357
|
+
res.setHeader('Connection', 'close')
|
|
358
|
+
res.error(413)
|
|
359
|
+
} else
|
|
360
|
+
this.rawBody.push(chunk)
|
|
361
|
+
})
|
|
362
|
+
.once('end', next)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private _removeTempFiles () {
|
|
367
|
+
if (this._files) {
|
|
368
|
+
if (!this._files.done) {
|
|
369
|
+
this.pause()
|
|
370
|
+
this._files.resolve(new Error('Invalid request files usage'))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this._files.list.forEach(f => {
|
|
374
|
+
if (f.stream)
|
|
375
|
+
f.stream.close()
|
|
376
|
+
if (f.file)
|
|
377
|
+
fs.unlink(f.file, NOOP)
|
|
378
|
+
})
|
|
379
|
+
this._files = undefined
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Extends http.ServerResponse */
|
|
385
|
+
export class ServerResponse extends http.ServerResponse {
|
|
386
|
+
declare req: ServerRequest
|
|
387
|
+
public router!: Router
|
|
388
|
+
public isJson!: boolean
|
|
389
|
+
public headersOnly!: boolean
|
|
390
|
+
|
|
391
|
+
private constructor (router: Router) {
|
|
392
|
+
super(new http.IncomingMessage(new net.Socket()))
|
|
393
|
+
this._init(router)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private _init (router: Router) {
|
|
397
|
+
this.router = router
|
|
398
|
+
this.isJson = false
|
|
399
|
+
this.headersOnly = false
|
|
400
|
+
this.statusCode = 200
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Send error reponse */
|
|
404
|
+
error (error: string | number | Error): void {
|
|
405
|
+
let code: number = 0
|
|
406
|
+
let text: string
|
|
407
|
+
if (error instanceof Error) {
|
|
408
|
+
if ('statusCode' in error)
|
|
409
|
+
code = error.statusCode as number
|
|
410
|
+
text = error.message
|
|
411
|
+
} else if (typeof error === 'number') {
|
|
412
|
+
code = error
|
|
413
|
+
text = commonCodes[code] || 'Error'
|
|
414
|
+
} else
|
|
415
|
+
text = error.toString()
|
|
416
|
+
if (!code && text) {
|
|
417
|
+
code = ResponseError.getStatusCode(text)
|
|
418
|
+
if (!code) {
|
|
419
|
+
const m = text.match(/^(Error|Exception)?([\w ]+)(Error|Exception)?:\s*(.+)/i)
|
|
420
|
+
if (m) {
|
|
421
|
+
const errorId = m[2].toLowerCase()
|
|
422
|
+
code = ResponseError.getStatusCode(m[1])
|
|
423
|
+
text = m[2]
|
|
424
|
+
if (!code) {
|
|
425
|
+
if (errorId.includes('access'))
|
|
426
|
+
code = 403
|
|
427
|
+
else if (errorId.includes('valid') || errorId.includes('case') || errorId.includes('param') || errorId.includes('permission'))
|
|
428
|
+
code = 422
|
|
429
|
+
else if (errorId.includes('busy') || errorId.includes('timeout'))
|
|
430
|
+
code = 408
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
code = code || 500
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
if (code === 400 || code === 413)
|
|
439
|
+
this.setHeader('Connection', 'close')
|
|
440
|
+
|
|
441
|
+
this.statusCode = code || 200
|
|
442
|
+
if (code < 200 || code === 204 || (code >= 300 && code <= 399))
|
|
443
|
+
return this.send()
|
|
444
|
+
|
|
445
|
+
if (this.isJson && (code < 300 || code >= 400))
|
|
446
|
+
this.send({ success: false, error: text ?? (commonCodes[this.statusCode] || http.STATUS_CODES[this.statusCode]) })
|
|
447
|
+
else
|
|
448
|
+
this.send(text != null ? text : (this.statusCode + ' ' + (commonCodes[this.statusCode] || http.STATUS_CODES[this.statusCode])))
|
|
449
|
+
} catch (e) {
|
|
450
|
+
this.statusCode = 500
|
|
451
|
+
this.send('Internal error')
|
|
452
|
+
console.error(e)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Sets Content-Type acording to data and sends response */
|
|
457
|
+
send (data: string | Buffer | Error | Readable | object = ''): void {
|
|
458
|
+
if (data instanceof Readable)
|
|
459
|
+
return (data.pipe(this, {end: true}), void 0)
|
|
460
|
+
if (!this.getHeader('Content-Type') && !(data instanceof Buffer)) {
|
|
461
|
+
if (data instanceof Error)
|
|
462
|
+
return this.error(data)
|
|
463
|
+
if (this.isJson || typeof data === 'object') {
|
|
464
|
+
data = JSON.stringify(typeof data === 'string' ? { message: data } : data)
|
|
465
|
+
this.setHeader('Content-Type', 'application/json')
|
|
466
|
+
} else {
|
|
467
|
+
data = data.toString()
|
|
468
|
+
if (data[0] === '{' || data[1] === '[')
|
|
469
|
+
this.setHeader('Content-Type', 'application/json')
|
|
470
|
+
else if (data[0] === '<' && (data.startsWith('<!DOCTYPE') || data.startsWith('<html')))
|
|
471
|
+
this.setHeader('Content-Type', 'text/html')
|
|
472
|
+
else
|
|
473
|
+
this.setHeader('Content-Type', 'text/plain')
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
data = data.toString()
|
|
477
|
+
this.setHeader('Content-Length', Buffer.byteLength(data, 'utf8'))
|
|
478
|
+
if (this.headersOnly)
|
|
479
|
+
this.end()
|
|
480
|
+
else
|
|
481
|
+
this.end(data, 'utf8')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Send json response */
|
|
485
|
+
json (data: any) {
|
|
486
|
+
this.isJson = true
|
|
487
|
+
if (data instanceof Error)
|
|
488
|
+
return this.error(data)
|
|
489
|
+
this.send(data)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Send json response in form { success: false, error: err } */
|
|
493
|
+
jsonError (error: string | number | object | Error): void {
|
|
494
|
+
this.isJson = true
|
|
495
|
+
if (typeof error === 'number')
|
|
496
|
+
error = http.STATUS_CODES[error] || 'Error'
|
|
497
|
+
if (error instanceof Error)
|
|
498
|
+
return this.json(error)
|
|
499
|
+
this.json(typeof error === 'string' ? { success: false, error } : { success: false, ...error })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Send json response in form { success: true, ... } */
|
|
503
|
+
jsonSuccess (data?: object | string): void {
|
|
504
|
+
this.isJson = true
|
|
505
|
+
if (data instanceof Error)
|
|
506
|
+
return this.json(data)
|
|
507
|
+
this.json(typeof data === 'string' ? { success: true, message: data } : { success: true, ...data })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Send redirect response to specified URL with optional status code (default: 302) */
|
|
511
|
+
redirect (code: number | string, url?: string): void {
|
|
512
|
+
if (typeof code === 'string') {
|
|
513
|
+
url = code
|
|
514
|
+
code = 302
|
|
515
|
+
}
|
|
516
|
+
this.setHeader('Location', url || '/')
|
|
517
|
+
this.setHeader('Content-Length', 0)
|
|
518
|
+
this.statusCode = code || 302
|
|
519
|
+
this.end()
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Set status code */
|
|
523
|
+
status (code: number): this {
|
|
524
|
+
this.statusCode = code
|
|
525
|
+
return this
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
download (path: string, filename?: string): void {
|
|
529
|
+
StaticPlugin.serveFile(this.req, this, {
|
|
530
|
+
path: path,
|
|
531
|
+
filename: filename || basename(path),
|
|
532
|
+
mimeType: StaticPlugin.mimeTypes[extname(path)] || 'application/octet-stream'
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** WebSocket options */
|
|
538
|
+
export interface WebSocketOptions {
|
|
539
|
+
maxPayload?: number,
|
|
540
|
+
autoPong?: boolean,
|
|
541
|
+
permessageDeflate?: boolean,
|
|
542
|
+
maxWindowBits?: number,
|
|
543
|
+
deflate?: boolean
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** WebSocket frame object */
|
|
547
|
+
interface WebSocketFrame {
|
|
548
|
+
fin: boolean,
|
|
549
|
+
rsv1: boolean,
|
|
550
|
+
opcode: number,
|
|
551
|
+
length: number,
|
|
552
|
+
mask: Buffer,
|
|
553
|
+
lengthReceived: number,
|
|
554
|
+
index: number
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const EMPTY_BUFFER = Buffer.alloc(0)
|
|
558
|
+
const DEFLATE_TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff])
|
|
559
|
+
|
|
560
|
+
/** WebSocket class */
|
|
561
|
+
export class WebSocket extends EventEmitter{
|
|
562
|
+
private _socket: net.Socket
|
|
563
|
+
private _frame?: WebSocketFrame
|
|
564
|
+
private _buffers: Buffer[] = [EMPTY_BUFFER]
|
|
565
|
+
private _buffersLength: number = 0
|
|
566
|
+
private _options: WebSocketOptions
|
|
567
|
+
public ready: boolean = false
|
|
568
|
+
|
|
569
|
+
constructor (req: ServerRequest, options?: WebSocketOptions) {
|
|
570
|
+
super()
|
|
571
|
+
this._socket = req.socket
|
|
572
|
+
this._options = {
|
|
573
|
+
maxPayload: 1024 * 1024,
|
|
574
|
+
permessageDeflate: false,
|
|
575
|
+
maxWindowBits: 15,
|
|
576
|
+
...options}
|
|
577
|
+
|
|
578
|
+
const key: string | undefined = req.headers['sec-websocket-key']
|
|
579
|
+
const upgrade: string | undefined = req.headers.upgrade
|
|
580
|
+
const version: number = +(req.headers['sec-websocket-version'] || 0)
|
|
581
|
+
const extensions: string | undefined = req.headers['sec-websocket-extensions']
|
|
582
|
+
const headers: string[] = []
|
|
583
|
+
|
|
584
|
+
if (!key || !upgrade || upgrade.toLocaleLowerCase() !== 'websocket' || version !== 13 || req.method !== 'GET') {
|
|
585
|
+
this._abort('Invalid WebSocket request', 400)
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
if (this._options.permessageDeflate && extensions?.includes('permessage-deflate')) {
|
|
589
|
+
let header = 'Sec-WebSocket-Extensions: permessage-deflate'
|
|
590
|
+
if ((this._options.maxWindowBits || 15) < 15 && extensions.includes('client_max_window_bits'))
|
|
591
|
+
header += `; client_max_window_bits=${this._options.maxWindowBits}`
|
|
592
|
+
headers.push(header)
|
|
593
|
+
this._options.deflate = true
|
|
594
|
+
}
|
|
595
|
+
this.ready = true
|
|
596
|
+
this._upgrade(key, headers)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private _upgrade (key: string, headers: string[] = []) {
|
|
600
|
+
const digest = crypto.createHash('sha1')
|
|
601
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
602
|
+
.digest('base64');
|
|
603
|
+
|
|
604
|
+
headers = [
|
|
605
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
606
|
+
'Upgrade: websocket',
|
|
607
|
+
'Connection: Upgrade',
|
|
608
|
+
`Sec-WebSocket-Accept: ${digest}`,
|
|
609
|
+
...headers,
|
|
610
|
+
'',
|
|
611
|
+
''
|
|
612
|
+
];
|
|
613
|
+
this._socket.write(headers.join('\r\n'))
|
|
614
|
+
this._socket.on('error',this._errorHandler.bind(this))
|
|
615
|
+
this._socket.on('data', this._dataHandler.bind(this))
|
|
616
|
+
this._socket.on('close', () => this.emit('close'))
|
|
617
|
+
this._socket.on('end', () => this.emit('end'))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Close connection */
|
|
621
|
+
close (reason?: number, data?: Buffer): void {
|
|
622
|
+
if (reason !== undefined) {
|
|
623
|
+
const buffer: Buffer = Buffer.alloc(2 + (data ? data.length : 0))
|
|
624
|
+
buffer.writeUInt16BE(reason, 0)
|
|
625
|
+
if (data)
|
|
626
|
+
data.copy(buffer, 2)
|
|
627
|
+
data = buffer
|
|
628
|
+
}
|
|
629
|
+
return this._sendFrame(0x88, data || EMPTY_BUFFER, () => this._socket.destroy())
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Generate WebSocket frame from data */
|
|
633
|
+
static getFrame(data: number | string | Buffer | undefined, options?: any): Buffer {
|
|
634
|
+
let msgType: number = 8
|
|
635
|
+
let dataLength: number = 0
|
|
636
|
+
if (typeof data === 'string') {
|
|
637
|
+
msgType = 1
|
|
638
|
+
dataLength = Buffer.byteLength(data, 'utf8')
|
|
639
|
+
} else if (data instanceof Buffer) {
|
|
640
|
+
msgType = 2
|
|
641
|
+
dataLength = data.length
|
|
642
|
+
} else if (typeof data === 'number') {
|
|
643
|
+
msgType = data
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const headerSize: number = 2 + (dataLength < 126 ? 0 : dataLength < 65536 ? 2 : 8) + (dataLength && options?.mask ? 4 : 0)
|
|
647
|
+
const frame: Buffer = Buffer.allocUnsafe(headerSize + dataLength)
|
|
648
|
+
frame[0] = 0x80 | msgType
|
|
649
|
+
frame[1] = dataLength > 65535 ? 127 : dataLength > 125 ? 126 : dataLength
|
|
650
|
+
if (dataLength > 65535)
|
|
651
|
+
frame.writeBigUInt64BE(dataLength as unknown as bigint, 2)
|
|
652
|
+
else if (dataLength > 125)
|
|
653
|
+
frame.writeUInt16BE(dataLength, 2)
|
|
654
|
+
if (dataLength && frame.length > dataLength) {
|
|
655
|
+
if (typeof data === 'string')
|
|
656
|
+
frame.write(data, headerSize, 'utf8')
|
|
657
|
+
else
|
|
658
|
+
(data as Buffer).copy(frame, headerSize)
|
|
659
|
+
}
|
|
660
|
+
if (dataLength && options?.mask) {
|
|
661
|
+
let i:number = headerSize, h:number = headerSize - 4
|
|
662
|
+
for (let i = 0; i < 4; i++)
|
|
663
|
+
frame[h + i] = Math.floor(Math.random() * 256)
|
|
664
|
+
for (let j: number = 0; j < dataLength; j++, i++) {
|
|
665
|
+
frame[i] ^= frame[h + (j & 3)]
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return frame
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Send data */
|
|
672
|
+
send (data: string | Buffer): void {
|
|
673
|
+
let msgType: number = typeof data === 'string' ? 1 : 2
|
|
674
|
+
if (typeof data === 'string')
|
|
675
|
+
data = Buffer.from(data, 'utf8')
|
|
676
|
+
if (this._options.deflate && data.length > 256) {
|
|
677
|
+
const output: Buffer[] = []
|
|
678
|
+
const deflate: zlib.Deflate = zlib.createDeflateRaw({
|
|
679
|
+
windowBits: this._options.maxWindowBits
|
|
680
|
+
});
|
|
681
|
+
deflate.write(data)
|
|
682
|
+
deflate.on('data', (chunk: Buffer) => output.push(chunk))
|
|
683
|
+
deflate.flush(() => {
|
|
684
|
+
if (output.length > 0 && output[output.length - 1].length > 4)
|
|
685
|
+
output[output.length - 1] = output[output.length - 1].subarray(0, output[output.length - 1].length - 4)
|
|
686
|
+
this._sendFrame(0xC0 | msgType, Buffer.concat(output))
|
|
687
|
+
})
|
|
688
|
+
} else
|
|
689
|
+
return this._sendFrame(0x80 | msgType, data)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private _errorHandler (error: Error): void {
|
|
693
|
+
this.emit('error', error)
|
|
694
|
+
if (this.ready)
|
|
695
|
+
this.close(error instanceof WebSocketError && error.statusCode || 1002)
|
|
696
|
+
else
|
|
697
|
+
this._socket.destroy()
|
|
698
|
+
this.ready = false
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private _headerLength (buffer?: Buffer): number {
|
|
702
|
+
if (this._frame)
|
|
703
|
+
return 0
|
|
704
|
+
if (!buffer || buffer.length < 2)
|
|
705
|
+
return 2
|
|
706
|
+
let hederInfo: number = buffer[1]
|
|
707
|
+
return 2 + (hederInfo & 0x80 ? 4 : 0) + ((hederInfo & 0x7F) === 126 ? 2 : 0) + ((hederInfo & 0x7F) === 127 ? 8 : 0)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private _dataHandler (data: Buffer): void {
|
|
711
|
+
while (data.length) {
|
|
712
|
+
let frame: WebSocketFrame | undefined = this._frame
|
|
713
|
+
if (!frame) {
|
|
714
|
+
let lastBuffer: Buffer = this._buffers[this._buffers.length - 1]
|
|
715
|
+
this._buffers[this._buffers.length - 1] = lastBuffer = Buffer.concat([lastBuffer, data])
|
|
716
|
+
let headerLength: number = this._headerLength(lastBuffer)
|
|
717
|
+
if (lastBuffer.length < headerLength)
|
|
718
|
+
return
|
|
719
|
+
const headerBits: number = lastBuffer[0]
|
|
720
|
+
const lengthBits: number = lastBuffer[1] & 0x7F
|
|
721
|
+
this._buffers.pop()
|
|
722
|
+
data = lastBuffer.subarray(headerLength)
|
|
723
|
+
|
|
724
|
+
// parse header
|
|
725
|
+
frame = this._frame = {
|
|
726
|
+
fin: (headerBits & 0x80) !== 0,
|
|
727
|
+
rsv1: (headerBits & 0x40) !== 0,
|
|
728
|
+
opcode: headerBits & 0x0F,
|
|
729
|
+
mask: (lastBuffer[1] & 0x80) ? lastBuffer.subarray(headerLength - 4, headerLength) : EMPTY_BUFFER,
|
|
730
|
+
length: lengthBits === 126 ? lastBuffer.readUInt16BE(2) : lengthBits === 127 ? lastBuffer.readBigUInt64BE(2) as unknown as number : lengthBits,
|
|
731
|
+
lengthReceived: 0,
|
|
732
|
+
index: this._buffers.length
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
let toRead: number = frame.length - frame.lengthReceived
|
|
736
|
+
if (toRead > data.length)
|
|
737
|
+
toRead = data.length
|
|
738
|
+
if (this._options.maxPayload && this._options.maxPayload < this._buffersLength + frame.length) {
|
|
739
|
+
this._errorHandler(new WebSocketError('Payload too big', 1009))
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// unmask
|
|
744
|
+
for (let i = 0, j = frame.lengthReceived; i < toRead; i++, j++)
|
|
745
|
+
data[i] ^= frame.mask[j & 3]
|
|
746
|
+
frame.lengthReceived += toRead
|
|
747
|
+
if (frame.lengthReceived < frame.length) {
|
|
748
|
+
this._buffers.push(data)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
this._buffers.push(data.subarray(0, toRead))
|
|
752
|
+
this._buffersLength += toRead
|
|
753
|
+
data = data.subarray(toRead)
|
|
754
|
+
|
|
755
|
+
if (frame.opcode >= 8) {
|
|
756
|
+
const message = Buffer.concat(this._buffers.splice(frame.index))
|
|
757
|
+
switch (frame.opcode) {
|
|
758
|
+
case 8:
|
|
759
|
+
if (!frame.length)
|
|
760
|
+
this.emit('close')
|
|
761
|
+
else {
|
|
762
|
+
const code: number = message.readInt16BE(0)
|
|
763
|
+
if (frame.length === 2)
|
|
764
|
+
this.emit('close', code)
|
|
765
|
+
else
|
|
766
|
+
this.emit('close', code, message.subarray(2))
|
|
767
|
+
}
|
|
768
|
+
this._socket.destroy()
|
|
769
|
+
return
|
|
770
|
+
case 9:
|
|
771
|
+
if (message.length)
|
|
772
|
+
this.emit('ping', message)
|
|
773
|
+
else
|
|
774
|
+
this.emit('ping')
|
|
775
|
+
if (this._options.autoPong)
|
|
776
|
+
this.pong(message)
|
|
777
|
+
break
|
|
778
|
+
case 10:
|
|
779
|
+
if (message.length)
|
|
780
|
+
this.emit('pong', message)
|
|
781
|
+
else
|
|
782
|
+
this.emit('pong')
|
|
783
|
+
break
|
|
784
|
+
default:
|
|
785
|
+
return this._errorHandler(new WebSocketError('Invalid WebSocket frame'))
|
|
786
|
+
}
|
|
787
|
+
} else if (frame.fin) {
|
|
788
|
+
if (!frame.opcode)
|
|
789
|
+
return this._errorHandler(new WebSocketError('Invalid WebSocket frame'))
|
|
790
|
+
|
|
791
|
+
if (this._options.deflate && frame.rsv1) {
|
|
792
|
+
const output: Buffer[] = []
|
|
793
|
+
const inflate = zlib.createInflateRaw({
|
|
794
|
+
windowBits: this._options.maxWindowBits
|
|
795
|
+
});
|
|
796
|
+
inflate.on('data', (chunk: Buffer) => output.push(chunk))
|
|
797
|
+
inflate.on('error', (err: Error) => this._errorHandler(err))
|
|
798
|
+
for (const buffer of this._buffers)
|
|
799
|
+
inflate.write(buffer)
|
|
800
|
+
inflate.write(DEFLATE_TRAILER)
|
|
801
|
+
inflate.flush(() => {
|
|
802
|
+
if (this.ready) {
|
|
803
|
+
const message = Buffer.concat(output)
|
|
804
|
+
this.emit('message', frame.opcode === 1 ? message.toString('utf8') : message)
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
} else {
|
|
808
|
+
const message = Buffer.concat(this._buffers)
|
|
809
|
+
this.emit('message', frame.opcode === 1 ? message.toString('utf8') : message)
|
|
810
|
+
}
|
|
811
|
+
this._buffers = []
|
|
812
|
+
this._buffersLength = 0
|
|
813
|
+
}
|
|
814
|
+
this._frame = undefined
|
|
815
|
+
this._buffers.push(EMPTY_BUFFER)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private _abort (message?: string, code?: number, headers?: any) {
|
|
820
|
+
code = code || 400
|
|
821
|
+
message = message || http.STATUS_CODES[code] || 'Closed'
|
|
822
|
+
headers = [
|
|
823
|
+
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}`,
|
|
824
|
+
'Connection: close',
|
|
825
|
+
'Content-Type: ' + message.startsWith('<') ? 'text/html' : 'text/plain',
|
|
826
|
+
`Content-Length: ${Buffer.byteLength(message)}`,
|
|
827
|
+
'',
|
|
828
|
+
message
|
|
829
|
+
]
|
|
830
|
+
this._socket.once('finish', () => {
|
|
831
|
+
this._socket.destroy()
|
|
832
|
+
this.emit('close')
|
|
833
|
+
})
|
|
834
|
+
this._socket.end(headers.join('\r\n'))
|
|
835
|
+
this.emit('error', new Error(message))
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Send ping frame */
|
|
839
|
+
ping (buffer?: Buffer) {
|
|
840
|
+
this._sendFrame(0x89, buffer || EMPTY_BUFFER)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Send pong frame */
|
|
844
|
+
pong (buffer?: Buffer) {
|
|
845
|
+
this._sendFrame(0x8A, buffer || EMPTY_BUFFER)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
protected _sendFrame (opcode: number, data: Buffer, cb?: () => void) {
|
|
849
|
+
if (!this.ready)
|
|
850
|
+
return
|
|
851
|
+
const dataLength: number = data.length
|
|
852
|
+
const headerSize: number = 2 + (dataLength < 126 ? 0 : dataLength < 65536 ? 2 : 8)
|
|
853
|
+
const frame: Buffer = Buffer.allocUnsafe(headerSize + (dataLength < 4096 ? dataLength : 0))
|
|
854
|
+
frame[0] = opcode
|
|
855
|
+
frame[1] = dataLength > 65535 ? 127 : dataLength > 125 ? 126 : dataLength
|
|
856
|
+
if (dataLength > 65535)
|
|
857
|
+
frame.writeBigUInt64BE(dataLength as unknown as bigint, 2)
|
|
858
|
+
else if (dataLength > 125)
|
|
859
|
+
frame.writeUInt16BE(dataLength, 2)
|
|
860
|
+
if (dataLength && frame.length > dataLength) {
|
|
861
|
+
data.copy(frame, headerSize)
|
|
862
|
+
this._socket.write(frame, cb)
|
|
863
|
+
} else
|
|
864
|
+
this._socket.write(frame, () => this._socket.write(data, cb))
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const server = {}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Controller for dynamic routes
|
|
872
|
+
*
|
|
873
|
+
* @example
|
|
874
|
+
* ```js
|
|
875
|
+
* class MyController extends Controller {
|
|
876
|
+
* static model = MyModel;
|
|
877
|
+
* static acl = 'auth';
|
|
878
|
+
*
|
|
879
|
+
* static 'acl:index' = '';
|
|
880
|
+
* static 'url:index' = 'GET /index';
|
|
881
|
+
* async index (req, res) {
|
|
882
|
+
* res.send('Hello World')
|
|
883
|
+
* }
|
|
884
|
+
*
|
|
885
|
+
* //function name prefixes translated to HTTP methods:
|
|
886
|
+
* // all => GET, get => GET, insert => POST, post => POST,
|
|
887
|
+
* // update => PUT, put => PUT, delete => DELETE,
|
|
888
|
+
* // modify => PATCH, patch => PATCH,
|
|
889
|
+
* // websocket => internal WebSocket
|
|
890
|
+
* // automatic acl will be: class_name + '/' + function_name_prefix
|
|
891
|
+
* // automatic url will be: method + ' /' + class_name + '/' + function_name_without_prefix
|
|
892
|
+
*
|
|
893
|
+
* //static 'acl:allUsers' = 'MyController/all';
|
|
894
|
+
* //static 'url:allUsers' = 'GET /MyController/Users';
|
|
895
|
+
* async allUsers () {
|
|
896
|
+
* return ['usr1', 'usr2', 'usr3']
|
|
897
|
+
* }
|
|
898
|
+
*
|
|
899
|
+
* //static 'acl:getOrder' = 'MyController/get';
|
|
900
|
+
* //static 'url:getOrder' = 'GET /Users/:id/:id1';
|
|
901
|
+
* static 'group:getOrder' = 'orders';
|
|
902
|
+
* static 'model:getOrder' = OrderModel;
|
|
903
|
+
* async getOrder (id: string, id1: string) {
|
|
904
|
+
* return {id, extras: id1, type: 'order'}
|
|
905
|
+
* }
|
|
906
|
+
*
|
|
907
|
+
* //static 'acl:insertOrder' = 'MyController/insert';
|
|
908
|
+
* //static 'url:insertOrder' = 'POST /Users/:id';
|
|
909
|
+
* static 'model:insertOrder' = OrderModel;
|
|
910
|
+
* async insertOrder (id: string, id1: string) {
|
|
911
|
+
* return {id, extras: id1, type: 'order'}
|
|
912
|
+
* }
|
|
913
|
+
*
|
|
914
|
+
* static 'acl:POST /login' = '';
|
|
915
|
+
* async 'POST /login' () {
|
|
916
|
+
* return {id, extras: id1, type: 'order'}
|
|
917
|
+
* }
|
|
918
|
+
* }
|
|
919
|
+
* ```
|
|
920
|
+
*/
|
|
921
|
+
export class Controller {
|
|
922
|
+
protected req: ServerRequest
|
|
923
|
+
protected res: ServerResponse
|
|
924
|
+
public model?: Model
|
|
925
|
+
|
|
926
|
+
constructor (req: ServerRequest, res: ServerResponse) {
|
|
927
|
+
this.req = req
|
|
928
|
+
this.res = res
|
|
929
|
+
res.isJson = true
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Generate routes for this controller */
|
|
933
|
+
static routes (): any[] {
|
|
934
|
+
const routes: any[] = []
|
|
935
|
+
const prefix: string = Object.getOwnPropertyDescriptor(this, 'name')?.enumerable ? this.name + '/' : ''
|
|
936
|
+
|
|
937
|
+
// iterate throught decorators
|
|
938
|
+
Object.getOwnPropertyNames(this.prototype).forEach(key => {
|
|
939
|
+
if (key === 'constructor' || key.startsWith('_'))
|
|
940
|
+
return
|
|
941
|
+
const func: any = (this.prototype as any)[key]
|
|
942
|
+
if (typeof func !== 'function')
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
const thisStatic: any = this
|
|
946
|
+
|
|
947
|
+
let url = thisStatic['url:' + key]
|
|
948
|
+
let acl = thisStatic['acl:' + key] ?? thisStatic['acl']
|
|
949
|
+
const user = thisStatic['user:' + key] ?? thisStatic['user']
|
|
950
|
+
const group = thisStatic['group:' + key] ?? thisStatic['group']
|
|
951
|
+
const model = thisStatic['model:' + key] ?? thisStatic['model']
|
|
952
|
+
|
|
953
|
+
let method = ''
|
|
954
|
+
if (!url)
|
|
955
|
+
key = key.replaceAll('$', '/')
|
|
956
|
+
if (!url && key.startsWith('/')) {
|
|
957
|
+
method = '*'
|
|
958
|
+
url = key
|
|
959
|
+
}
|
|
960
|
+
let keyMatch = !url && key.match(/^(all|get|put|post|patch|insert|update|modify|delete|websocket)[/_]?([\w_/-]*)$/i)
|
|
961
|
+
if (keyMatch) {
|
|
962
|
+
method = keyMatch[1]
|
|
963
|
+
url = '/' + prefix + keyMatch[2]
|
|
964
|
+
}
|
|
965
|
+
keyMatch = !url && key.match(/^([*\w]+) (.+)$/)
|
|
966
|
+
if (keyMatch) {
|
|
967
|
+
method = keyMatch[1]
|
|
968
|
+
url = keyMatch[2].startsWith('/') ? keyMatch[2] : ('/' + prefix + keyMatch[1])
|
|
969
|
+
}
|
|
970
|
+
keyMatch = !method && url?.match(/^([*\w]+) (.+)$/)
|
|
971
|
+
if (keyMatch) {
|
|
972
|
+
method = keyMatch[1]
|
|
973
|
+
url = keyMatch[2].startsWith('/') ? keyMatch[2] : ('/' + prefix + keyMatch[2])
|
|
974
|
+
}
|
|
975
|
+
if (!method)
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
let autoAcl = method.toLowerCase()
|
|
979
|
+
switch (autoAcl) {
|
|
980
|
+
case '*':
|
|
981
|
+
autoAcl = ''
|
|
982
|
+
break
|
|
983
|
+
case 'post':
|
|
984
|
+
autoAcl = 'insert'
|
|
985
|
+
break
|
|
986
|
+
case 'put':
|
|
987
|
+
autoAcl = 'update'
|
|
988
|
+
break
|
|
989
|
+
case 'patch':
|
|
990
|
+
autoAcl = 'modify'
|
|
991
|
+
break
|
|
992
|
+
}
|
|
993
|
+
method = method.toUpperCase()
|
|
994
|
+
switch (method) {
|
|
995
|
+
case '*':
|
|
996
|
+
break
|
|
997
|
+
case 'GET':
|
|
998
|
+
case 'POST':
|
|
999
|
+
case 'PUT':
|
|
1000
|
+
case 'PATCH':
|
|
1001
|
+
case 'DELETE':
|
|
1002
|
+
case 'WEBSOCKET':
|
|
1003
|
+
break
|
|
1004
|
+
case 'ALL':
|
|
1005
|
+
method = 'GET'
|
|
1006
|
+
break
|
|
1007
|
+
case 'INSERT':
|
|
1008
|
+
method = 'POST'
|
|
1009
|
+
break
|
|
1010
|
+
case 'UPDATE':
|
|
1011
|
+
method = 'PUT'
|
|
1012
|
+
break
|
|
1013
|
+
case 'MODIFY':
|
|
1014
|
+
method = 'PATCH'
|
|
1015
|
+
break
|
|
1016
|
+
default:
|
|
1017
|
+
throw new Error('Invalid url method for: ' + key)
|
|
1018
|
+
}
|
|
1019
|
+
if (user === undefined && group === undefined && acl === undefined)
|
|
1020
|
+
acl = prefix + autoAcl
|
|
1021
|
+
|
|
1022
|
+
// add params if not available in url
|
|
1023
|
+
if (func.length && !url.includes(':')) {
|
|
1024
|
+
let args: string[] = ['/:id']
|
|
1025
|
+
for (let i = 1; i < func.length; i++)
|
|
1026
|
+
args.push('/:id' + i)
|
|
1027
|
+
url += args.join('')
|
|
1028
|
+
}
|
|
1029
|
+
const list: Array<string|Function> = [method + ' ' + url.replace(/\/\//g, '/')]
|
|
1030
|
+
if (acl)
|
|
1031
|
+
list.push('acl:' + acl)
|
|
1032
|
+
if (user)
|
|
1033
|
+
list.push('user:' + user)
|
|
1034
|
+
if (group)
|
|
1035
|
+
list.push('group:' + group)
|
|
1036
|
+
list.push((req: ServerRequest, res: ServerResponse) => {
|
|
1037
|
+
res.isJson = true
|
|
1038
|
+
const obj: Controller = new this(req, res)
|
|
1039
|
+
if (model) {
|
|
1040
|
+
req.model = obj.model = model instanceof Model ? model : Model.models[model]
|
|
1041
|
+
if (!obj.model)
|
|
1042
|
+
throw new InvalidData(model, 'model')
|
|
1043
|
+
}
|
|
1044
|
+
return func.apply(obj, req.paramsList)
|
|
1045
|
+
})
|
|
1046
|
+
routes.push(list)
|
|
1047
|
+
})
|
|
1048
|
+
return routes
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Middleware */
|
|
1053
|
+
export interface Middleware {
|
|
1054
|
+
(req: ServerRequest, res: ServerResponse, next: Function): any;
|
|
1055
|
+
/** @default 0 */
|
|
1056
|
+
priority?: number;
|
|
1057
|
+
plugin?: Plugin;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Internal router item */
|
|
1061
|
+
interface RouterItem {
|
|
1062
|
+
name?: string
|
|
1063
|
+
hook?: Middleware[]
|
|
1064
|
+
next?: Middleware[]
|
|
1065
|
+
param?: RouterItem
|
|
1066
|
+
last?: RouterItem
|
|
1067
|
+
tree?: {[key: string]: RouterItem}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
class Waiter {
|
|
1071
|
+
private _waiters: any = {}
|
|
1072
|
+
private _id: number = 0
|
|
1073
|
+
private _busy: number = 0
|
|
1074
|
+
|
|
1075
|
+
get busy() {
|
|
1076
|
+
return this._busy > 0
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
startJob() {
|
|
1080
|
+
this._busy++
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
endJob(id?: string) {
|
|
1084
|
+
this._busy--
|
|
1085
|
+
if (!this._busy)
|
|
1086
|
+
this.resolve(id || 'ready')
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
get nextId() {
|
|
1090
|
+
return (++this._id).toString()
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async wait(id: string): Promise<void> {
|
|
1094
|
+
return new Promise<void>(resolve => (this._waiters[id] = this._waiters[id] || []).push(resolve))
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
resolve(id: string): void {
|
|
1098
|
+
const resolvers = this._waiters[id]
|
|
1099
|
+
if (resolvers)
|
|
1100
|
+
for (const resolve of resolvers)
|
|
1101
|
+
resolve(undefined)
|
|
1102
|
+
delete this._waiters[id]
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
class EmitterWaiter extends Waiter {
|
|
1107
|
+
private _emitter: EventEmitter
|
|
1108
|
+
constructor (emitter: EventEmitter) {
|
|
1109
|
+
super()
|
|
1110
|
+
this._emitter = emitter
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
resolve(id: string): void {
|
|
1114
|
+
if (id === 'ready')
|
|
1115
|
+
this._emitter.emit(id)
|
|
1116
|
+
super.resolve(id)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Router */
|
|
1121
|
+
export class Router extends EventEmitter {
|
|
1122
|
+
public server: MicroServer
|
|
1123
|
+
public auth?: Auth
|
|
1124
|
+
public plugins: {[key: string]: Plugin} = {}
|
|
1125
|
+
|
|
1126
|
+
private _stack: Middleware[] = []
|
|
1127
|
+
private _stackAfter: Middleware[] = []
|
|
1128
|
+
private _tree: {[key: string]: RouterItem} = {}
|
|
1129
|
+
_waiter: Waiter = new Waiter()
|
|
1130
|
+
|
|
1131
|
+
/** @param {MicroServer} server */
|
|
1132
|
+
constructor (server: MicroServer) {
|
|
1133
|
+
super()
|
|
1134
|
+
this.server = server
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** bind middleware or create one from string like: 'redirect:302,https://redirect.to', 'error:422', 'param:name=value', 'acl:users/get', 'model:User', 'group:Users', 'user:admin' */
|
|
1138
|
+
bind (fn: string | Function | object): Function {
|
|
1139
|
+
if (typeof fn === 'string') {
|
|
1140
|
+
let name = fn
|
|
1141
|
+
let idx = name.indexOf(':')
|
|
1142
|
+
if (idx < 0 && name.includes('=')) {
|
|
1143
|
+
name = 'param:' + name
|
|
1144
|
+
idx = 5
|
|
1145
|
+
}
|
|
1146
|
+
if (idx >= 0) {
|
|
1147
|
+
const v = name.slice(idx + 1)
|
|
1148
|
+
const type = name.slice(0, idx)
|
|
1149
|
+
|
|
1150
|
+
// predefined middlewares
|
|
1151
|
+
switch (type) {
|
|
1152
|
+
// redirect:302,https://redirect.to
|
|
1153
|
+
case 'redirect': {
|
|
1154
|
+
let redirect = v.split(','), code = parseInt(v[0])
|
|
1155
|
+
if (!code || code < 301 || code > 399)
|
|
1156
|
+
code = 302
|
|
1157
|
+
return (req: ServerRequest, res: ServerResponse) => res.redirect(code, redirect[1] || v)
|
|
1158
|
+
}
|
|
1159
|
+
// error:422
|
|
1160
|
+
case 'error':
|
|
1161
|
+
return (req: ServerRequest, res: ServerResponse) => res.error(parseInt(v) || 422)
|
|
1162
|
+
// param:name=value
|
|
1163
|
+
case 'param': {
|
|
1164
|
+
idx = v.indexOf('=')
|
|
1165
|
+
if (idx > 0) {
|
|
1166
|
+
const prm = v.slice(0, idx), val = v.slice(idx + 1)
|
|
1167
|
+
return (req: ServerRequest, res: ServerResponse, next: Function) => { req.params[prm] = val; return next() }
|
|
1168
|
+
}
|
|
1169
|
+
break
|
|
1170
|
+
}
|
|
1171
|
+
case 'model': {
|
|
1172
|
+
const model = v
|
|
1173
|
+
return (req: ServerRequest, res: ServerResponse) => {
|
|
1174
|
+
res.isJson = true
|
|
1175
|
+
req.params.model = model
|
|
1176
|
+
req.model = Model.models[model]
|
|
1177
|
+
if (!req.model) {
|
|
1178
|
+
console.error(`Data model ${model} not defined for request ${req.path}`)
|
|
1179
|
+
return res.error(422)
|
|
1180
|
+
}
|
|
1181
|
+
return req.model.handler(req, res)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// user:userid
|
|
1185
|
+
// group:user_groupid
|
|
1186
|
+
// acl:validacl
|
|
1187
|
+
case 'user':
|
|
1188
|
+
case 'group':
|
|
1189
|
+
case 'acl':
|
|
1190
|
+
return (req: ServerRequest, res: ServerResponse, next: Function) => {
|
|
1191
|
+
if (type === 'user' && v === req.user?.id)
|
|
1192
|
+
return next()
|
|
1193
|
+
if (type === 'acl') {
|
|
1194
|
+
req.params.acl = v
|
|
1195
|
+
if (req.auth?.acl(v))
|
|
1196
|
+
return next()
|
|
1197
|
+
}
|
|
1198
|
+
if (type === 'group') {
|
|
1199
|
+
req.params.group = v
|
|
1200
|
+
if (req.user?.group === v)
|
|
1201
|
+
return next()
|
|
1202
|
+
}
|
|
1203
|
+
const accept = req.headers.accept || ''
|
|
1204
|
+
if (!res.isJson && req.auth?.options.redirect && req.method === 'GET' && !accept.includes('json') && (accept.includes('html') || accept.includes('*/*'))) {
|
|
1205
|
+
if (req.auth.options.redirect && req.url !== req.auth.options.redirect)
|
|
1206
|
+
return res.redirect(302, req.auth.options.redirect)
|
|
1207
|
+
else if (req.auth.options.mode !== 'cookie') {
|
|
1208
|
+
res.setHeader('WWW-Authenticate', `Basic realm="${req.auth.options.realm}"`)
|
|
1209
|
+
return res.error(401)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return res.error('Permission denied')
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
throw new Error('Invalid option: ' + name)
|
|
1217
|
+
}
|
|
1218
|
+
if (fn && typeof fn === 'object' && 'handler' in fn && typeof fn.handler === 'function')
|
|
1219
|
+
return fn.handler.bind(fn)
|
|
1220
|
+
if (typeof fn !== 'function')
|
|
1221
|
+
throw new Error('Invalid middleware: ' + String.toString.call(fn))
|
|
1222
|
+
return fn.bind(this)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** Handler */
|
|
1226
|
+
handler (req: ServerRequest, res: ServerResponse, next: Function, method?: string) {
|
|
1227
|
+
const nextAfter: Function = next
|
|
1228
|
+
next = () => this._walkStack(this._stackAfter, req, res, nextAfter)
|
|
1229
|
+
if (method)
|
|
1230
|
+
return !this._walkTree(this._tree[method], req, res, next) && next()
|
|
1231
|
+
const walk = () => {
|
|
1232
|
+
if (!this._walkTree(this._tree[req.method || 'GET'], req, res, next) &&
|
|
1233
|
+
!this._walkTree(this._tree['*'], req, res, next))
|
|
1234
|
+
next()
|
|
1235
|
+
}
|
|
1236
|
+
req.rewrite = (url: string) => {
|
|
1237
|
+
if (req.originalUrl)
|
|
1238
|
+
res.error(508)
|
|
1239
|
+
req.updateUrl(url)
|
|
1240
|
+
walk()
|
|
1241
|
+
}
|
|
1242
|
+
this._walkStack(this._stack, req, res, walk)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private _walkStack (rstack: Function[], req: ServerRequest, res: ServerResponse, next: Function) {
|
|
1246
|
+
let rnexti = 0
|
|
1247
|
+
const sendData = (data: any) => {
|
|
1248
|
+
if (!res.headersSent && data !== undefined) {
|
|
1249
|
+
if ((data === null || typeof data === 'string') && !res.isJson)
|
|
1250
|
+
return res.send(data)
|
|
1251
|
+
if (typeof data === 'object' &&
|
|
1252
|
+
(data instanceof Buffer || data instanceof Readable || (data instanceof Error && !res.isJson)))
|
|
1253
|
+
return res.send(data)
|
|
1254
|
+
return res.jsonSuccess(data)
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const rnext = () => {
|
|
1258
|
+
const cb = rstack[rnexti++]
|
|
1259
|
+
if (cb) {
|
|
1260
|
+
try {
|
|
1261
|
+
req.router = this
|
|
1262
|
+
const p = cb(req, res, rnext)
|
|
1263
|
+
if (p instanceof Promise)
|
|
1264
|
+
p.catch(e => e).then(sendData)
|
|
1265
|
+
else
|
|
1266
|
+
sendData(p)
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
sendData(e)
|
|
1269
|
+
}
|
|
1270
|
+
} else
|
|
1271
|
+
return next()
|
|
1272
|
+
}
|
|
1273
|
+
return rnext()
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private _walkTree (item: RouterItem | undefined, req: ServerRequest, res: ServerResponse, next: Function) {
|
|
1277
|
+
req.params = {}
|
|
1278
|
+
req.paramsList = []
|
|
1279
|
+
const rstack: Function[] = []
|
|
1280
|
+
const reg = /\/([^/]*)/g
|
|
1281
|
+
let m: RegExpExecArray | null
|
|
1282
|
+
let lastItem, done
|
|
1283
|
+
while (m = reg.exec(req.pathname)) {
|
|
1284
|
+
const name = m[1]
|
|
1285
|
+
if (!item || done) {
|
|
1286
|
+
item = undefined
|
|
1287
|
+
break
|
|
1288
|
+
}
|
|
1289
|
+
if (lastItem !== item) {
|
|
1290
|
+
lastItem = item
|
|
1291
|
+
item.hook?.forEach((hook: Function) => rstack.push(hook.bind(item)))
|
|
1292
|
+
}
|
|
1293
|
+
if (!item.tree) { // last
|
|
1294
|
+
if (item.name) {
|
|
1295
|
+
req.params[item.name] += '/' + name
|
|
1296
|
+
req.paramsList[req.paramsList.length - 1] = req.params[item.name]
|
|
1297
|
+
} else
|
|
1298
|
+
done = true
|
|
1299
|
+
} else {
|
|
1300
|
+
item = item.last || item.tree[name] || item.param
|
|
1301
|
+
if (item && item.name) {
|
|
1302
|
+
req.params[item.name] = name
|
|
1303
|
+
req.paramsList.push(name)
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (lastItem !== item)
|
|
1308
|
+
item?.hook?.forEach((hook: Function) => rstack.push(hook.bind(item)))
|
|
1309
|
+
item?.next?.forEach((cb: Function) => rstack.push(cb))
|
|
1310
|
+
if (!rstack.length)
|
|
1311
|
+
return
|
|
1312
|
+
this._walkStack(rstack, req, res, next)
|
|
1313
|
+
return true
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private _add (method: string, url: string, key: 'next' | 'hook', middlewares: any[]) {
|
|
1317
|
+
if (key === 'next')
|
|
1318
|
+
this.server.emit('route', {
|
|
1319
|
+
method,
|
|
1320
|
+
url,
|
|
1321
|
+
middlewares
|
|
1322
|
+
})
|
|
1323
|
+
middlewares = middlewares.map(m => this.bind(m))
|
|
1324
|
+
|
|
1325
|
+
let item: RouterItem = this._tree[method]
|
|
1326
|
+
if (!item)
|
|
1327
|
+
item = this._tree[method] = { tree: {} }
|
|
1328
|
+
if (!url.startsWith('/')) {
|
|
1329
|
+
if (method === '*' && url === '') {
|
|
1330
|
+
this._stack.push(...middlewares)
|
|
1331
|
+
return this
|
|
1332
|
+
}
|
|
1333
|
+
url = '/' + url
|
|
1334
|
+
}
|
|
1335
|
+
const reg = /\/(:?)([^/*]+)(\*?)/g
|
|
1336
|
+
let m: RegExpExecArray | null
|
|
1337
|
+
while (m = reg.exec(url)) {
|
|
1338
|
+
const param: string = m[1], name: string = m[2], last: string = m[3]
|
|
1339
|
+
if (last) {
|
|
1340
|
+
item.last = { name: name }
|
|
1341
|
+
item = item.last
|
|
1342
|
+
} else {
|
|
1343
|
+
if (!item.tree)
|
|
1344
|
+
throw new Error('Invalid route path')
|
|
1345
|
+
if (param) {
|
|
1346
|
+
item = item.param = item.param || { tree: {}, name: name }
|
|
1347
|
+
} else {
|
|
1348
|
+
let subitem = item.tree[name]
|
|
1349
|
+
if (!subitem)
|
|
1350
|
+
subitem = item.tree[name] = { tree: {} }
|
|
1351
|
+
item = subitem
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (!item[key])
|
|
1356
|
+
item[key] = []
|
|
1357
|
+
item[key].push(...middlewares)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Clear routes and middlewares */
|
|
1361
|
+
clear () {
|
|
1362
|
+
this._tree = {}
|
|
1363
|
+
this._stack = []
|
|
1364
|
+
return this
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Add middleware route.
|
|
1369
|
+
* Middlewares may return promises for res.jsonSuccess(...), throw errors for res.error(...), return string or {} for res.send(...)
|
|
1370
|
+
*
|
|
1371
|
+
* @signature add(plugin: Plugin)
|
|
1372
|
+
* @param {Plugin} plugin plugin module instance
|
|
1373
|
+
* @return {Promise<>}
|
|
1374
|
+
*
|
|
1375
|
+
* @signature add(pluginid: string, ...args: any)
|
|
1376
|
+
* @param {string} pluginid pluginid module
|
|
1377
|
+
* @param {...any} args arguments passed to constructor
|
|
1378
|
+
* @return {Promise<>}
|
|
1379
|
+
*
|
|
1380
|
+
* @signature add(pluginClass: typeof Plugin, ...args: any)
|
|
1381
|
+
* @param {typeof Plugin} pluginClass plugin class
|
|
1382
|
+
* @param {...any} args arguments passed to constructor
|
|
1383
|
+
* @return {Promise<>}
|
|
1384
|
+
*
|
|
1385
|
+
* @signature add(middleware: Middleware)
|
|
1386
|
+
* @param {Middleware} middleware
|
|
1387
|
+
* @return {Promise<>}
|
|
1388
|
+
*
|
|
1389
|
+
* @signature add(methodUrl: string, ...middlewares: any)
|
|
1390
|
+
* @param {string} methodUrl 'METHOD /url' or '/url'
|
|
1391
|
+
* @param {...any} middlewares
|
|
1392
|
+
* @return {Promise<>}
|
|
1393
|
+
*
|
|
1394
|
+
* @signature add(methodUrl: string, controllerClass: typeof Controller)
|
|
1395
|
+
* @param {string} methodUrl 'METHOD /url' or '/url'
|
|
1396
|
+
* @param {typeof Controller} controllerClass
|
|
1397
|
+
* @return {Promise<>}
|
|
1398
|
+
*
|
|
1399
|
+
* @signature add(methodUrl: string, routes: Array<Array<any>>)
|
|
1400
|
+
* @param {string} methodUrl 'METHOD /url' or '/url'
|
|
1401
|
+
* @param {Array<Array<any>>} routes list with subroutes: ['METHOD /suburl', ...middlewares]
|
|
1402
|
+
* @return {Promise<>}
|
|
1403
|
+
*
|
|
1404
|
+
* @signature add(methodUrl: string, routes: Array<Array<any>>)
|
|
1405
|
+
* @param {string} methodUrl 'METHOD /url' or '/url'
|
|
1406
|
+
* @param {Array<Array<any>>} routes list with subroutes: ['METHOD /suburl', ...middlewares]
|
|
1407
|
+
* @return {Promise<>}
|
|
1408
|
+
*
|
|
1409
|
+
* @signature add(routes: { [key: string]: Array<any> })
|
|
1410
|
+
* @param { {[key: string]: Array<any>} } routes list with subroutes: 'METHOD /suburl': [...middlewares]
|
|
1411
|
+
* @return {Promise<>}
|
|
1412
|
+
*
|
|
1413
|
+
* @signature add(methodUrl: string, routes: { [key: string]: Array<any> })
|
|
1414
|
+
* @param {string} methodUrl 'METHOD /url' or '/url'
|
|
1415
|
+
* @param { {[key: string]: Array<any>} } routes list with subroutes: 'METHOD /suburl': [...middlewares]
|
|
1416
|
+
* @return {Promise<>}
|
|
1417
|
+
*/
|
|
1418
|
+
async use (...args: any): Promise<void> {
|
|
1419
|
+
if (!args[0])
|
|
1420
|
+
return
|
|
1421
|
+
|
|
1422
|
+
this.server._waiter.startJob()
|
|
1423
|
+
|
|
1424
|
+
for (let i = 0; i < args.length; i++)
|
|
1425
|
+
args[i] = await args[i]
|
|
1426
|
+
|
|
1427
|
+
// use(plugin)
|
|
1428
|
+
if (args[0] instanceof Plugin) {
|
|
1429
|
+
await this._plugin(args[0])
|
|
1430
|
+
return this.server._waiter.endJob()
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// use(pluginid, ...args)
|
|
1434
|
+
if (typeof args[0] === 'string' && MicroServer.plugins[args[0]]) {
|
|
1435
|
+
const constructor = MicroServer.plugins[args[0]]
|
|
1436
|
+
const plugin = new constructor(this, ...args.slice(1))
|
|
1437
|
+
await this._plugin(plugin)
|
|
1438
|
+
return this.server._waiter.endJob()
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// use(PluginClass, ...args)
|
|
1442
|
+
if (typeof args[0] === 'function' && args[0].prototype instanceof Plugin) {
|
|
1443
|
+
const plugin = new args[0](this, ...args.slice(1))
|
|
1444
|
+
await this._plugin(plugin)
|
|
1445
|
+
return this.server._waiter.endJob()
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// use(middleware)
|
|
1449
|
+
if (isFunction(args[0])) {
|
|
1450
|
+
this._middleware(args[0] as Middleware)
|
|
1451
|
+
return this.server._waiter.endJob()
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
let method = '*', url = '/'
|
|
1455
|
+
if (typeof args[0] === 'string') {
|
|
1456
|
+
const m = args[0].match(/^([A-Z]+) (.*)/)
|
|
1457
|
+
if (m)
|
|
1458
|
+
[method, url] = [m[1], m[2]]
|
|
1459
|
+
else
|
|
1460
|
+
url = args[0]
|
|
1461
|
+
if (!url.startsWith('/'))
|
|
1462
|
+
throw new Error(`Invalid url ${url}`)
|
|
1463
|
+
args = args.slice(1)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// use('/url', ControllerClass)
|
|
1467
|
+
if (typeof args[0] === 'function' && args[0].prototype instanceof Controller) {
|
|
1468
|
+
const routes = await args[0].routes()
|
|
1469
|
+
if (routes)
|
|
1470
|
+
args[0] = routes
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// use('/url', [ ['METHOD /url', ...], {'METHOD } ])
|
|
1474
|
+
if (Array.isArray(args[0])) {
|
|
1475
|
+
if (method !== '*')
|
|
1476
|
+
throw new Error('Invalid router usage')
|
|
1477
|
+
for (const item of args[0]) {
|
|
1478
|
+
if (Array.isArray(item)) {
|
|
1479
|
+
// [methodUrl, ...middlewares]
|
|
1480
|
+
if (typeof item[0] !== 'string' || !item[0].match(/^(\w+ )?\//))
|
|
1481
|
+
throw new Error('Url expected')
|
|
1482
|
+
await this.use(item[0].replace(/\//, (url === '/' ? '' : url) + '/'), ...item.slice(1))
|
|
1483
|
+
} else
|
|
1484
|
+
throw new Error('Invalid param')
|
|
1485
|
+
}
|
|
1486
|
+
return this.server._waiter.endJob()
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// use('/url', {'METHOD /url': [...middlewares], ... } ])
|
|
1490
|
+
if (typeof args[0] === 'object' && args[0].constructor === Object) {
|
|
1491
|
+
if (method !== '*')
|
|
1492
|
+
throw new Error('Invalid router usage')
|
|
1493
|
+
for (const [subUrl, subArgs] of Object.entries(args[0])) {
|
|
1494
|
+
if (!subUrl.match(/^(\w+ )?\//))
|
|
1495
|
+
throw new Error('Url expected')
|
|
1496
|
+
await this.use(subUrl.replace(/\//, (url === '/' ? '' : url) + '/'), ...(Array.isArray(subArgs) ? subArgs : [subArgs]))
|
|
1497
|
+
}
|
|
1498
|
+
return this.server._waiter.endJob()
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// use('/url', ...middleware)
|
|
1502
|
+
this._add(method, url, 'next', args.filter((o: any) => o))
|
|
1503
|
+
return this.server._waiter.endJob()
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private _middleware(middleware?: Middleware): void {
|
|
1507
|
+
if (!middleware)
|
|
1508
|
+
return
|
|
1509
|
+
const priority: number = (middleware?.priority || 0) - 1
|
|
1510
|
+
const stack = priority < -1 ? this._stackAfter : this._stack
|
|
1511
|
+
|
|
1512
|
+
const idx = stack.findIndex(f => 'priority' in f
|
|
1513
|
+
&& priority >= (f.priority || 0))
|
|
1514
|
+
stack.splice(idx < 0 ? stack.length : idx, 0, middleware)
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private async _plugin(plugin: Plugin): Promise<void> {
|
|
1518
|
+
let added: string | undefined
|
|
1519
|
+
if (plugin.name) {
|
|
1520
|
+
if (this.plugins[plugin.name])
|
|
1521
|
+
throw new Error(`Plugin ${plugin.name} already added`)
|
|
1522
|
+
this.plugins[plugin.name] = plugin
|
|
1523
|
+
added = plugin.name
|
|
1524
|
+
}
|
|
1525
|
+
await plugin.initialise?.()
|
|
1526
|
+
if (plugin.handler) {
|
|
1527
|
+
const middleware: Middleware = plugin.handler.bind(plugin)
|
|
1528
|
+
middleware.plugin = plugin
|
|
1529
|
+
middleware.priority = plugin.priority
|
|
1530
|
+
this._middleware(middleware)
|
|
1531
|
+
}
|
|
1532
|
+
if (plugin.routes)
|
|
1533
|
+
await this.use(isFunction(plugin.routes) ? await plugin.routes() : plugin.routes)
|
|
1534
|
+
if (added)
|
|
1535
|
+
this._waiter.resolve(added)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async waitPlugin (id: string): Promise<Plugin> {
|
|
1539
|
+
if (!this.plugins[id])
|
|
1540
|
+
await this._waiter.wait(id)
|
|
1541
|
+
return this.plugins[id]
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** Add hook */
|
|
1545
|
+
hook (url: string, ...mid: Middleware[]): void {
|
|
1546
|
+
const m = url.match(/^([A-Z]+) (.*)/)
|
|
1547
|
+
let method = '*'
|
|
1548
|
+
if (m)
|
|
1549
|
+
[method, url] = [m[1], m[2]]
|
|
1550
|
+
this._add(method, url, 'hook', mid)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Check if middleware allready added */
|
|
1554
|
+
has (mid: Middleware): boolean {
|
|
1555
|
+
return this._stack.includes(mid) || (mid.name && !!this._stack.find(f => f.name === mid.name)) || false
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
export interface HttpHandler {
|
|
1560
|
+
(req: ServerRequest, res: ServerResponse): void
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
export interface TcpHandler {
|
|
1564
|
+
(socket: net.Socket): void
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
export interface ListenConfig {
|
|
1568
|
+
/** listen port(s) with optional protocol and host (Ex. 8080 or '0.0.0.0:8080,8180' or 'https://0.0.0.0:8080' or 'tcp://0.0.0.0:8080' or 'tls://0.0.0.0:8080') */
|
|
1569
|
+
listen?: string | number
|
|
1570
|
+
/** tls options */
|
|
1571
|
+
tls?: {cert: string, key: string, ca?: string}
|
|
1572
|
+
/** custom handler */
|
|
1573
|
+
handler?: HttpHandler | TcpHandler
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export interface CorsOptions {
|
|
1577
|
+
/** allowed origins (default: '*') */
|
|
1578
|
+
origin: string,
|
|
1579
|
+
/** allowed headers (default: '*') */
|
|
1580
|
+
headers: string,
|
|
1581
|
+
/** allow credentials (default: false) */
|
|
1582
|
+
credentials: boolean,
|
|
1583
|
+
/** Expose headers */
|
|
1584
|
+
expose?: string,
|
|
1585
|
+
/** Max age */
|
|
1586
|
+
maxAge?: number
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** MicroServer configuration */
|
|
1590
|
+
export interface MicroServerConfig extends ListenConfig {
|
|
1591
|
+
/** server instance root path */
|
|
1592
|
+
root?: string
|
|
1593
|
+
/** Auth options */
|
|
1594
|
+
auth?: AuthOptions
|
|
1595
|
+
/** routes to add */
|
|
1596
|
+
routes?: any
|
|
1597
|
+
/** Static file options */
|
|
1598
|
+
static?: StaticOptions
|
|
1599
|
+
/** max body size (default: 5MB) */
|
|
1600
|
+
maxBodySize?: number
|
|
1601
|
+
/** allowed HTTP methods */
|
|
1602
|
+
methods?: string
|
|
1603
|
+
/** trust proxy */
|
|
1604
|
+
trustProxy?: string[]
|
|
1605
|
+
/** cors options */
|
|
1606
|
+
cors?: string | CorsOptions | boolean
|
|
1607
|
+
/** upload dir (default: './upload') */
|
|
1608
|
+
uploadDir?: string,
|
|
1609
|
+
/** allow websocket deflate compression (default: false) */
|
|
1610
|
+
websocketCompress?: boolean,
|
|
1611
|
+
/** max websocket payload (default: 1MB) */
|
|
1612
|
+
websocketMaxPayload?: number,
|
|
1613
|
+
/** websocket max window bits 8-15 for deflate (default: 10) */
|
|
1614
|
+
websocketMaxWindowBits?: number,
|
|
1615
|
+
/** extra options for plugins */
|
|
1616
|
+
[key: string]: any
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
export class MicroServer extends EventEmitter {
|
|
1620
|
+
/** server configuration */
|
|
1621
|
+
public config: MicroServerConfig
|
|
1622
|
+
/** main router */
|
|
1623
|
+
public router: Router
|
|
1624
|
+
/** virtual host routers */
|
|
1625
|
+
public vhosts?: {[key: string]: Router}
|
|
1626
|
+
/** all sockets */
|
|
1627
|
+
public sockets: Set<net.Socket>
|
|
1628
|
+
/** server instances */
|
|
1629
|
+
public servers: Set<net.Server>
|
|
1630
|
+
|
|
1631
|
+
_waiter: Waiter = new EmitterWaiter(this)
|
|
1632
|
+
|
|
1633
|
+
public static plugins: {[key: string]: PluginClass} = {}
|
|
1634
|
+
|
|
1635
|
+
private _methods: {[key: string]: boolean} = {}
|
|
1636
|
+
private _init: (f: Function, ...args: any[]) => void
|
|
1637
|
+
|
|
1638
|
+
constructor (config: MicroServerConfig) {
|
|
1639
|
+
super()
|
|
1640
|
+
|
|
1641
|
+
let promise = Promise.resolve()
|
|
1642
|
+
this._init = (f: Function, ...args: any[]) => {
|
|
1643
|
+
promise = promise.then(() => f.apply(this, args)).catch(e => this.emit('error', e))
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
this.config = {
|
|
1647
|
+
maxBodySize: defaultMaxBodySize,
|
|
1648
|
+
methods: defaultMethods,
|
|
1649
|
+
...config,
|
|
1650
|
+
root: path.normalize(config.root || process.cwd())
|
|
1651
|
+
};
|
|
1652
|
+
(config.methods || defaultMethods).split(',').map(s => s.trim()).forEach(m => this._methods[m] = true)
|
|
1653
|
+
this.router = new Router(this)
|
|
1654
|
+
|
|
1655
|
+
this.servers = new Set()
|
|
1656
|
+
this.sockets = new Set()
|
|
1657
|
+
|
|
1658
|
+
if (config.routes)
|
|
1659
|
+
this.use(config.routes)
|
|
1660
|
+
|
|
1661
|
+
for (const key in MicroServer.plugins) {
|
|
1662
|
+
if (config[key])
|
|
1663
|
+
this.router.use(MicroServer.plugins[key], config[key])
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
if (config.listen)
|
|
1667
|
+
this._init(() => {
|
|
1668
|
+
this.listen({
|
|
1669
|
+
tls: config.tls,
|
|
1670
|
+
listen: config.listen || 8080
|
|
1671
|
+
})
|
|
1672
|
+
})
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** Add one time listener or call immediatelly for 'ready' */
|
|
1676
|
+
once (name: string, cb: Function) {
|
|
1677
|
+
if (name === 'ready' && this.isReady())
|
|
1678
|
+
cb()
|
|
1679
|
+
else
|
|
1680
|
+
super.once(name, cb as any)
|
|
1681
|
+
return this
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** Add listener and call immediatelly for 'ready' */
|
|
1685
|
+
on (name: string, cb: Function) {
|
|
1686
|
+
if (name === 'ready' && this.isReady())
|
|
1687
|
+
cb()
|
|
1688
|
+
super.on(name, cb as any)
|
|
1689
|
+
return this
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
public isReady(): boolean {
|
|
1693
|
+
return !this._waiter.busy
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async waitReady (): Promise<void> {
|
|
1697
|
+
if (this.isReady())
|
|
1698
|
+
return
|
|
1699
|
+
return this._waiter.wait("ready")
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async waitPlugin (id: string): Promise<void> {
|
|
1703
|
+
await this.router.waitPlugin(id)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** Listen server, should be used only if config.listen is not set */
|
|
1707
|
+
listen (config?: ListenConfig): Promise<void> {
|
|
1708
|
+
const listen = (config?.listen || this.config.listen || 0) + ''
|
|
1709
|
+
const handler = config?.handler || this.handler.bind(this)
|
|
1710
|
+
const tlsConfig = config ? config.tls : this.config.tls
|
|
1711
|
+
|
|
1712
|
+
const readFile = (data: string | undefined) => data && (data.indexOf('\n') > 0 ? data : fs.readFileSync(data))
|
|
1713
|
+
function tlsOptions(): tls.SecureContextOptions {
|
|
1714
|
+
return {
|
|
1715
|
+
cert: readFile(tlsConfig?.cert),
|
|
1716
|
+
key: readFile(tlsConfig?.key),
|
|
1717
|
+
ca: readFile(tlsConfig?.ca)
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
function tlsOptionsReload(srv: tls.Server | https.Server) {
|
|
1721
|
+
if (tlsConfig?.cert && tlsConfig.cert.indexOf('\n') < 0) {
|
|
1722
|
+
let debounce: NodeJS.Timeout | undefined
|
|
1723
|
+
fs.watch(tlsConfig.cert, () => {
|
|
1724
|
+
clearTimeout(debounce)
|
|
1725
|
+
debounce = setTimeout(() => {
|
|
1726
|
+
debounce = undefined
|
|
1727
|
+
srv.setSecureContext(tlsOptions())
|
|
1728
|
+
}, 2000)
|
|
1729
|
+
})
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const reg = /^((?<proto>\w+):\/\/)?(?<host>(\[[^\]]+\]|[a-z][^:,]+|\d+\.\d+\.\d+\.\d+))?:?(?<port>\d+)?/
|
|
1734
|
+
listen.split(',').forEach(listen => {
|
|
1735
|
+
this._waiter.startJob()
|
|
1736
|
+
let {proto, host, port} = reg.exec(listen)?.groups || {}
|
|
1737
|
+
let srv: net.Server | http.Server | https.Server
|
|
1738
|
+
switch (proto) {
|
|
1739
|
+
case 'tcp':
|
|
1740
|
+
if (!config?.handler)
|
|
1741
|
+
throw new Error('Handler is required for tcp')
|
|
1742
|
+
srv = net.createServer(handler as any)
|
|
1743
|
+
break
|
|
1744
|
+
case 'tls':
|
|
1745
|
+
if (!config?.handler)
|
|
1746
|
+
throw new Error('Handler is required for tls')
|
|
1747
|
+
srv = tls.createServer(tlsOptions(), handler as any)
|
|
1748
|
+
tlsOptionsReload(srv as tls.Server)
|
|
1749
|
+
break
|
|
1750
|
+
case 'https':
|
|
1751
|
+
port = port || '443'
|
|
1752
|
+
srv = https.createServer(tlsOptions(), handler as any)
|
|
1753
|
+
tlsOptionsReload(srv as https.Server)
|
|
1754
|
+
break
|
|
1755
|
+
default:
|
|
1756
|
+
port = port || '80'
|
|
1757
|
+
srv = http.createServer(handler as any)
|
|
1758
|
+
break
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
this.servers.add(srv)
|
|
1762
|
+
if (port === '0') // skip listening
|
|
1763
|
+
this._waiter.endJob()
|
|
1764
|
+
else {
|
|
1765
|
+
srv.listen(parseInt(port), host?.replace(/[\[\]]/g, '') || '0.0.0.0', () => {
|
|
1766
|
+
const addr: net.AddressInfo = srv.address() as net.AddressInfo
|
|
1767
|
+
this.emit('listen', addr.port, addr.address, srv);
|
|
1768
|
+
(srv as any)._ready = true
|
|
1769
|
+
this._waiter.endJob()
|
|
1770
|
+
})
|
|
1771
|
+
}
|
|
1772
|
+
srv.on('error', err => {
|
|
1773
|
+
srv.close()
|
|
1774
|
+
this.servers.delete(srv)
|
|
1775
|
+
if (!(srv as any)._ready)
|
|
1776
|
+
this._waiter.endJob()
|
|
1777
|
+
this.emit('error', err)
|
|
1778
|
+
})
|
|
1779
|
+
srv.on('connection', s => {
|
|
1780
|
+
this.sockets.add(s)
|
|
1781
|
+
s.once('close', () => this.sockets.delete(s))
|
|
1782
|
+
})
|
|
1783
|
+
srv.on('upgrade', this.handlerUpgrade.bind(this))
|
|
1784
|
+
})
|
|
1785
|
+
return this._waiter.wait('ready')
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Add middleware, routes, etc.. see {router.use} */
|
|
1789
|
+
use (...args: any): Promise<void> {
|
|
1790
|
+
return this.router.use(...args)
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/** Default server handler */
|
|
1794
|
+
handler (req: ServerRequest, res: ServerResponse): void {
|
|
1795
|
+
this.requestInit(req, res)
|
|
1796
|
+
|
|
1797
|
+
// limit input data size
|
|
1798
|
+
if (parseInt(req.headers['content-length'] || '-1') > (this.config.maxBodySize || defaultMaxBodySize)) {
|
|
1799
|
+
req.pause()
|
|
1800
|
+
res.error(413)
|
|
1801
|
+
return
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
this.handlerInit(req, res, () => this.handlerLast(req, res))
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
protected requestInit (req: ServerRequest, res?: ServerResponse) {
|
|
1808
|
+
Object.setPrototypeOf(req, ServerRequest.prototype);
|
|
1809
|
+
(req as any)._init(this.router)
|
|
1810
|
+
|
|
1811
|
+
if (res) {
|
|
1812
|
+
Object.setPrototypeOf(res, ServerResponse.prototype);
|
|
1813
|
+
(res as any)._init(this.router)
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/** Preprocess request, used by {MicroServer.handler} */
|
|
1818
|
+
handlerInit (req: ServerRequest, res: ServerResponse, next: Function) {
|
|
1819
|
+
let cors = this.config.cors
|
|
1820
|
+
if (cors && req.headers.origin) {
|
|
1821
|
+
if (cors === true)
|
|
1822
|
+
cors = '*'
|
|
1823
|
+
if (typeof cors === 'string')
|
|
1824
|
+
cors = { origin: cors, headers: 'Content-Type', credentials: true }
|
|
1825
|
+
|
|
1826
|
+
if (cors.origin)
|
|
1827
|
+
res.setHeader('Access-Control-Allow-Origin', cors.origin)
|
|
1828
|
+
if (cors.headers)
|
|
1829
|
+
res.setHeader('Access-Control-Allow-Headers', cors.headers)
|
|
1830
|
+
if (cors.credentials)
|
|
1831
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true')
|
|
1832
|
+
if (cors.expose)
|
|
1833
|
+
res.setHeader('Access-Control-Expose-Headers', cors.expose)
|
|
1834
|
+
if (cors.maxAge)
|
|
1835
|
+
res.setHeader('Access-Control-Max-Age', cors.maxAge)
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (req.method === 'OPTIONS') {
|
|
1839
|
+
res.statusCode = 204
|
|
1840
|
+
res.setHeader('Allow', this.config.methods || defaultMethods)
|
|
1841
|
+
res.end()
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (!req.method ||!this._methods[req.method]) {
|
|
1846
|
+
res.statusCode = 405
|
|
1847
|
+
res.setHeader('Allow', this.config.methods || defaultMethods)
|
|
1848
|
+
res.end()
|
|
1849
|
+
return
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (req.method === 'HEAD') {
|
|
1853
|
+
req.method = 'GET'
|
|
1854
|
+
res.headersOnly = true
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
return req.bodyDecode(res, this.config, () => {
|
|
1858
|
+
if ((req.rawBodySize && req.rawBody[0] && (req.rawBody[0][0] === 91 || req.rawBody[0][0] === 123))
|
|
1859
|
+
|| req.headers.accept?.includes?.('json') || req.headers['content-type']?.includes?.('json'))
|
|
1860
|
+
res.isJson = true
|
|
1861
|
+
|
|
1862
|
+
return req.router.handler(req, res, next)
|
|
1863
|
+
})
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Last request handler */
|
|
1867
|
+
handlerLast (req: ServerRequest, res: ServerResponse, next?: Function) {
|
|
1868
|
+
if (res.headersSent)
|
|
1869
|
+
return
|
|
1870
|
+
if (!next)
|
|
1871
|
+
next = () => res.error(404)
|
|
1872
|
+
return next()
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/** Default upgrade handler, used for WebSockets */
|
|
1876
|
+
handlerUpgrade (req: ServerRequest, socket: net.Socket, head: any) {
|
|
1877
|
+
this.requestInit(req)
|
|
1878
|
+
|
|
1879
|
+
//req.headers = head
|
|
1880
|
+
//(req as any)['head'] = head
|
|
1881
|
+
const host: string = req.headers.host || ''
|
|
1882
|
+
const router = this.vhosts?.[host] || this.router
|
|
1883
|
+
const res = {
|
|
1884
|
+
get headersSent (): boolean {
|
|
1885
|
+
return socket.bytesWritten > 0
|
|
1886
|
+
},
|
|
1887
|
+
statusCode: 200,
|
|
1888
|
+
socket,
|
|
1889
|
+
server,
|
|
1890
|
+
write (data?: string): void {
|
|
1891
|
+
if (res.headersSent)
|
|
1892
|
+
throw new Error('Headers already sent')
|
|
1893
|
+
let code = res.statusCode || 403
|
|
1894
|
+
if (code < 400) {
|
|
1895
|
+
data = 'Invalid WebSocket response'
|
|
1896
|
+
console.error(data)
|
|
1897
|
+
code = 500
|
|
1898
|
+
}
|
|
1899
|
+
if (!data)
|
|
1900
|
+
data = http.STATUS_CODES[code] || ''
|
|
1901
|
+
const headers: string[] = [
|
|
1902
|
+
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}`,
|
|
1903
|
+
'Connection: close',
|
|
1904
|
+
'Content-Type: text/html',
|
|
1905
|
+
`Content-Length: ${Buffer.byteLength(data)}`,
|
|
1906
|
+
'',
|
|
1907
|
+
data
|
|
1908
|
+
]
|
|
1909
|
+
socket.write(headers.join('\r\n'), () => { socket.destroy() });
|
|
1910
|
+
},
|
|
1911
|
+
error (code: number): void {
|
|
1912
|
+
res.statusCode = code || 403
|
|
1913
|
+
res.write()
|
|
1914
|
+
},
|
|
1915
|
+
end (data?: string): void {
|
|
1916
|
+
res.write(data)
|
|
1917
|
+
},
|
|
1918
|
+
send (data?: string): void {
|
|
1919
|
+
res.write(data)
|
|
1920
|
+
},
|
|
1921
|
+
setHeader (): void { }
|
|
1922
|
+
}
|
|
1923
|
+
router.handler(req, res as unknown as ServerResponse, () => res.error(404), 'WEBSOCKET')
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/** Close server instance */
|
|
1927
|
+
async close () {
|
|
1928
|
+
return new Promise((resolve: Function) => {
|
|
1929
|
+
let count = 0
|
|
1930
|
+
function done () {
|
|
1931
|
+
count--
|
|
1932
|
+
if (!count)
|
|
1933
|
+
setTimeout(() => resolve(), 2)
|
|
1934
|
+
}
|
|
1935
|
+
for (const s of this.servers) {
|
|
1936
|
+
count++
|
|
1937
|
+
s.once('close', done)
|
|
1938
|
+
s.close()
|
|
1939
|
+
}
|
|
1940
|
+
this.servers.clear()
|
|
1941
|
+
for (const s of this.sockets) {
|
|
1942
|
+
count++
|
|
1943
|
+
s.once('close', done)
|
|
1944
|
+
s.destroy()
|
|
1945
|
+
}
|
|
1946
|
+
this.sockets.clear()
|
|
1947
|
+
}).then(() => {
|
|
1948
|
+
this.emit('close')
|
|
1949
|
+
this._waiter.resolve("close")
|
|
1950
|
+
})
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/** Add route, alias to `server.router.use(url, ...args)` */
|
|
1954
|
+
all (url: string, ...args: any): MicroServer {
|
|
1955
|
+
this.router.use(url, ...args)
|
|
1956
|
+
return this
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** Add route, alias to `server.router.use('GET ' + url, ...args)` */
|
|
1960
|
+
get (url: string, ...args: any): MicroServer {
|
|
1961
|
+
this.router.use('GET ' + url, ...args)
|
|
1962
|
+
return this
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Add route, alias to `server.router.use('POST ' + url, ...args)` */
|
|
1966
|
+
post (url: string, ...args: any): MicroServer {
|
|
1967
|
+
this.router.use('POST ' + url, ...args)
|
|
1968
|
+
return this
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/** Add route, alias to `server.router.use('PUT ' + url, ...args)` */
|
|
1972
|
+
put (url: string, ...args: any): MicroServer {
|
|
1973
|
+
this.router.use('PUT ' + url, ...args)
|
|
1974
|
+
return this
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Add route, alias to `server.router.use('PATCH ' + url, ...args)` */
|
|
1978
|
+
patch (url: string, ...args: any): MicroServer {
|
|
1979
|
+
this.router.use('PATCH ' + url, ...args)
|
|
1980
|
+
return this
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** Add route, alias to `server.router.use('DELETE ' + url, ...args)` */
|
|
1984
|
+
delete (url: string, ...args: any): MicroServer {
|
|
1985
|
+
this.router.use('DELETE ' + url, ...args)
|
|
1986
|
+
return this
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** Add websocket handler, alias to `server.router.use('WEBSOCKET ' + url, ...args)` */
|
|
1990
|
+
websocket (url: string, ...args: any): MicroServer {
|
|
1991
|
+
this.router.use('WEBSOCKET ' + url, ...args)
|
|
1992
|
+
return this
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/** Add router hook, alias to `server.router.hook(url, ...args)` */
|
|
1996
|
+
hook (url: string, ...args: any): MicroServer {
|
|
1997
|
+
this.router.hook(url, args.filter((o: any) => o))
|
|
1998
|
+
return this
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/** Trust proxy plugin, adds `req.ip` and `req.localip` */
|
|
2003
|
+
class TrustProxyPlugin extends Plugin {
|
|
2004
|
+
priority: number = 110
|
|
2005
|
+
name: string = 'trustProxy'
|
|
2006
|
+
|
|
2007
|
+
private trustProxy: string[] = []
|
|
2008
|
+
|
|
2009
|
+
constructor (router: Router, options?: string[]) {
|
|
2010
|
+
super(router)
|
|
2011
|
+
this.trustProxy = options || []
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
isLocal (ip: string) {
|
|
2015
|
+
return !!ip.match(/^(127\.|10\.|192\.168\.|172\.16\.|fe80|fc|fd|::)/)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
handler(req: ServerRequest, res: ServerResponse, next: Function): void {
|
|
2019
|
+
req.ip = req.socket.remoteAddress || '::1'
|
|
2020
|
+
req.localip = this.isLocal(req.ip)
|
|
2021
|
+
const xip = req.headers['x-real-ip'] || req.headers['x-forwarded-for']
|
|
2022
|
+
if (xip) {
|
|
2023
|
+
if (!this.trustProxy.includes(req.ip))
|
|
2024
|
+
return res.error(400)
|
|
2025
|
+
|
|
2026
|
+
if (req.headers['x-forwarded-proto'] === 'https') {
|
|
2027
|
+
req.protocol = 'https'
|
|
2028
|
+
req.secure = true
|
|
2029
|
+
}
|
|
2030
|
+
req.ip = Array.isArray(xip) ? xip[0] : xip
|
|
2031
|
+
req.localip = this.isLocal(req.ip)
|
|
2032
|
+
}
|
|
2033
|
+
return next()
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
MicroServer.plugins.trustProxy = TrustProxyPlugin
|
|
2037
|
+
|
|
2038
|
+
interface VHostOptions {
|
|
2039
|
+
[host: string]: any[] | {[url: string]: any}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** Virtual host plugin */
|
|
2043
|
+
class VHostPlugin extends Plugin {
|
|
2044
|
+
priority: number = 100
|
|
2045
|
+
|
|
2046
|
+
constructor (router: Router, options: VHostOptions) {
|
|
2047
|
+
super(router)
|
|
2048
|
+
|
|
2049
|
+
const server: MicroServer = router.server
|
|
2050
|
+
|
|
2051
|
+
if (!server.vhosts) {
|
|
2052
|
+
server.vhosts = {}
|
|
2053
|
+
this.name = 'vhost'
|
|
2054
|
+
} else
|
|
2055
|
+
this.handler = undefined
|
|
2056
|
+
for (const host in options) {
|
|
2057
|
+
if (!server.vhosts[host])
|
|
2058
|
+
server.vhosts[host] = new Router(server)
|
|
2059
|
+
server.vhosts[host].use(options[host])
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
handler? (req: ServerRequest, res: ServerResponse, next: Function) {
|
|
2064
|
+
const host = req.headers.host || ''
|
|
2065
|
+
const router: Router | undefined = req.router.server.vhosts?.[host]
|
|
2066
|
+
if (router) {
|
|
2067
|
+
req.router = res.router = router
|
|
2068
|
+
router.handler(req, res, () => req.router.server.handlerLast(req, res))
|
|
2069
|
+
} else
|
|
2070
|
+
next()
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
MicroServer.plugins.vhost = VHostPlugin
|
|
2074
|
+
|
|
2075
|
+
/** Static files options */
|
|
2076
|
+
export interface StaticOptions {
|
|
2077
|
+
/** files root directory */
|
|
2078
|
+
root?: string,
|
|
2079
|
+
/** url path */
|
|
2080
|
+
path?: string,
|
|
2081
|
+
/** additional mime types */
|
|
2082
|
+
mimeTypes?: { [key: string]: string },
|
|
2083
|
+
/** file extension handlers */
|
|
2084
|
+
handlers?: { [key: string]: Middleware },
|
|
2085
|
+
/** ignore prefixes */
|
|
2086
|
+
ignore?: string[]
|
|
2087
|
+
/** index file. default: 'index.html' */
|
|
2088
|
+
index?: string
|
|
2089
|
+
/** Update Last-Modified header. default: true */
|
|
2090
|
+
lastModified?: boolean
|
|
2091
|
+
/** Update ETag header. default: true */
|
|
2092
|
+
etag?: boolean
|
|
2093
|
+
/** Max file age in seconds */
|
|
2094
|
+
maxAge?: number
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
export interface ServeFileOptions {
|
|
2098
|
+
/** path */
|
|
2099
|
+
path: string
|
|
2100
|
+
/** root */
|
|
2101
|
+
root?: string
|
|
2102
|
+
/** file name */
|
|
2103
|
+
filename?: string
|
|
2104
|
+
/** file mime type */
|
|
2105
|
+
mimeType?: string
|
|
2106
|
+
/** last modified date */
|
|
2107
|
+
lastModified?: boolean
|
|
2108
|
+
/** etag */
|
|
2109
|
+
etag?: boolean
|
|
2110
|
+
/** max age */
|
|
2111
|
+
maxAge?: number
|
|
2112
|
+
/** range */
|
|
2113
|
+
range?: boolean
|
|
2114
|
+
/** stat */
|
|
2115
|
+
stats?: fs.Stats
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const etagPrefix = crypto.randomBytes(4).toString('hex')
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* Static files middleware plugin
|
|
2122
|
+
* Usage: server.use('static', '/public')
|
|
2123
|
+
* Usage: server.use('static', { root: 'public', path: '/static' })
|
|
2124
|
+
*/
|
|
2125
|
+
class StaticPlugin extends Plugin {
|
|
2126
|
+
/** Default mime types */
|
|
2127
|
+
static mimeTypes: { [key: string]: string } = {
|
|
2128
|
+
'.ico': 'image/x-icon',
|
|
2129
|
+
'.htm': 'text/html',
|
|
2130
|
+
'.html': 'text/html',
|
|
2131
|
+
'.txt': 'text/plain',
|
|
2132
|
+
'.js': 'text/javascript',
|
|
2133
|
+
'.json': 'application/json',
|
|
2134
|
+
'.css': 'text/css',
|
|
2135
|
+
'.png': 'image/png',
|
|
2136
|
+
'.jpg': 'image/jpeg',
|
|
2137
|
+
'.svg': 'image/svg+xml',
|
|
2138
|
+
'.mp3': 'audio/mpeg',
|
|
2139
|
+
'.ogg': 'audio/ogg',
|
|
2140
|
+
'.mp4': 'video/mp4',
|
|
2141
|
+
'.pdf': 'application/pdf',
|
|
2142
|
+
'.woff': 'application/x-font-woff',
|
|
2143
|
+
'.woff2': 'application/x-font-woff2',
|
|
2144
|
+
'.ttf': 'application/x-font-ttf',
|
|
2145
|
+
'.gz': 'application/gzip',
|
|
2146
|
+
'.zip': 'application/zip',
|
|
2147
|
+
'.tgz': 'application/gzip',
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/** Custom mime types */
|
|
2151
|
+
mimeTypes: { [key: string]: string }
|
|
2152
|
+
/** File extension handlers */
|
|
2153
|
+
handlers?: { [key: string]: Middleware }
|
|
2154
|
+
/** Files root directory */
|
|
2155
|
+
root: string
|
|
2156
|
+
/** Ignore prefixes */
|
|
2157
|
+
ignore: string[]
|
|
2158
|
+
/** Index file. default: 'index.html' */
|
|
2159
|
+
index: string
|
|
2160
|
+
/** Update Last-Modified header. default: true */
|
|
2161
|
+
lastModified: boolean
|
|
2162
|
+
/** Update ETag header. default: true */
|
|
2163
|
+
etag: boolean
|
|
2164
|
+
/** Max file age in seconds (default: 31536000) */
|
|
2165
|
+
maxAge?: number
|
|
2166
|
+
|
|
2167
|
+
constructor (router: Router, options?: StaticOptions | string) {
|
|
2168
|
+
super(router)
|
|
2169
|
+
if (!options)
|
|
2170
|
+
options = {}
|
|
2171
|
+
if (typeof options === 'string')
|
|
2172
|
+
options = { path: options }
|
|
2173
|
+
|
|
2174
|
+
this.mimeTypes = options.mimeTypes ? { ...StaticPlugin.mimeTypes, ...options.mimeTypes } : Object.freeze(StaticPlugin.mimeTypes)
|
|
2175
|
+
this.root = path.resolve((options.root || options?.path || 'public').replace(/^\//, '')) + path.sep
|
|
2176
|
+
this.ignore = (options.ignore || []).map((p: string) => path.normalize(path.join(this.root, p)) + path.sep)
|
|
2177
|
+
this.index = options.index || 'index.html'
|
|
2178
|
+
this.handlers = options.handlers
|
|
2179
|
+
this.lastModified = options.lastModified !== false
|
|
2180
|
+
this.etag = options.etag !== false
|
|
2181
|
+
this.maxAge = options.maxAge
|
|
2182
|
+
|
|
2183
|
+
router.use('GET /' + (options.path?.replace(/^[.\/]*/, '') || '').replace(/\/$/, '') + '/:path*', this.staticHandler.bind(this))
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** Default static files handler */
|
|
2187
|
+
staticHandler (req: ServerRequest, res: ServerResponse, next: Function) {
|
|
2188
|
+
if (req.method !== 'GET')
|
|
2189
|
+
return next()
|
|
2190
|
+
|
|
2191
|
+
let filename = path.normalize(path.join(this.root, (req.params && req.params.path) || req.pathname))
|
|
2192
|
+
if (!filename.startsWith(this.root)) // check root access
|
|
2193
|
+
return next()
|
|
2194
|
+
|
|
2195
|
+
const firstch = basename(filename)[0]
|
|
2196
|
+
if (firstch === '.' || firstch === '_') // hidden file
|
|
2197
|
+
return next()
|
|
2198
|
+
|
|
2199
|
+
if (filename.endsWith(path.sep))
|
|
2200
|
+
filename += this.index
|
|
2201
|
+
|
|
2202
|
+
const ext = path.extname(filename)
|
|
2203
|
+
const mimeType = this.mimeTypes[ext]
|
|
2204
|
+
if (!mimeType)
|
|
2205
|
+
return next()
|
|
2206
|
+
|
|
2207
|
+
// check ignore access
|
|
2208
|
+
for (let i = 0; i < this.ignore.length; i++) {
|
|
2209
|
+
if (filename.startsWith(this.ignore[i]))
|
|
2210
|
+
return next()
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
fs.stat(filename, (err, stats) => {
|
|
2214
|
+
if (err || stats.isDirectory())
|
|
2215
|
+
return next()
|
|
2216
|
+
|
|
2217
|
+
const handler = this.handlers?.[ext]
|
|
2218
|
+
if (handler) {
|
|
2219
|
+
(req as any).filename = filename
|
|
2220
|
+
return handler.call(this, req, res, next)
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
StaticPlugin.serveFile(req, res, {
|
|
2224
|
+
path: filename,
|
|
2225
|
+
mimeType,
|
|
2226
|
+
stats
|
|
2227
|
+
})
|
|
2228
|
+
})
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
static serveFile (req: ServerRequest, res: ServerResponse, options: ServeFileOptions) {
|
|
2232
|
+
const filePath: string = options.root ? path.join(options.root, options.path) : options.path
|
|
2233
|
+
const statRes = (err: NodeJS.ErrnoException | null, stats: fs.Stats): void => {
|
|
2234
|
+
if (err)
|
|
2235
|
+
return res.error(err)
|
|
2236
|
+
if (!stats.isFile())
|
|
2237
|
+
return res.error(404)
|
|
2238
|
+
|
|
2239
|
+
if (!res.getHeader('Content-Type')) {
|
|
2240
|
+
if (options.mimeType)
|
|
2241
|
+
res.setHeader('Content-Type', options.mimeType)
|
|
2242
|
+
else
|
|
2243
|
+
res.setHeader('Content-Type', this.mimeTypes[path.extname(options.path)] || 'application/octet-stream')
|
|
2244
|
+
}
|
|
2245
|
+
if (options.filename)
|
|
2246
|
+
res.setHeader('Content-Disposition', 'attachment; filename="' + options.filename + '"')
|
|
2247
|
+
if (options.lastModified !== false)
|
|
2248
|
+
res.setHeader('Last-Modified', stats.mtime.toUTCString())
|
|
2249
|
+
res.setHeader('Content-Length', stats.size)
|
|
2250
|
+
if (options.etag !== false) {
|
|
2251
|
+
const etag = '"' + etagPrefix + stats.mtime.getTime().toString(32) + '"'
|
|
2252
|
+
if (req.headers['if-none-match'] === etag || req.headers['if-modified-since'] === stats.mtime.toUTCString()) {
|
|
2253
|
+
res.statusCode = 304
|
|
2254
|
+
res.headersOnly = true
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
if (options.maxAge)
|
|
2258
|
+
res.setHeader('Cache-Control', 'max-age=' + options.maxAge)
|
|
2259
|
+
if (res.headersOnly) {
|
|
2260
|
+
res.end()
|
|
2261
|
+
return
|
|
2262
|
+
}
|
|
2263
|
+
const streamOptions = {start: 0, end: stats.size - 1}
|
|
2264
|
+
if (options.range !== false) {
|
|
2265
|
+
const range: string | undefined = req.headers['range']
|
|
2266
|
+
if (range && range.startsWith('bytes=')) {
|
|
2267
|
+
const parts = range.slice(6).split('-')
|
|
2268
|
+
|
|
2269
|
+
streamOptions.start = parseInt(parts[0]) || 0
|
|
2270
|
+
streamOptions.end = parts[1] ? parseInt(parts[1]) : stats.size - 1
|
|
2271
|
+
res.setHeader('Content-Range', `bytes ${streamOptions.start}-${streamOptions.end}/${stats.size}`)
|
|
2272
|
+
res.setHeader('Content-Length', streamOptions.end - streamOptions.start + 1)
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
fs.createReadStream(filePath, streamOptions).pipe(res)
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (!options.stats)
|
|
2279
|
+
fs.stat(filePath, statRes)
|
|
2280
|
+
else
|
|
2281
|
+
statRes(null, options.stats)
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
MicroServer.plugins.static = StaticPlugin
|
|
2285
|
+
|
|
2286
|
+
/** Proxy plugin options */
|
|
2287
|
+
export interface ProxyPluginOptions {
|
|
2288
|
+
/** Base path */
|
|
2289
|
+
path?: string,
|
|
2290
|
+
/** Remote url */
|
|
2291
|
+
remote?: string,
|
|
2292
|
+
/** Match regex filter */
|
|
2293
|
+
match?: string,
|
|
2294
|
+
/** Override/set headers for remote */
|
|
2295
|
+
headers?: { [key: string]: string },
|
|
2296
|
+
/** Valid headers to forward */
|
|
2297
|
+
validHeaders?: { [key: string]: boolean }
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
export class ProxyPlugin extends Plugin {
|
|
2301
|
+
/** Default valid headers */
|
|
2302
|
+
static validHeaders: { [key: string]: boolean } = {
|
|
2303
|
+
authorization: true,
|
|
2304
|
+
accept: true,
|
|
2305
|
+
'accept-encoding': true,
|
|
2306
|
+
'accept-language': true,
|
|
2307
|
+
'cache-control': true,
|
|
2308
|
+
cookie: true,
|
|
2309
|
+
'content-type': true,
|
|
2310
|
+
'content-length': true,
|
|
2311
|
+
host: true,
|
|
2312
|
+
referer: true,
|
|
2313
|
+
'if-match': true,
|
|
2314
|
+
'if-none-match': true,
|
|
2315
|
+
'if-modified-since': true,
|
|
2316
|
+
'user-agent': true,
|
|
2317
|
+
date: true,
|
|
2318
|
+
range: true
|
|
2319
|
+
}
|
|
2320
|
+
/** Current valid headers */
|
|
2321
|
+
validHeaders: { [key: string]: boolean }
|
|
2322
|
+
/** Override headers to forward to remote */
|
|
2323
|
+
headers: { [key: string]: string } | undefined
|
|
2324
|
+
/** Remote url */
|
|
2325
|
+
remoteUrl: URL
|
|
2326
|
+
/** Match regex filter */
|
|
2327
|
+
regex?: RegExp
|
|
2328
|
+
|
|
2329
|
+
constructor (router: Router, options?: ProxyPluginOptions | string) {
|
|
2330
|
+
super(router)
|
|
2331
|
+
if (typeof options !== 'object')
|
|
2332
|
+
options = { remote: options }
|
|
2333
|
+
if (!options.remote)
|
|
2334
|
+
throw new Error('Invalid param')
|
|
2335
|
+
|
|
2336
|
+
this.remoteUrl = new URL(options.remote)
|
|
2337
|
+
this.regex = options.match ? new RegExp(options.match) : undefined
|
|
2338
|
+
|
|
2339
|
+
this.headers = options.headers
|
|
2340
|
+
this.validHeaders = {...ProxyPlugin.validHeaders, ...options?.validHeaders}
|
|
2341
|
+
if (options.path && options.path !== '/') {
|
|
2342
|
+
this.handler = undefined
|
|
2343
|
+
router.use(options.path + '/:path*', this.proxyHandler.bind(this))
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** Default proxy handler */
|
|
2348
|
+
proxyHandler (req: ServerRequest, res: ServerResponse, next: Function) {
|
|
2349
|
+
const reqOptions: http.RequestOptions = {
|
|
2350
|
+
method: req.method,
|
|
2351
|
+
headers: {},
|
|
2352
|
+
host: this.remoteUrl.hostname,
|
|
2353
|
+
port: parseInt(this.remoteUrl.port) || (this.remoteUrl.protocol === 'https:' ? 443 : 80),
|
|
2354
|
+
path: this.remoteUrl.pathname
|
|
2355
|
+
}
|
|
2356
|
+
const rawHeaders: string[] = req.rawHeaders
|
|
2357
|
+
let path = req.params.path
|
|
2358
|
+
if (path)
|
|
2359
|
+
path += (req.path.match(/\?.*/) || [''])[0]
|
|
2360
|
+
if (!path && this.regex) {
|
|
2361
|
+
const m = this.regex.exec(req.path)
|
|
2362
|
+
if (!m)
|
|
2363
|
+
return next()
|
|
2364
|
+
path = m.length > 1 ? m[1] : m[0]
|
|
2365
|
+
}
|
|
2366
|
+
if (!path)
|
|
2367
|
+
path = req.path || ''
|
|
2368
|
+
if (!path.startsWith('/'))
|
|
2369
|
+
path = '/' + path
|
|
2370
|
+
reqOptions.path += path
|
|
2371
|
+
if (!reqOptions.headers)
|
|
2372
|
+
reqOptions.headers = {}
|
|
2373
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
2374
|
+
const n = rawHeaders[i], nlow = n.toLowerCase()
|
|
2375
|
+
if (this.validHeaders[nlow] && nlow !== 'host')
|
|
2376
|
+
(reqOptions.headers as any)[n] = rawHeaders[i + 1]
|
|
2377
|
+
}
|
|
2378
|
+
if (this.headers)
|
|
2379
|
+
Object.assign(reqOptions.headers, this.headers)
|
|
2380
|
+
reqOptions.setHost = true
|
|
2381
|
+
|
|
2382
|
+
const conn = this.remoteUrl.protocol === 'https:' ? https.request(reqOptions) : http.request(reqOptions)
|
|
2383
|
+
conn.on('response', (response: http.IncomingMessage) => {
|
|
2384
|
+
res.statusCode = response.statusCode || 502
|
|
2385
|
+
for (let i = 0; i < response.rawHeaders.length; i += 2) {
|
|
2386
|
+
const n = response.rawHeaders[i], nlow = n.toLowerCase()
|
|
2387
|
+
if (nlow !== 'transfer-encoding' && nlow !== 'connection')
|
|
2388
|
+
res.setHeader(n, response.rawHeaders[i + 1])
|
|
2389
|
+
}
|
|
2390
|
+
response.on('data', chunk => {
|
|
2391
|
+
res.write(chunk)
|
|
2392
|
+
})
|
|
2393
|
+
response.on('end', () => {
|
|
2394
|
+
res.end()
|
|
2395
|
+
})
|
|
2396
|
+
})
|
|
2397
|
+
conn.on('error', () => res.error(502))
|
|
2398
|
+
|
|
2399
|
+
// Content-Length must be allready defined
|
|
2400
|
+
if (req.rawBody.length) {
|
|
2401
|
+
const postStream = new Readable()
|
|
2402
|
+
req.rawBody.forEach(chunk => {
|
|
2403
|
+
postStream.push(chunk)
|
|
2404
|
+
})
|
|
2405
|
+
postStream.push(null)
|
|
2406
|
+
postStream.pipe(res)
|
|
2407
|
+
} else
|
|
2408
|
+
conn.end()
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/** Proxy plugin handler as middleware */
|
|
2412
|
+
handler? (req: ServerRequest, res: ServerResponse, next: Function): void {
|
|
2413
|
+
return this.proxyHandler(req, res, next)
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
MicroServer.plugins.proxy = ProxyPlugin
|
|
2417
|
+
|
|
2418
|
+
/** User info */
|
|
2419
|
+
export interface UserInfo {
|
|
2420
|
+
/** User id */
|
|
2421
|
+
id: string,
|
|
2422
|
+
/** User password plain or hash */
|
|
2423
|
+
password?: string,
|
|
2424
|
+
/** ACL options */
|
|
2425
|
+
acl?: {[key: string]: boolean},
|
|
2426
|
+
/** User group */
|
|
2427
|
+
group?: string,
|
|
2428
|
+
/** Custom user data */
|
|
2429
|
+
[key: string]: any
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/** Authentication options */
|
|
2433
|
+
export interface AuthOptions {
|
|
2434
|
+
/** Authentication token */
|
|
2435
|
+
token: string | Buffer
|
|
2436
|
+
/** Users */
|
|
2437
|
+
users?: {[key: string]: UserInfo} | ((usr: string, psw?: string) => Promise<UserInfo|undefined>)
|
|
2438
|
+
/** Default ACL */
|
|
2439
|
+
defaultAcl?: { [key: string]: boolean }
|
|
2440
|
+
/** Expire time in seconds */
|
|
2441
|
+
expire?: number
|
|
2442
|
+
/** Authentication mode */
|
|
2443
|
+
mode?: 'cookie' | 'token'
|
|
2444
|
+
/** Authentication realm for basic authentication */
|
|
2445
|
+
realm?: string
|
|
2446
|
+
/** Redirect URL */
|
|
2447
|
+
redirect?: string
|
|
2448
|
+
/** Authentication cache */
|
|
2449
|
+
cache?: { [key: string]: { data: UserInfo, time: number } }
|
|
2450
|
+
/** Interal next cache cleanup time */
|
|
2451
|
+
cacheCleanup?: number
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/** Authentication class */
|
|
2455
|
+
export class Auth {
|
|
2456
|
+
/** Server request */
|
|
2457
|
+
public req: ServerRequest | undefined
|
|
2458
|
+
/** Server response */
|
|
2459
|
+
public res: ServerResponse | undefined
|
|
2460
|
+
/** Authentication options */
|
|
2461
|
+
public options: AuthOptions
|
|
2462
|
+
/** Get user function */
|
|
2463
|
+
public users: ((usr: string, psw?: string, salt?: string) => Promise<UserInfo|undefined>)
|
|
2464
|
+
|
|
2465
|
+
constructor (options?: AuthOptions) {
|
|
2466
|
+
let token: string | Buffer = options?.token || defaultToken
|
|
2467
|
+
if (!token || token.length !== 32)
|
|
2468
|
+
token = defaultToken
|
|
2469
|
+
if (token.length !== 32)
|
|
2470
|
+
token = crypto.createHash('sha256').update(token).digest()
|
|
2471
|
+
if (!(token instanceof Buffer))
|
|
2472
|
+
token = Buffer.from(token as string)
|
|
2473
|
+
this.options = {
|
|
2474
|
+
token,
|
|
2475
|
+
users: options?.users,
|
|
2476
|
+
mode: options?.mode || 'cookie',
|
|
2477
|
+
defaultAcl: options?.defaultAcl || { '*': false },
|
|
2478
|
+
expire: options?.expire || defaultExpire,
|
|
2479
|
+
cache: options?.cache || {}
|
|
2480
|
+
}
|
|
2481
|
+
if (typeof options?.users === 'function')
|
|
2482
|
+
this.users = options.users
|
|
2483
|
+
else
|
|
2484
|
+
this.users = async (usrid, psw) => {
|
|
2485
|
+
const users: {[key: string]: UserInfo} | undefined = this.options.users as {[key: string]: UserInfo}
|
|
2486
|
+
const usr: UserInfo | undefined = users?.[usrid]
|
|
2487
|
+
if (usr && (psw === undefined || this.checkPassword(usrid, psw, usr.password || '')))
|
|
2488
|
+
return usr
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/** Decode token */
|
|
2493
|
+
decode (data: string) {
|
|
2494
|
+
data = data.replace(/-/g, '+').replace(/\./g, '/')
|
|
2495
|
+
const iv: Buffer = Buffer.from(data.slice(0, 22), 'base64')
|
|
2496
|
+
|
|
2497
|
+
try {
|
|
2498
|
+
const decipher: crypto.DecipherGCM = crypto.createDecipheriv('aes-256-gcm', this.options.token, iv) as crypto.DecipherGCM
|
|
2499
|
+
decipher.setAuthTag(Buffer.from(data.slice(22, 44), 'base64'))
|
|
2500
|
+
const dec: string = decipher.update(data.slice(44), 'base64', 'utf8') + decipher.final('utf8')
|
|
2501
|
+
const match: RegExpMatchArray | null = dec.match(/^(.*);([0-9a-f]{8});$/)
|
|
2502
|
+
if (match) {
|
|
2503
|
+
const expire = parseInt(match[2], 16) + 946681200 - Math.floor(new Date().getTime() / 1000)
|
|
2504
|
+
if (expire > 0)
|
|
2505
|
+
return {
|
|
2506
|
+
data: match[1],
|
|
2507
|
+
expire
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
} catch (e) {
|
|
2511
|
+
}
|
|
2512
|
+
return {
|
|
2513
|
+
data: '',
|
|
2514
|
+
expire: -1
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/** Encode token */
|
|
2519
|
+
encode (data: string, expire?: number) {
|
|
2520
|
+
if (!expire)
|
|
2521
|
+
expire = this.options.expire || defaultExpire
|
|
2522
|
+
data = data + ';' + ('0000000' + Math.floor(new Date().getTime() / 1000 - 946681200 + expire).toString(16)).slice(-8) + ';'
|
|
2523
|
+
const iv: Buffer = crypto.randomBytes(16)
|
|
2524
|
+
const cipher: crypto.CipherGCM = crypto.createCipheriv('aes-256-gcm', this.options.token, iv) as crypto.CipherGCM
|
|
2525
|
+
let encrypted: string = cipher.update(data, 'utf8', 'base64') + cipher.final('base64')
|
|
2526
|
+
encrypted = iv.toString('base64').slice(0, 22) + cipher.getAuthTag().toString('base64').slice(0, 22) + encrypted
|
|
2527
|
+
|
|
2528
|
+
return encrypted.replace(/==?/, '').replace(/\//g, '.').replace(/\+/g, '-')
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* Check acl over authenticated user with: `id`, `group/*`, `*`
|
|
2533
|
+
* @param {string} id - to authenticate: `id`, `group/id`, `model/action`, comma separated best: true => false => def
|
|
2534
|
+
* @param {boolean} [def=false] - default access
|
|
2535
|
+
*/
|
|
2536
|
+
acl (id: string, def: boolean = false): boolean {
|
|
2537
|
+
if (!this.req?.user)
|
|
2538
|
+
return false
|
|
2539
|
+
const reqAcl: {[key: string]: boolean} | undefined = this.req.user.acl || this.options.defaultAcl
|
|
2540
|
+
if (!reqAcl)
|
|
2541
|
+
return def
|
|
2542
|
+
|
|
2543
|
+
// this points to req
|
|
2544
|
+
let access: boolean | undefined
|
|
2545
|
+
const list = (id || '').split(',')
|
|
2546
|
+
list.forEach(id => access ||= id === 'auth' ? true : reqAcl[id])
|
|
2547
|
+
if (access !== undefined)
|
|
2548
|
+
return access
|
|
2549
|
+
list.forEach(id => {
|
|
2550
|
+
const p = id.lastIndexOf('/')
|
|
2551
|
+
if (p > 0)
|
|
2552
|
+
access ||= reqAcl[id.slice(0, p + 1) + '*']
|
|
2553
|
+
})
|
|
2554
|
+
if (access === undefined)
|
|
2555
|
+
access = reqAcl['*']
|
|
2556
|
+
return access ?? def
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* Authenticate user and setup cookie
|
|
2561
|
+
* @param {string|UserInfo} usr - user id used with options.users to retrieve user object. User object must contain `id` and `acl` object (Ex. usr = {id:'usr', acl:{'users/*':true}})
|
|
2562
|
+
* @param {string} [psw] - user password (if used for user authentication with options.users)
|
|
2563
|
+
* @param {number} [expire] - expire time in seconds (default: options.expire)
|
|
2564
|
+
*/
|
|
2565
|
+
async token (usr: string | UserInfo | undefined, psw: string | undefined, expire?: number): Promise<string | undefined> {
|
|
2566
|
+
let data: string | undefined
|
|
2567
|
+
if (typeof usr === 'object' && usr && (usr.id || usr._id))
|
|
2568
|
+
data = JSON.stringify(usr)
|
|
2569
|
+
else if (typeof usr === 'string') {
|
|
2570
|
+
if (psw !== undefined) {
|
|
2571
|
+
const userInfo = await this.users(usr, psw)
|
|
2572
|
+
data = userInfo?.id || userInfo?._id
|
|
2573
|
+
} else
|
|
2574
|
+
data = usr
|
|
2575
|
+
}
|
|
2576
|
+
if (data)
|
|
2577
|
+
return this.encode(data, expire)
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Authenticate user and setup cookie
|
|
2582
|
+
*/
|
|
2583
|
+
async login (usr: string | UserInfo | undefined, psw?: string, options?: {expire?: number, salt?: string}): Promise<UserInfo | undefined> {
|
|
2584
|
+
let usrInfo: UserInfo | undefined
|
|
2585
|
+
if (typeof usr === 'object')
|
|
2586
|
+
usrInfo = usr
|
|
2587
|
+
if (typeof usr === 'string')
|
|
2588
|
+
usrInfo = await this.users(usr, psw, options?.salt)
|
|
2589
|
+
if (usrInfo?.id || usrInfo?._id) {
|
|
2590
|
+
const expire = Math.min(34560000, options?.expire || this.options.expire || defaultExpire)
|
|
2591
|
+
const expireTime = new Date().getTime() + expire * 1000
|
|
2592
|
+
const token = await this.token((usrInfo?.id || usrInfo?._id), undefined, expire)
|
|
2593
|
+
|
|
2594
|
+
if (token && this.res && this.req) {
|
|
2595
|
+
const oldToken: string | undefined = (this.req as any).tokenId
|
|
2596
|
+
if (oldToken)
|
|
2597
|
+
delete this.options.cache?.[oldToken]
|
|
2598
|
+
this.req.tokenId = token
|
|
2599
|
+
if (this.options.mode === 'cookie')
|
|
2600
|
+
this.res.setHeader('set-cookie', `token=${token}; max-age=${expire}; path=${this.req.baseUrl || '/'}`)
|
|
2601
|
+
if (this.options.cache)
|
|
2602
|
+
this.options.cache[token] = { data: usrInfo, time: expireTime }
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return usrInfo
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/** Logout logged in user */
|
|
2609
|
+
logout (): void {
|
|
2610
|
+
if (this.req && this.res) {
|
|
2611
|
+
const oldToken: string | undefined = (this.req as any).tokenId
|
|
2612
|
+
if (oldToken)
|
|
2613
|
+
delete this.options.cache?.[oldToken]
|
|
2614
|
+
if (this.options.mode === 'cookie')
|
|
2615
|
+
this.res.setHeader('set-cookie', `token=; max-age=-1; path=${this.req.baseUrl || '/'}`)
|
|
2616
|
+
else {
|
|
2617
|
+
if (this.req.headers.authentication) {
|
|
2618
|
+
this.res.setHeader('set-cookie', 'token=')
|
|
2619
|
+
this.res.error(401)
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
this.req.user = undefined
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
/** Get hashed string from user and password */
|
|
2627
|
+
password (usr: string, psw: string, salt?: string): string {
|
|
2628
|
+
return Auth.password(usr, psw, salt)
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/** Get hashed string from user and password */
|
|
2632
|
+
static password (usr: string, psw: string, salt?: string): string {
|
|
2633
|
+
if (usr)
|
|
2634
|
+
psw = crypto.createHash('sha512').update(usr + '|' + psw).digest('hex')
|
|
2635
|
+
if (salt) {
|
|
2636
|
+
salt = salt === '*' ? crypto.randomBytes(32).toString('hex') : (salt.length > 128 ? salt.slice(0, salt.length - 128) : salt)
|
|
2637
|
+
psw = salt + crypto.createHash('sha512').update(psw + salt).digest('hex')
|
|
2638
|
+
}
|
|
2639
|
+
return psw
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
/** Validate user password */
|
|
2643
|
+
checkPassword (usr: string, psw: string, storedPsw: string, salt?: string): boolean {
|
|
2644
|
+
return Auth.checkPassword(usr, psw, storedPsw, salt)
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
/** Validate user password */
|
|
2648
|
+
static checkPassword (usr: string, psw: string, storedPsw: string, salt?: string): boolean {
|
|
2649
|
+
let success: boolean = false
|
|
2650
|
+
if (usr && storedPsw) {
|
|
2651
|
+
if (storedPsw.length > 128) { // salted hash
|
|
2652
|
+
if (psw.length < 128) // plain == salted-hash
|
|
2653
|
+
success = this.password(usr, psw, storedPsw) === storedPsw
|
|
2654
|
+
else if (psw.length === 128) // hash == salted-hash
|
|
2655
|
+
success = this.password('', psw, storedPsw) === storedPsw
|
|
2656
|
+
else // rnd-salted-hash === salted-hash
|
|
2657
|
+
success = psw === this.password('', storedPsw, psw)
|
|
2658
|
+
} else if (storedPsw.length === 128) { // hash
|
|
2659
|
+
if (psw.length < 128) // plain == hash
|
|
2660
|
+
success = this.password(usr, psw) === storedPsw
|
|
2661
|
+
else if (psw.length === 128) // hash == hash
|
|
2662
|
+
success = psw === storedPsw
|
|
2663
|
+
else if (salt) // rnd-salted-hash === hash
|
|
2664
|
+
success = psw === this.password('', this.password('', storedPsw, salt), psw)
|
|
2665
|
+
} else { // plain
|
|
2666
|
+
if (psw.length < 128) // plain == plain
|
|
2667
|
+
success = psw === storedPsw
|
|
2668
|
+
else if (psw.length === 128) // hash == plain
|
|
2669
|
+
success = psw === this.password(usr, storedPsw)
|
|
2670
|
+
else if (salt) // rnd-salted-hash == plain
|
|
2671
|
+
success = psw === this.password('', this.password(usr, storedPsw, salt), psw)
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
return success
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/** Clear user cache if users setting where changed */
|
|
2678
|
+
clearCache (): void {
|
|
2679
|
+
const cache = this.options.cache
|
|
2680
|
+
if (cache)
|
|
2681
|
+
Object.keys(cache).forEach(k => delete cache[k])
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/*
|
|
2686
|
+
// Client login implementation
|
|
2687
|
+
async function login (username, password) {
|
|
2688
|
+
function hex (b) { return Array.from(Uint8Array.from(b)).map(b => b.toString(16).padStart(2, "0")).join("") }
|
|
2689
|
+
async function hash (data) { return hex(await crypto.subtle.digest('sha-512', new TextEncoder().encode(data)))}
|
|
2690
|
+
const rnd = hex(crypto.getRandomValues(new Int8Array(32)))
|
|
2691
|
+
return rnd + await hash(await hash(username + '|' + password) + rnd)
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// Server login implementation
|
|
2695
|
+
// password should be stored with `req.auth.password(req, user, password)` but may be in plain form too
|
|
2696
|
+
|
|
2697
|
+
server.use('auth', {
|
|
2698
|
+
users: {
|
|
2699
|
+
testuser: {
|
|
2700
|
+
acl: {
|
|
2701
|
+
'user/update': true,
|
|
2702
|
+
'messages/*': true,
|
|
2703
|
+
},
|
|
2704
|
+
password: <hash-password>
|
|
2705
|
+
},
|
|
2706
|
+
admin: {
|
|
2707
|
+
acl: {
|
|
2708
|
+
'user/*': true,
|
|
2709
|
+
'messages/*': true,
|
|
2710
|
+
},
|
|
2711
|
+
password: <hash-password>
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
})
|
|
2715
|
+
//or
|
|
2716
|
+
server.use('auth', {
|
|
2717
|
+
async users (usr, psw) {
|
|
2718
|
+
const obj = await db.getUser(usr)
|
|
2719
|
+
if (!obj.disabled && this.checkPassword(usr, psw, obj.password)) {
|
|
2720
|
+
const {password, ...res} = obj // remove password field
|
|
2721
|
+
return res
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
})
|
|
2725
|
+
|
|
2726
|
+
async function loginMiddleware(req, res) {
|
|
2727
|
+
const user = await req.auth.login(req.body.username || '', req.body.password || '')
|
|
2728
|
+
if (user)
|
|
2729
|
+
res.jsonSuccess(user)
|
|
2730
|
+
else
|
|
2731
|
+
res.jsonError('Access denied')
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
// More secure way is to store salted hashes on server `req.auth.password(user, password, '*')`
|
|
2735
|
+
// and corespondingly 1 extra step is needed in athentication to retrieve salt from passwod hash `password.slice(0, 64)`
|
|
2736
|
+
// client function will be:
|
|
2737
|
+
async function login (username, password, salt) {
|
|
2738
|
+
function hex (b) { return Array.from(Uint8Array.from(b)).map(b => b.toString(16).padStart(2, "0")).join("") }
|
|
2739
|
+
async function hash (data) { return hex(await crypto.subtle.digest('sha-512', new TextEncoder().encode(data)))}
|
|
2740
|
+
const rnd = hex(crypto.getRandomValues(new Int8Array(32)))
|
|
2741
|
+
return rnd + await hash(await hash(salt + await hash(await hash(username + '|' + password) + salt)) + rnd)
|
|
2742
|
+
}
|
|
2743
|
+
*/
|
|
2744
|
+
|
|
2745
|
+
/** Authentication plugin */
|
|
2746
|
+
class AuthPlugin extends Plugin {
|
|
2747
|
+
options: AuthOptions
|
|
2748
|
+
name: string = 'auth'
|
|
2749
|
+
|
|
2750
|
+
constructor (router: Router, options?: AuthOptions) {
|
|
2751
|
+
super(router)
|
|
2752
|
+
|
|
2753
|
+
if (router.auth)
|
|
2754
|
+
throw new Error('Auth plugin already initialized')
|
|
2755
|
+
|
|
2756
|
+
this.options = {
|
|
2757
|
+
mode: 'cookie',
|
|
2758
|
+
token: defaultToken,
|
|
2759
|
+
expire: defaultExpire,
|
|
2760
|
+
defaultAcl: { '*': false },
|
|
2761
|
+
cache: {},
|
|
2762
|
+
...options,
|
|
2763
|
+
cacheCleanup: new Date().getTime()
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
if (this.options.token === defaultToken)
|
|
2767
|
+
console.warn('Default token in auth plugin')
|
|
2768
|
+
|
|
2769
|
+
router.auth = new Auth(this.options)
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
/** Authentication middleware */
|
|
2773
|
+
async handler (req: ServerRequest, res: ServerResponse, next: Function) {
|
|
2774
|
+
const options: AuthOptions = this.options, cache = options.cache
|
|
2775
|
+
const auth = new Auth(options)
|
|
2776
|
+
req.auth = auth
|
|
2777
|
+
auth.req = req
|
|
2778
|
+
auth.res = res
|
|
2779
|
+
|
|
2780
|
+
const authorization = req.headers.authorization || '';
|
|
2781
|
+
if (authorization.startsWith('Basic ')) {
|
|
2782
|
+
let usr = cache?.[authorization]
|
|
2783
|
+
if (usr)
|
|
2784
|
+
req.user = usr.data
|
|
2785
|
+
else {
|
|
2786
|
+
const usrpsw = Buffer.from(authorization.slice(6), 'base64').toString('utf-8'),
|
|
2787
|
+
pos = usrpsw.indexOf(':'), username = usrpsw.slice(0, pos), psw = usrpsw.slice(pos + 1)
|
|
2788
|
+
if (username && psw)
|
|
2789
|
+
req.user = await auth.users(username, psw)
|
|
2790
|
+
if (!req.user)
|
|
2791
|
+
return res.error(401)
|
|
2792
|
+
if (cache) // 1 hour to expire in cache
|
|
2793
|
+
cache[authorization] = { data: req.user, time: new Date().getTime() + Math.min(3600000, (options.expire || defaultExpire) * 1000) }
|
|
2794
|
+
}
|
|
2795
|
+
return next()
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
const cookie = req.headers.cookie, cookies = cookie ? cookie.split(/;\s+/g) : []
|
|
2799
|
+
const sid = cookies.find(s => s.startsWith('token='))
|
|
2800
|
+
let token = ''
|
|
2801
|
+
if (authorization.startsWith('Bearer '))
|
|
2802
|
+
token = authorization.slice(7)
|
|
2803
|
+
|
|
2804
|
+
if (sid)
|
|
2805
|
+
token = sid.slice(sid.indexOf('=') + 1)
|
|
2806
|
+
|
|
2807
|
+
if (!token)
|
|
2808
|
+
token = req.query.token
|
|
2809
|
+
|
|
2810
|
+
if (token) {
|
|
2811
|
+
const now = new Date().getTime()
|
|
2812
|
+
let usr: UserInfo, expire: number | undefined
|
|
2813
|
+
if (cache && (!options.cacheCleanup || options.cacheCleanup > now)) {
|
|
2814
|
+
options.cacheCleanup = now + 600000
|
|
2815
|
+
process.nextTick(() => Object.entries(cache).forEach((entry: [string, { data: UserInfo, time: number }]) => {
|
|
2816
|
+
if (entry[1].time < now) delete cache[entry[0]]
|
|
2817
|
+
}))
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// check in cache
|
|
2821
|
+
(req as any).tokenId = token
|
|
2822
|
+
let usrCache = cache?.[token]
|
|
2823
|
+
if (usrCache && usrCache.time > now)
|
|
2824
|
+
[req.user, expire] = [usrCache.data, Math.floor((usrCache.time - now) / 1000)]
|
|
2825
|
+
else {
|
|
2826
|
+
const usrData = auth.decode(token)
|
|
2827
|
+
if (!usrData.data) {
|
|
2828
|
+
req.auth.logout()
|
|
2829
|
+
return new AccessDenied()
|
|
2830
|
+
}
|
|
2831
|
+
if (usrData.data.startsWith('{')) {
|
|
2832
|
+
try {
|
|
2833
|
+
usr = JSON.parse(usrData.data)
|
|
2834
|
+
req.user = usr
|
|
2835
|
+
} catch (e) { }
|
|
2836
|
+
} else {
|
|
2837
|
+
req.user = await auth.users(usrData.data)
|
|
2838
|
+
if (!req.user) {
|
|
2839
|
+
req.auth.logout()
|
|
2840
|
+
return new AccessDenied()
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
expire = usrData.expire
|
|
2844
|
+
if (req.user && cache)
|
|
2845
|
+
cache[token] = { data: req.user, time: expire }
|
|
2846
|
+
}
|
|
2847
|
+
// renew
|
|
2848
|
+
if (req.user && expire < (options.expire || defaultExpire) / 2)
|
|
2849
|
+
await req.auth.login(req.user)
|
|
2850
|
+
}
|
|
2851
|
+
if (!res.headersSent)
|
|
2852
|
+
return next()
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
MicroServer.plugins.auth = AuthPlugin
|
|
2856
|
+
|
|
2857
|
+
/** Create microserver */
|
|
2858
|
+
export function create (config: MicroServerConfig) { return new MicroServer(config) }
|
|
2859
|
+
|
|
2860
|
+
export interface FileStoreOptions {
|
|
2861
|
+
/** Base directory */
|
|
2862
|
+
dir?: string
|
|
2863
|
+
/** Cache timeout in milliseconds */
|
|
2864
|
+
cacheTimeout?: number
|
|
2865
|
+
/** Max number of cached items */
|
|
2866
|
+
cacheItems?: number
|
|
2867
|
+
/** Debounce timeout in milliseconds for autosave */
|
|
2868
|
+
debounceTimeout?: number
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
interface FileItem {
|
|
2872
|
+
atime: number
|
|
2873
|
+
mtime: number
|
|
2874
|
+
data: any
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/** JSON File store */
|
|
2878
|
+
export class FileStore {
|
|
2879
|
+
private _cache: { [name: string]: FileItem }
|
|
2880
|
+
private _dir: string
|
|
2881
|
+
private _cacheTimeout: number
|
|
2882
|
+
private _cacheItems: number
|
|
2883
|
+
private _debounceTimeout: number
|
|
2884
|
+
private _iter: number
|
|
2885
|
+
|
|
2886
|
+
constructor (options?: FileStoreOptions) {
|
|
2887
|
+
this._cache = {}
|
|
2888
|
+
this._dir = options?.dir || 'data'
|
|
2889
|
+
this._cacheTimeout = options?.cacheTimeout || 2000
|
|
2890
|
+
this._cacheItems = options?.cacheItems || 10
|
|
2891
|
+
this._debounceTimeout = options?.debounceTimeout || 1000
|
|
2892
|
+
this._iter = 0
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/** cleanup cache */
|
|
2896
|
+
cleanup (): void {
|
|
2897
|
+
if (this._iter > this._cacheItems) {
|
|
2898
|
+
this._iter = 0
|
|
2899
|
+
const now = new Date().getTime()
|
|
2900
|
+
const keys = Object.keys(this._cache)
|
|
2901
|
+
if (keys.length > this._cacheItems) {
|
|
2902
|
+
keys.forEach(n => {
|
|
2903
|
+
if (now - this._cache[n].atime > this._cacheTimeout)
|
|
2904
|
+
delete this._cache[n]
|
|
2905
|
+
})
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
private _queue: Promise<any> = Promise.resolve()
|
|
2911
|
+
|
|
2912
|
+
private async _sync(cb: Function): Promise<any> {
|
|
2913
|
+
let r: Function
|
|
2914
|
+
let p: Promise<any> = new Promise(resolve => r = resolve)
|
|
2915
|
+
this._queue = this._queue.then(async () => {
|
|
2916
|
+
try {
|
|
2917
|
+
r(await cb())
|
|
2918
|
+
} catch (e) {
|
|
2919
|
+
r(e)
|
|
2920
|
+
}
|
|
2921
|
+
})
|
|
2922
|
+
return p
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
async close () {
|
|
2926
|
+
await this.sync()
|
|
2927
|
+
this._iter = 0
|
|
2928
|
+
this._cache = {}
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
async sync () {
|
|
2932
|
+
for (const name in this._cache) {
|
|
2933
|
+
for (const key in this._cache)
|
|
2934
|
+
await (this._cache[key].data as any).__sync__?.()
|
|
2935
|
+
}
|
|
2936
|
+
await this._queue
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/** load json file data */
|
|
2940
|
+
async load (name: string, autosave: boolean = false): Promise<any> {
|
|
2941
|
+
let item: FileItem = this._cache[name]
|
|
2942
|
+
if (item && new Date().getTime() - item.atime < this._cacheTimeout)
|
|
2943
|
+
return item.data
|
|
2944
|
+
|
|
2945
|
+
return this._sync(async () => {
|
|
2946
|
+
item = this._cache[name]
|
|
2947
|
+
if (item && new Date().getTime() - item.atime < this._cacheTimeout)
|
|
2948
|
+
return item.data
|
|
2949
|
+
try {
|
|
2950
|
+
const stat = await fs.promises.lstat(path.join(this._dir, name))
|
|
2951
|
+
if (item?.mtime !== stat.mtime.getTime()) {
|
|
2952
|
+
let data: object = JSON.parse(await fs.promises.readFile(path.join(this._dir, name), 'utf8') || '{}')
|
|
2953
|
+
this._iter++
|
|
2954
|
+
this.cleanup()
|
|
2955
|
+
if (autosave)
|
|
2956
|
+
data = this.observe(data, () => this.save(name, data))
|
|
2957
|
+
this._cache[name] = {
|
|
2958
|
+
atime: new Date().getTime(),
|
|
2959
|
+
mtime: stat.mtime.getTime(),
|
|
2960
|
+
data: data
|
|
2961
|
+
}
|
|
2962
|
+
return data
|
|
2963
|
+
}
|
|
2964
|
+
} catch {
|
|
2965
|
+
delete this._cache[name]
|
|
2966
|
+
}
|
|
2967
|
+
return item?.data
|
|
2968
|
+
})
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/** save data */
|
|
2972
|
+
async save (name: string, data: any): Promise<any> {
|
|
2973
|
+
this._iter++
|
|
2974
|
+
const item: FileItem = {
|
|
2975
|
+
atime: new Date().getTime(),
|
|
2976
|
+
mtime: new Date().getTime(),
|
|
2977
|
+
data: data
|
|
2978
|
+
}
|
|
2979
|
+
this._cache[name] = item
|
|
2980
|
+
this._sync(async () => {
|
|
2981
|
+
if (this._cache[name] === item) {
|
|
2982
|
+
this.cleanup()
|
|
2983
|
+
try {
|
|
2984
|
+
await fs.promises.writeFile(path.join(this._dir, name), JSON.stringify(this._cache[name].data), 'utf8')
|
|
2985
|
+
} catch {
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
})
|
|
2989
|
+
return data
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/** load all files in directory */
|
|
2993
|
+
async all (name: string, autosave: boolean = false): Promise<{[key: string]: any}> {
|
|
2994
|
+
return this._sync(async () => {
|
|
2995
|
+
const files = await fs.promises.readdir(name ? path.join(this._dir, name) : this._dir)
|
|
2996
|
+
const res: {[key: string]: any} = {}
|
|
2997
|
+
await Promise.all(files.map(file =>
|
|
2998
|
+
(file.startsWith('.') && !file.startsWith('_') && !file.startsWith('$')) &&
|
|
2999
|
+
this.load(name ? name + '/' + file : file, autosave)
|
|
3000
|
+
.then(data => {res[file] = data})
|
|
3001
|
+
))
|
|
3002
|
+
return res
|
|
3003
|
+
})
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/** delete data file */
|
|
3007
|
+
async delete (name: string): Promise<void> {
|
|
3008
|
+
delete this._cache[name]
|
|
3009
|
+
return this._sync(async () => {
|
|
3010
|
+
if (this._cache[name])
|
|
3011
|
+
return
|
|
3012
|
+
try {
|
|
3013
|
+
await fs.promises.unlink(path.join(this._dir, name))
|
|
3014
|
+
} catch {
|
|
3015
|
+
}
|
|
3016
|
+
})
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
/** Observe data object */
|
|
3020
|
+
observe (data: object, cb: (data: object, key: string, value: any) => void): object {
|
|
3021
|
+
function debounce(func: (...args: any) => void, debounceTime: number): Function | any {
|
|
3022
|
+
const maxTotalDebounceTime: number = debounceTime * 2
|
|
3023
|
+
let timeoutId: any
|
|
3024
|
+
let lastCallTime = Date.now()
|
|
3025
|
+
let _args: any
|
|
3026
|
+
|
|
3027
|
+
const abort = () => {
|
|
3028
|
+
if (timeoutId)
|
|
3029
|
+
clearTimeout(timeoutId)
|
|
3030
|
+
_args = undefined
|
|
3031
|
+
timeoutId = undefined
|
|
3032
|
+
}
|
|
3033
|
+
const exec = () => {
|
|
3034
|
+
const args = _args
|
|
3035
|
+
if (args) {
|
|
3036
|
+
abort()
|
|
3037
|
+
lastCallTime = Date.now()
|
|
3038
|
+
func(...args)
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
const start = (...args: any) => {
|
|
3042
|
+
const currentTime = Date.now()
|
|
3043
|
+
const timeSinceLastCall = currentTime - lastCallTime
|
|
3044
|
+
abort()
|
|
3045
|
+
if (timeSinceLastCall >= maxTotalDebounceTime) {
|
|
3046
|
+
func(...args)
|
|
3047
|
+
lastCallTime = currentTime
|
|
3048
|
+
} else {
|
|
3049
|
+
_args = args
|
|
3050
|
+
timeoutId = setTimeout(exec, Math.max(debounceTime - timeSinceLastCall, 0))
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
start.abort = abort
|
|
3054
|
+
start.immediate = exec
|
|
3055
|
+
return start
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
const changed = debounce((target: {[key: string]: any}, key: string) => cb.call(data, target, key, target[key]), this._debounceTimeout)
|
|
3059
|
+
const handler = {
|
|
3060
|
+
get(target: {[key: string]: any}, key: string) {
|
|
3061
|
+
if (key === '__sync__')
|
|
3062
|
+
return changed.immediate
|
|
3063
|
+
if (typeof target[key] === 'object' && target[key] !== null)
|
|
3064
|
+
return new Proxy(target[key], handler)
|
|
3065
|
+
return target[key]
|
|
3066
|
+
},
|
|
3067
|
+
set(target: {[key: string]: any}, key: string, value: any) {
|
|
3068
|
+
if (target[key] === value)
|
|
3069
|
+
return true
|
|
3070
|
+
if (value && typeof value === 'object')
|
|
3071
|
+
value = {...value}
|
|
3072
|
+
target[key] = value
|
|
3073
|
+
changed(target, key)
|
|
3074
|
+
return true
|
|
3075
|
+
},
|
|
3076
|
+
deleteProperty(target: {[key: string]: any}, key: string) {
|
|
3077
|
+
delete target[key]
|
|
3078
|
+
changed(target, key)
|
|
3079
|
+
return true
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
return new Proxy(data, handler)
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
let globalObjectId = crypto.randomBytes(8)
|
|
3087
|
+
function newObjectId() {
|
|
3088
|
+
for (let i = 7; i >= 0; i--)
|
|
3089
|
+
if (++globalObjectId[i] < 256)
|
|
3090
|
+
break
|
|
3091
|
+
return (new Date().getTime() / 1000 | 0).toString(16) + globalObjectId.toString('hex')
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
/** Model validation options */
|
|
3095
|
+
interface ModelValidateOptions {
|
|
3096
|
+
/** User info */
|
|
3097
|
+
user?: UserInfo
|
|
3098
|
+
/** Request params */
|
|
3099
|
+
params?: Object
|
|
3100
|
+
/** is insert */
|
|
3101
|
+
insert?: boolean
|
|
3102
|
+
/** is read-only */
|
|
3103
|
+
readOnly?: boolean
|
|
3104
|
+
/** validate */
|
|
3105
|
+
validate?: boolean
|
|
3106
|
+
/** use default */
|
|
3107
|
+
default?: boolean
|
|
3108
|
+
/** is required */
|
|
3109
|
+
required?: boolean
|
|
3110
|
+
/** projection fields */
|
|
3111
|
+
projection?: Document
|
|
3112
|
+
}
|
|
3113
|
+
/** Model field validation options */
|
|
3114
|
+
interface ModelValidateFieldOptions extends ModelValidateOptions {
|
|
3115
|
+
name: string
|
|
3116
|
+
field: FieldDescriptionInternal
|
|
3117
|
+
model: Model
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
export interface ModelCallbackFunc {
|
|
3121
|
+
(options: any): any
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
/** Model field description */
|
|
3125
|
+
export interface FieldDescriptionObject {
|
|
3126
|
+
/** Field type */
|
|
3127
|
+
type: string | Function | Model | Array<string | Function | Model>
|
|
3128
|
+
/** Is array */
|
|
3129
|
+
array?: boolean
|
|
3130
|
+
/** Is required */
|
|
3131
|
+
required?: boolean | string | ModelCallbackFunc
|
|
3132
|
+
/** Can read */
|
|
3133
|
+
canRead?: boolean | string | ModelCallbackFunc
|
|
3134
|
+
/** Can write */
|
|
3135
|
+
canWrite?: boolean | string | ModelCallbackFunc
|
|
3136
|
+
/** Default value */
|
|
3137
|
+
default?: number | string | ModelCallbackFunc
|
|
3138
|
+
/** Validate function */
|
|
3139
|
+
validate?: (value: any, options: ModelValidateOptions) => string | number | object | null | Error | typeof Error
|
|
3140
|
+
/** Valid values */
|
|
3141
|
+
enum?: Array<string|number>
|
|
3142
|
+
/** Minimum value for string and number */
|
|
3143
|
+
minimum?: number | string
|
|
3144
|
+
/** Maximum value for string and number */
|
|
3145
|
+
maximum?: number | string
|
|
3146
|
+
/** Regex validation or 'email', 'url', 'date', 'time', 'date-time' */
|
|
3147
|
+
format?: string
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
type FieldDescription = FieldDescriptionObject | string | Function | Model | FieldDescription[]
|
|
3151
|
+
|
|
3152
|
+
interface FieldDescriptionInternal {
|
|
3153
|
+
type: string
|
|
3154
|
+
model?: Model
|
|
3155
|
+
required?: ModelCallbackFunc
|
|
3156
|
+
canRead: ModelCallbackFunc
|
|
3157
|
+
canWrite: ModelCallbackFunc
|
|
3158
|
+
default: ModelCallbackFunc
|
|
3159
|
+
validate: (value: any, options: ModelValidateFieldOptions) => any
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
export declare interface ModelCollections {
|
|
3163
|
+
collection(name: string): Promise<MicroCollection>
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
export class Model {
|
|
3167
|
+
static collections?: ModelCollections
|
|
3168
|
+
static models: {[key: string]: Model} = {}
|
|
3169
|
+
|
|
3170
|
+
/** Define model */
|
|
3171
|
+
static define(name: string, fields: {[key: string]: FieldDescription}, options?: {collection?: MicroCollection | Promise<MicroCollection>, class?: typeof Model}): Model {
|
|
3172
|
+
options = options || {}
|
|
3173
|
+
if (!options.collection && this.collections)
|
|
3174
|
+
options.collection = this.collections.collection(name)
|
|
3175
|
+
const inst: Model = options?.class
|
|
3176
|
+
? new options.class(fields, {name, ...options})
|
|
3177
|
+
: new Model(fields, {name, ...options})
|
|
3178
|
+
Model.models[name] = inst
|
|
3179
|
+
return inst
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
/** Model fields description */
|
|
3183
|
+
model: {[key: string]: FieldDescriptionInternal}
|
|
3184
|
+
/** Model name */
|
|
3185
|
+
name: string
|
|
3186
|
+
/** Model collection for persistance */
|
|
3187
|
+
collection?: MicroCollection | Promise<MicroCollection>
|
|
3188
|
+
|
|
3189
|
+
/** Create model acording to description */
|
|
3190
|
+
constructor (fields: {[key: string]: FieldDescription}, options?: {collection?: MicroCollection | Promise<MicroCollection>, name?: string}) {
|
|
3191
|
+
const model: {[key: string]: FieldDescriptionInternal} = this.model = {}
|
|
3192
|
+
this.name = options?.name || (this as any).__proto__.constructor.name
|
|
3193
|
+
this.collection = options?.collection
|
|
3194
|
+
this.handler = this.handler.bind(this)
|
|
3195
|
+
|
|
3196
|
+
for (const n in fields) {
|
|
3197
|
+
const modelField: FieldDescriptionInternal = this.model[n] = {name: n} as any
|
|
3198
|
+
let field: FieldDescriptionObject = fields[n] as any
|
|
3199
|
+
let fieldType: any, isArray: boolean = false
|
|
3200
|
+
if (typeof field === 'object' && !Array.isArray(field) && !(field instanceof Model))
|
|
3201
|
+
fieldType = field.type
|
|
3202
|
+
else {
|
|
3203
|
+
fieldType = field
|
|
3204
|
+
field = {} as any
|
|
3205
|
+
}
|
|
3206
|
+
if (Array.isArray(fieldType)) {
|
|
3207
|
+
isArray = true
|
|
3208
|
+
fieldType = fieldType[0]
|
|
3209
|
+
}
|
|
3210
|
+
if (typeof fieldType === 'function')
|
|
3211
|
+
fieldType = fieldType.name
|
|
3212
|
+
|
|
3213
|
+
let validateType: (value: any, options: ModelValidateFieldOptions) => any
|
|
3214
|
+
|
|
3215
|
+
if (fieldType instanceof Model) {
|
|
3216
|
+
modelField.model = fieldType
|
|
3217
|
+
fieldType = 'model'
|
|
3218
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => modelField.model?.validate(value, options)
|
|
3219
|
+
} else {
|
|
3220
|
+
fieldType = fieldType.toString().toLowerCase()
|
|
3221
|
+
switch (fieldType) {
|
|
3222
|
+
case "objectid":
|
|
3223
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3224
|
+
if (typeof value === 'string')
|
|
3225
|
+
return value
|
|
3226
|
+
if (typeof value === 'object' && value.constructor.name.toLowerCase() === 'objectid')
|
|
3227
|
+
return JSON.stringify(value)
|
|
3228
|
+
throw new InvalidData(options.name, 'field type')
|
|
3229
|
+
}
|
|
3230
|
+
break
|
|
3231
|
+
case 'string':
|
|
3232
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3233
|
+
if (typeof value === 'string')
|
|
3234
|
+
return value
|
|
3235
|
+
if (typeof value === 'number')
|
|
3236
|
+
return value.toString()
|
|
3237
|
+
throw new InvalidData(options.name, 'field type')
|
|
3238
|
+
}
|
|
3239
|
+
break
|
|
3240
|
+
case 'number':
|
|
3241
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3242
|
+
if (typeof value === 'number')
|
|
3243
|
+
return value
|
|
3244
|
+
throw new InvalidData(options.name, 'field type')
|
|
3245
|
+
}
|
|
3246
|
+
break
|
|
3247
|
+
case 'int':
|
|
3248
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3249
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
3250
|
+
return value
|
|
3251
|
+
throw new InvalidData(options.name, 'field type')
|
|
3252
|
+
}
|
|
3253
|
+
break
|
|
3254
|
+
case "json":
|
|
3255
|
+
case "object":
|
|
3256
|
+
fieldType = 'object'
|
|
3257
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3258
|
+
if (typeof value === 'object')
|
|
3259
|
+
return value
|
|
3260
|
+
throw new InvalidData(options.name, 'field type')
|
|
3261
|
+
}
|
|
3262
|
+
break
|
|
3263
|
+
case 'boolean':
|
|
3264
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3265
|
+
if (typeof value === 'boolean')
|
|
3266
|
+
return value
|
|
3267
|
+
throw new InvalidData(options.name, 'field type')
|
|
3268
|
+
}
|
|
3269
|
+
break
|
|
3270
|
+
case 'array':
|
|
3271
|
+
isArray = true
|
|
3272
|
+
fieldType = 'any'
|
|
3273
|
+
validateType = (value: any) => value
|
|
3274
|
+
break
|
|
3275
|
+
case 'date':
|
|
3276
|
+
validateType = (value: any, options: ModelValidateFieldOptions) => {
|
|
3277
|
+
if (typeof value === 'string')
|
|
3278
|
+
value = new Date(value)
|
|
3279
|
+
if (value instanceof Date && !isNaN(value.getTime()))
|
|
3280
|
+
return value
|
|
3281
|
+
throw new InvalidData(options.name, 'field type')
|
|
3282
|
+
}
|
|
3283
|
+
break
|
|
3284
|
+
case '*':
|
|
3285
|
+
case 'any':
|
|
3286
|
+
fieldType = 'any'
|
|
3287
|
+
validateType = (value: any) => value
|
|
3288
|
+
break
|
|
3289
|
+
default:
|
|
3290
|
+
throw new InvalidData(n, 'field type ' + fieldType)
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
modelField.type = fieldType + (isArray ? '[]' : '')
|
|
3294
|
+
const validators: Function[] = [validateType]
|
|
3295
|
+
const validate = (value: any, options: ModelValidateFieldOptions) => validators.reduce((v: any, f: Function) => {
|
|
3296
|
+
v = f(v, options)
|
|
3297
|
+
if (v === Error)
|
|
3298
|
+
throw new InvalidData(options.name, 'field value')
|
|
3299
|
+
if (v instanceof Error)
|
|
3300
|
+
throw v
|
|
3301
|
+
return v
|
|
3302
|
+
}, value)
|
|
3303
|
+
if (isArray)
|
|
3304
|
+
modelField.validate = (value: any[], options: ModelValidateFieldOptions) => {
|
|
3305
|
+
if (Array.isArray(value))
|
|
3306
|
+
return value.map((v, i) => validate(v, { ...options, name: options.name + '[' + i + ']' }))
|
|
3307
|
+
throw new InvalidData(options.name, 'field type')
|
|
3308
|
+
}
|
|
3309
|
+
else
|
|
3310
|
+
modelField.validate = validate
|
|
3311
|
+
modelField.required = field.required ? this._fieldFunction(field.required, false) : undefined
|
|
3312
|
+
modelField.canWrite = this._fieldFunction(field.canWrite, typeof field.canWrite === 'string' && field.canWrite.startsWith('$') ? false : true)
|
|
3313
|
+
modelField.canRead = this._fieldFunction(field.canRead, typeof field.canRead === 'string' && field.canRead.startsWith('$') ? false : true)
|
|
3314
|
+
if (field.default !== undefined) {
|
|
3315
|
+
const def = field.default
|
|
3316
|
+
if (typeof def === 'function' && def.name === 'ObjectId')
|
|
3317
|
+
modelField.default = () => newObjectId()
|
|
3318
|
+
else if (def === Date)
|
|
3319
|
+
modelField.default = () => new Date()
|
|
3320
|
+
else if (typeof def !== 'function')
|
|
3321
|
+
modelField.default = this._fieldFunction(def)
|
|
3322
|
+
else
|
|
3323
|
+
modelField.default = def
|
|
3324
|
+
}
|
|
3325
|
+
if (field.minimum !== undefined) {
|
|
3326
|
+
const minimum = field.minimum
|
|
3327
|
+
validators.push((value: any, options: ModelValidateFieldOptions) => {
|
|
3328
|
+
try { if (value >= minimum) return value } catch (e) {}
|
|
3329
|
+
return Error
|
|
3330
|
+
})
|
|
3331
|
+
}
|
|
3332
|
+
if (field.maximum !== undefined) {
|
|
3333
|
+
const maximum = field.maximum
|
|
3334
|
+
validators.push((value: any, options: ModelValidateFieldOptions) => {
|
|
3335
|
+
try { if (value <= maximum) return value } catch (e) {}
|
|
3336
|
+
return Error
|
|
3337
|
+
})
|
|
3338
|
+
}
|
|
3339
|
+
if (field.enum) {
|
|
3340
|
+
const enumField = field.enum
|
|
3341
|
+
validators.push((value: any, options: ModelValidateFieldOptions) => enumField.includes(value) ? value: Error)
|
|
3342
|
+
}
|
|
3343
|
+
if (field.format && modelField.type === 'string') {
|
|
3344
|
+
let format = field.format
|
|
3345
|
+
switch (format) {
|
|
3346
|
+
case 'date':
|
|
3347
|
+
format = '^\\d{4}-\\d{2}-\\d{2}$'
|
|
3348
|
+
break
|
|
3349
|
+
case 'time':
|
|
3350
|
+
format = '^\\d{2}:\\d{2}(:\\d{2})?$'
|
|
3351
|
+
break
|
|
3352
|
+
case 'date-time':
|
|
3353
|
+
format = '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}(:\\d{2})?$'
|
|
3354
|
+
break
|
|
3355
|
+
case 'url':
|
|
3356
|
+
format = '^https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]'
|
|
3357
|
+
break
|
|
3358
|
+
case 'email':
|
|
3359
|
+
format = '^[a-zA-Z0-9.!#$%&\'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$'
|
|
3360
|
+
break
|
|
3361
|
+
}
|
|
3362
|
+
const regex = new RegExp(format)
|
|
3363
|
+
validators.push((value: any, options: ModelValidateFieldOptions) => regex.test(value) ? value : Error)
|
|
3364
|
+
}
|
|
3365
|
+
if (field.validate)
|
|
3366
|
+
validators.push(field.validate)
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
/** Validate data over model */
|
|
3371
|
+
validate (data: Document, options?: ModelValidateOptions): Document {
|
|
3372
|
+
options = options || {}
|
|
3373
|
+
const prefix: string = (options as any).name ? (options as any).name + '.' : ''
|
|
3374
|
+
if (options.validate === false)
|
|
3375
|
+
return data
|
|
3376
|
+
const res: Document = {}
|
|
3377
|
+
for (const name in this.model) {
|
|
3378
|
+
const field = this.model[name] as FieldDescriptionInternal
|
|
3379
|
+
const paramOptions = {...options, field, name: prefix + name, model: this}
|
|
3380
|
+
const canWrite = field.canWrite(paramOptions), canRead = field.canRead(paramOptions), required = field.required?.(paramOptions)
|
|
3381
|
+
if (options.readOnly) {
|
|
3382
|
+
if (canRead === false || !field.type)
|
|
3383
|
+
continue
|
|
3384
|
+
if (data[name] !== undefined) {
|
|
3385
|
+
res[name] = data[name]
|
|
3386
|
+
continue
|
|
3387
|
+
}
|
|
3388
|
+
else if (!required)
|
|
3389
|
+
continue
|
|
3390
|
+
}
|
|
3391
|
+
let v = canWrite === false ? undefined : data[name]
|
|
3392
|
+
if (v === undefined) {
|
|
3393
|
+
if (options.default !== false && field.default)
|
|
3394
|
+
v = field.default.length ? field.default(paramOptions) : (field.default as Function)()
|
|
3395
|
+
if (v !== undefined) {
|
|
3396
|
+
res[name] = v
|
|
3397
|
+
continue
|
|
3398
|
+
}
|
|
3399
|
+
if (required && canWrite !== false && (!options.insert || name !== '_id'))
|
|
3400
|
+
throw new InvalidData('missing ' + name, 'field')
|
|
3401
|
+
continue
|
|
3402
|
+
}
|
|
3403
|
+
if (options.readOnly) {
|
|
3404
|
+
res[name] = v
|
|
3405
|
+
continue
|
|
3406
|
+
}
|
|
3407
|
+
if (!field.type)
|
|
3408
|
+
continue
|
|
3409
|
+
res[name] = this._validateField(v, paramOptions)
|
|
3410
|
+
}
|
|
3411
|
+
return res
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
private _fieldFunction(value: any, def?: any): ModelCallbackFunc {
|
|
3415
|
+
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
|
|
3416
|
+
const names = value.slice(2, -1).split('.')
|
|
3417
|
+
if (names.length === 1) {
|
|
3418
|
+
const n = names[0]
|
|
3419
|
+
if (n === 'now' || n === 'Date')
|
|
3420
|
+
return () => new Date()
|
|
3421
|
+
if (n === 'ObjectId')
|
|
3422
|
+
return () => newObjectId()
|
|
3423
|
+
return (options: any) => options[n] ?? def
|
|
3424
|
+
}
|
|
3425
|
+
return (options: any) => names.reduce((p, n) => p = typeof p === 'object' ? p[n] : undefined, options) ?? def
|
|
3426
|
+
}
|
|
3427
|
+
if (value === undefined)
|
|
3428
|
+
return () => def
|
|
3429
|
+
return () => value
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
private _validateField (value: any, options: ModelValidateFieldOptions): any {
|
|
3433
|
+
const field: FieldDescriptionInternal = options.field
|
|
3434
|
+
if (value == null) {
|
|
3435
|
+
if (field.required?.(options) && (!options.insert || options.name !== '_id'))
|
|
3436
|
+
throw new InvalidData('missing ' + options.name, 'field')
|
|
3437
|
+
return null
|
|
3438
|
+
}
|
|
3439
|
+
return field.validate(value, options)
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
/** Generate filter for data queries */
|
|
3443
|
+
getFilter (data: Document, options?: ModelValidateOptions): Document {
|
|
3444
|
+
const res: Document = {}
|
|
3445
|
+
if (data._id)
|
|
3446
|
+
res._id = data._id
|
|
3447
|
+
for (const name in this.model) {
|
|
3448
|
+
if (!(name in res)) {
|
|
3449
|
+
const field = this.model[name] as FieldDescriptionInternal
|
|
3450
|
+
const paramOptions = {...options, field, name, model: this}
|
|
3451
|
+
if ((!options?.required && name in data) || (field.required && field.default)) {
|
|
3452
|
+
if (typeof field.required === 'function' && field.required(paramOptions) && field.default && (!(name in data) || field.canWrite(options) === false))
|
|
3453
|
+
res[name] = options?.default !== false ? field.default.length ? field.default(paramOptions) : (field.default as Function)() : data[name]
|
|
3454
|
+
else if (name in data)
|
|
3455
|
+
res[name] = options?.validate !== false ? this._validateField(data[name], paramOptions) : data[name]
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
if (typeof options?.projection === 'object')
|
|
3460
|
+
for (const name in options.projection) {
|
|
3461
|
+
if (name !== '_id' && name in this.model && !res[name])
|
|
3462
|
+
res[name] = options.projection[name]
|
|
3463
|
+
}
|
|
3464
|
+
return res
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
/** Find one document */
|
|
3468
|
+
async findOne (query: Query, options?: ModelValidateOptions): Promise<Document|undefined> {
|
|
3469
|
+
if (this.collection instanceof Promise)
|
|
3470
|
+
this.collection = await this.collection
|
|
3471
|
+
if (!this.collection)
|
|
3472
|
+
throw new AccessDenied('Database not configured')
|
|
3473
|
+
const doc = await this.collection.findOne(this.getFilter(query, {readOnly: true, ...options}))
|
|
3474
|
+
return doc ? this.validate(doc, {readOnly: true}) : undefined
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
/** Find many documents */
|
|
3478
|
+
async findMany (query: Query, options?: ModelValidateOptions): Promise<Document[]> {
|
|
3479
|
+
if (this.collection instanceof Promise)
|
|
3480
|
+
this.collection = await this.collection
|
|
3481
|
+
if (!this.collection)
|
|
3482
|
+
throw new AccessDenied('Database not configured')
|
|
3483
|
+
const res: Document[] = []
|
|
3484
|
+
await this.collection.find(this.getFilter(query || {}, options)).forEach((doc: Document) => res.push(this.validate(doc, {readOnly: true})))
|
|
3485
|
+
return res
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
/** Insert a new document */
|
|
3489
|
+
async insert (data: Document, options?: ModelValidateOptions): Promise<void> {
|
|
3490
|
+
return this.update(data, {...options, insert: true})
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/** Update one matching document */
|
|
3494
|
+
async update (query: Query, options?: ModelValidateOptions): Promise<void> {
|
|
3495
|
+
if (this.collection instanceof Promise)
|
|
3496
|
+
this.collection = await this.collection
|
|
3497
|
+
if (!this.collection)
|
|
3498
|
+
throw new AccessDenied('Database not configured')
|
|
3499
|
+
if (options?.validate !== false)
|
|
3500
|
+
query = this.validate(query, options)
|
|
3501
|
+
const unset: {[key: string]: number} = query.$unset || {}
|
|
3502
|
+
for (const n in query) {
|
|
3503
|
+
if (query[n] === undefined || query[n] === null) {
|
|
3504
|
+
query.$unset = unset
|
|
3505
|
+
unset[n] = 1
|
|
3506
|
+
delete query[n]
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
const res = await this.collection.findAndModify({query: this.getFilter(query, {required: true, validate: false, default: false}), update: query, upsert: options?.insert})
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
/** Delete one matching document */
|
|
3513
|
+
async delete (query: Query, options?: ModelValidateOptions): Promise<void> {
|
|
3514
|
+
if (this.collection instanceof Promise)
|
|
3515
|
+
this.collection = await this.collection
|
|
3516
|
+
if (!this.collection)
|
|
3517
|
+
throw new AccessDenied('Database not configured')
|
|
3518
|
+
if (query._id)
|
|
3519
|
+
await this.collection.deleteOne(this.getFilter(query, options))
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/** Microserver middleware */
|
|
3523
|
+
handler (req: ServerRequest, res: ServerResponse): any {
|
|
3524
|
+
res.isJson = true
|
|
3525
|
+
let filter: Query | undefined, filterStr: string | undefined = req.query.filter
|
|
3526
|
+
if (filterStr) {
|
|
3527
|
+
try {
|
|
3528
|
+
if (!filterStr.startsWith('{'))
|
|
3529
|
+
filterStr = Buffer.from(filterStr, 'base64').toString('utf-8')
|
|
3530
|
+
filter = JSON.parse(filterStr)
|
|
3531
|
+
} catch {
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
switch (req.method) {
|
|
3535
|
+
case 'GET':
|
|
3536
|
+
if ('id' in req.params)
|
|
3537
|
+
return this.findOne({_id: req.params.id}, {user: req.user, params: req.params, projection: filter}).then(res => ({data: res}))
|
|
3538
|
+
return this.findMany({}, {user: req.user, params: req.params, projection: filter}).then(res => ({data: res}))
|
|
3539
|
+
case 'POST':
|
|
3540
|
+
if (!req.body)
|
|
3541
|
+
return res.error(422)
|
|
3542
|
+
return this.update(req.body, {user: req.user, params: req.params, insert: true, projection: filter}).then(res => ({data: res}))
|
|
3543
|
+
case 'PUT':
|
|
3544
|
+
if (!req.body)
|
|
3545
|
+
return res.error(422)
|
|
3546
|
+
req.body._id = req.params.id
|
|
3547
|
+
return this.update(req.body, {user: req.user, params: req.params, insert: false, projection: filter}).then(res => ({data: res}))
|
|
3548
|
+
case 'DELETE':
|
|
3549
|
+
return this.delete({_id: req.params.id}, {user: req.user, params: req.params, projection: filter}).then(res => ({data: res}))
|
|
3550
|
+
default:
|
|
3551
|
+
return res.error(422)
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
export declare interface MicroCollectionOptions {
|
|
3557
|
+
/** Collection name */
|
|
3558
|
+
name?: string
|
|
3559
|
+
/** Collection persistent store */
|
|
3560
|
+
store?: FileStore
|
|
3561
|
+
/** Custom data loader */
|
|
3562
|
+
load?: (col: MicroCollection) => Promise<object>
|
|
3563
|
+
/** Custom data saver */
|
|
3564
|
+
save?: (id: string, doc: Document | undefined, col: MicroCollection) => Promise<Document>
|
|
3565
|
+
/** Preloaded data object */
|
|
3566
|
+
data?: {[key: string]: Document}
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
export declare interface Query {
|
|
3570
|
+
[key: string]: any
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
export declare interface Document {
|
|
3574
|
+
[key: string]: any
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/** Cursor */
|
|
3578
|
+
export declare interface Cursor {
|
|
3579
|
+
forEach (cb: Function, self?: any): Promise<number>
|
|
3580
|
+
all (): Promise<Document[]>
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** Find options */
|
|
3584
|
+
export declare interface FindOptions {
|
|
3585
|
+
/** Query */
|
|
3586
|
+
query?: Query
|
|
3587
|
+
/** is upsert */
|
|
3588
|
+
upsert?: boolean
|
|
3589
|
+
/** is new */
|
|
3590
|
+
new?: boolean
|
|
3591
|
+
/** update object */
|
|
3592
|
+
update?: Query
|
|
3593
|
+
/** maximum number of hits */
|
|
3594
|
+
limit?: number
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
/** Collection factory */
|
|
3598
|
+
class MicroCollections implements ModelCollections {
|
|
3599
|
+
protected options: MicroCollectionOptions
|
|
3600
|
+
constructor (options: MicroCollectionOptions) {
|
|
3601
|
+
this.options = options
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
/** Get collection */
|
|
3605
|
+
async collection(name: string): Promise<MicroCollection> {
|
|
3606
|
+
return new MicroCollection({...this.options, name})
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
/** minimalistic indexed mongo type collection with persistance for usage with Model */
|
|
3611
|
+
export class MicroCollection {
|
|
3612
|
+
/** Collection name */
|
|
3613
|
+
public name: string
|
|
3614
|
+
/** Collection data */
|
|
3615
|
+
public data: {[key: string]: Document}
|
|
3616
|
+
|
|
3617
|
+
private _ready: Promise<void> | undefined
|
|
3618
|
+
private _save?: (id: string, doc: Document | undefined, col: MicroCollection) => Promise<Document>
|
|
3619
|
+
|
|
3620
|
+
/** Get collections factory */
|
|
3621
|
+
static collections(options: MicroCollectionOptions): ModelCollections {
|
|
3622
|
+
return new MicroCollections(options)
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
constructor(options: MicroCollectionOptions = {}) {
|
|
3626
|
+
this.name = options.name || this.constructor.name
|
|
3627
|
+
const load = options.load ?? (options.store && ((col: MicroCollection) => options.store?.load(col.name, true)))
|
|
3628
|
+
this.data = options.data || {}
|
|
3629
|
+
this._save = options.save
|
|
3630
|
+
this._ready = load?.(this)?.catch(() => {}).then(data => {
|
|
3631
|
+
this.data = data || {}
|
|
3632
|
+
})
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/** check for collection is ready */
|
|
3636
|
+
protected async checkReady() {
|
|
3637
|
+
if (this._ready) {
|
|
3638
|
+
await this._ready
|
|
3639
|
+
this._ready = undefined
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
/** Query document with query filter */
|
|
3644
|
+
protected queryDocument(query?: Query, data?: Document) {
|
|
3645
|
+
if (query && data)
|
|
3646
|
+
for (const n in query) {
|
|
3647
|
+
if (query[n] === null)
|
|
3648
|
+
if (data[n] != null)
|
|
3649
|
+
return
|
|
3650
|
+
else
|
|
3651
|
+
continue
|
|
3652
|
+
if (n.startsWith('$') || typeof query[n] === 'object')
|
|
3653
|
+
console.warn(`Invalid query field: ${n}`)
|
|
3654
|
+
if (data[n] !== query[n])
|
|
3655
|
+
return
|
|
3656
|
+
}
|
|
3657
|
+
return data
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
/** Count all documents */
|
|
3661
|
+
async count(): Promise<number> {
|
|
3662
|
+
await this.checkReady()
|
|
3663
|
+
return Object.keys(this.data).length
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
/** Find one matching document */
|
|
3667
|
+
async findOne(query: Query): Promise<Document|undefined> {
|
|
3668
|
+
await this.checkReady()
|
|
3669
|
+
const id: string = query._id
|
|
3670
|
+
if (id)
|
|
3671
|
+
return this.queryDocument(query, this.data[id])
|
|
3672
|
+
let res
|
|
3673
|
+
await this.find(query).forEach((doc: Document) => (res = doc) && false)
|
|
3674
|
+
return res
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
/** Find all matching documents */
|
|
3678
|
+
find(query: Query): Cursor {
|
|
3679
|
+
return {
|
|
3680
|
+
forEach: async (cb: (doc: Document) => boolean | void, self?: any): Promise<number> => {
|
|
3681
|
+
await this._ready
|
|
3682
|
+
let count: number = 0
|
|
3683
|
+
for (const id in this.data)
|
|
3684
|
+
if (this.queryDocument(query, this.data[id])) {
|
|
3685
|
+
count++
|
|
3686
|
+
if (cb.call(self ?? this, this.data[id]) === false)
|
|
3687
|
+
break
|
|
3688
|
+
if (query.limit && count >= query.limit)
|
|
3689
|
+
break
|
|
3690
|
+
}
|
|
3691
|
+
return count
|
|
3692
|
+
},
|
|
3693
|
+
all: async (): Promise<Document[]> => {
|
|
3694
|
+
await this._ready
|
|
3695
|
+
return Object.values(this.data).filter(doc => this.queryDocument(query, doc))
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/** Find and modify one matching document */
|
|
3701
|
+
async findAndModify(options: FindOptions): Promise<number> {
|
|
3702
|
+
if (!options.query)
|
|
3703
|
+
return 0
|
|
3704
|
+
await this.checkReady()
|
|
3705
|
+
const id = ((options.upsert || options.new) && !options.query._id) ? newObjectId() : options.query._id
|
|
3706
|
+
if (!id) {
|
|
3707
|
+
let count: number = 0
|
|
3708
|
+
this.find(options.query).forEach((doc: Document) => {
|
|
3709
|
+
if (this.queryDocument(options.query, doc)) {
|
|
3710
|
+
Object.assign(doc, options.update)
|
|
3711
|
+
if (this._save) {
|
|
3712
|
+
if (!this._ready)
|
|
3713
|
+
this._ready = Promise.resolve()
|
|
3714
|
+
this._ready = this._ready.then(async () => {
|
|
3715
|
+
this.data[doc._id] = await this._save?.(doc._id, doc, this) || this.data[doc._id]
|
|
3716
|
+
})
|
|
3717
|
+
}
|
|
3718
|
+
count++
|
|
3719
|
+
if (options.limit && count >= options.limit)
|
|
3720
|
+
return false
|
|
3721
|
+
}
|
|
3722
|
+
})
|
|
3723
|
+
return count
|
|
3724
|
+
}
|
|
3725
|
+
let doc = this.queryDocument(options.query, this.data[id])
|
|
3726
|
+
if (!doc) {
|
|
3727
|
+
if (!options.upsert && !options.new)
|
|
3728
|
+
throw new InvalidData(`Document not found`)
|
|
3729
|
+
doc = {_id: id}
|
|
3730
|
+
this.data[id] = doc
|
|
3731
|
+
} else {
|
|
3732
|
+
if (options.new)
|
|
3733
|
+
throw new InvalidData(`Document dupplicate`)
|
|
3734
|
+
}
|
|
3735
|
+
if (options.update) {
|
|
3736
|
+
for (const n in options.update) {
|
|
3737
|
+
if (!n.startsWith('$'))
|
|
3738
|
+
doc[n] = options.update[n]
|
|
3739
|
+
}
|
|
3740
|
+
if (options.update.$unset) {
|
|
3741
|
+
for (const n in options.update.$unset)
|
|
3742
|
+
delete doc[n]
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
if (this._save)
|
|
3746
|
+
this.data[id] = await this._save(id, doc, this) || doc
|
|
3747
|
+
return 1
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
/** Insert one document */
|
|
3751
|
+
async insertOne(doc: Document): Promise<Document> {
|
|
3752
|
+
await this.checkReady()
|
|
3753
|
+
if (doc._id && this.data[doc._id])
|
|
3754
|
+
throw new InvalidData(`Document ${doc._id} dupplicate`)
|
|
3755
|
+
if (!doc._id)
|
|
3756
|
+
doc._id = {_id: newObjectId(), ...doc}
|
|
3757
|
+
else
|
|
3758
|
+
doc = {...doc}
|
|
3759
|
+
this.data[doc._id] = doc
|
|
3760
|
+
if (this._save)
|
|
3761
|
+
this.data[doc._id] = doc = await this._save(doc._id, doc, this) || doc
|
|
3762
|
+
return doc
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
/** Insert multiple documents */
|
|
3766
|
+
async insert(docs: Document[]): Promise<Document[]> {
|
|
3767
|
+
await this.checkReady()
|
|
3768
|
+
docs.forEach(doc => {
|
|
3769
|
+
if (doc._id && this.data[doc._id])
|
|
3770
|
+
throw new InvalidData(`Document ${doc._id} dupplicate`)
|
|
3771
|
+
})
|
|
3772
|
+
for (let i = 0; i < docs.length; i++)
|
|
3773
|
+
docs[i] = await this.insertOne(docs[i])
|
|
3774
|
+
return docs
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
/** Delete one matching document */
|
|
3778
|
+
async deleteOne(query: Query): Promise<void> {
|
|
3779
|
+
const id = query._id
|
|
3780
|
+
if (!id)
|
|
3781
|
+
return
|
|
3782
|
+
await this.checkReady()
|
|
3783
|
+
delete this.data[id]
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
/** Delete all matching documents */
|
|
3787
|
+
async deleteMany(query: Query): Promise<number> {
|
|
3788
|
+
let count: number = 0
|
|
3789
|
+
await this.checkReady()
|
|
3790
|
+
this.find(query).forEach((doc: Document) => {
|
|
3791
|
+
if (this.queryDocument(query, doc)) {
|
|
3792
|
+
count++
|
|
3793
|
+
delete this.data[doc._id]
|
|
3794
|
+
if (this._save) {
|
|
3795
|
+
if (!this._ready)
|
|
3796
|
+
this._ready = Promise.resolve()
|
|
3797
|
+
this._ready = this._ready.then(async () => {this._save?.(doc._id, undefined, this)})
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
})
|
|
3801
|
+
return count
|
|
3802
|
+
}
|
|
3803
|
+
}
|